diff --git a/.github/workflows/docker-server.yml b/.github/workflows/docker-server.yml index ab242f60..8a284da8 100644 --- a/.github/workflows/docker-server.yml +++ b/.github/workflows/docker-server.yml @@ -74,8 +74,8 @@ jobs: id: build uses: docker/build-push-action@v7 with: - context: . - file: ./openflare-server/Dockerfile + context: ./openflare-server + file: ./docker/Dockerfile platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true build-args: | diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 6a2b0c7d..1146ddb6 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -17,14 +17,14 @@ jobs: env: CI: "" run: | - cd openflare-server/web + cd openflare-server/frontend corepack enable pnpm install --frozen-lockfile - pnpm build + pnpm build:embed - name: Deploy 🚀 uses: JamesIves/github-pages-deploy-action@releases/v3 with: ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} BRANCH: gh-pages # The branch the action should deploy to. - FOLDER: openflare-server/web/build # The folder the action should deploy. \ No newline at end of file + FOLDER: openflare-server/frontend/out # The folder the action should deploy. \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74aeab3b..fcc3de55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,26 +69,38 @@ jobs: with: fetch-depth: 0 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + run_install: false + - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 + cache: pnpm + cache-dependency-path: openflare-server/frontend/pnpm-lock.yaml - name: Build Frontend + working-directory: openflare-server/frontend env: CI: "" - VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} run: | - cd openflare-server/web - corepack enable pnpm install --frozen-lockfile - NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build + pnpm build:embed + + - name: Prepare embed directory + run: | + rm -rf openflare-server/internal/router/root/dist + cp -R openflare-server/frontend/out openflare-server/internal/router/root/dist - name: Upload Frontend Artifact uses: actions/upload-artifact@v4 with: name: frontend-build - path: openflare-server/web/build + path: openflare-server/internal/router/root/dist retention-days: 1 build-binaries: @@ -127,14 +139,16 @@ jobs: uses: actions/download-artifact@v4 with: name: frontend-build - path: openflare-server/web/build + path: openflare-server/internal/router/root/dist - name: Set up Go uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version-file: openflare-server/go.mod + cache-dependency-path: openflare-server/go.sum - name: Build Server + working-directory: openflare-server env: CGO_ENABLED: 0 GOOS: ${{ matrix.goos }} @@ -143,8 +157,10 @@ jobs: VERSION: ${{ needs.prepare.outputs.version }} run: | go mod download - mkdir -p dist - go build -trimpath -ldflags "-s -w -X 'github.com/rain-kl/openflare/openflare-server/internal/common.Version=$VERSION'" -o "dist/$ASSET_NAME" ./openflare-server/cmd/server + mkdir -p ../dist + go build -trimpath -tags embed_frontend \ + -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION'" \ + -o "../dist/$ASSET_NAME" ./main.go - name: Upload Binary Artifact uses: actions/upload-artifact@v4 diff --git a/.grok/hooks/dmux-hooks.json b/.grok/hooks/dmux-hooks.json new file mode 100644 index 00000000..2e0cfa2e --- /dev/null +++ b/.grok/hooks/dmux-hooks.json @@ -0,0 +1,35 @@ +{ + "description": "dmux pane status hooks for Grok Build", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node '/Users/ryan/DEV/Go/OpenFlare/.dmux/worktrees/dmux-2026-06-19-110554/.grok/hooks/dmux-status-hook.cjs'", + "timeout": 5, + "env": { + "DMUX_PANE_ID": "dmux-1781838355207", + "DMUX_TMUX_PANE_ID": "%28" + } + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "node '/Users/ryan/DEV/Go/OpenFlare/.dmux/worktrees/dmux-2026-06-19-110554/.grok/hooks/dmux-status-hook.cjs'", + "timeout": 5, + "env": { + "DMUX_PANE_ID": "dmux-1781838355207", + "DMUX_TMUX_PANE_ID": "%28" + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.grok/hooks/dmux-status-hook.cjs b/.grok/hooks/dmux-status-hook.cjs new file mode 100755 index 00000000..185d6701 --- /dev/null +++ b/.grok/hooks/dmux-status-hook.cjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +const fs = require('fs'); + +function normalizeHookEventName(value) { + const raw = String(value || ''); + const normalized = raw.trim().toLowerCase().replace(/-/g, '_'); + switch (normalized) { + case 'stop': + return 'Stop'; + case 'notification': + return 'Notification'; + case 'user_prompt_submit': + case 'userpromptsubmit': + return 'UserPromptSubmit'; + case 'pre_tool_use': + case 'pretooluse': + return 'PreToolUse'; + case 'post_tool_use': + case 'posttooluse': + return 'PostToolUse'; + case 'post_tool_use_failure': + case 'posttoolusefailure': + return 'PostToolUseFailure'; + case 'session_start': + case 'sessionstart': + return 'SessionStart'; + case 'session_end': + case 'sessionend': + return 'SessionEnd'; + default: + return raw; + } +} + +function stringValue(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value; + } + } + return ''; +} + +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + let payload = {}; + try { + payload = input.trim() ? JSON.parse(input) : {}; + } catch (error) { + payload = { parse_error: String(error), raw: input }; + } + + const hookEventName = normalizeHookEventName( + payload.hookEventName || payload.hook_event_name || process.env.GROK_HOOK_EVENT + ); + const sessionId = stringValue( + payload.sessionId, + payload.session_id, + process.env.GROK_SESSION_ID + ); + const message = stringValue( + payload.lastAssistantMessage, + payload.last_assistant_message, + payload.message, + payload.notificationMessage, + payload.notification_message + ); + + const event = { + source: 'grok-status-hook', + dmuxPaneId: process.env.DMUX_PANE_ID || '', + tmuxPaneId: process.env.DMUX_TMUX_PANE_ID || '', + expectedDmuxPaneId: 'dmux-1781838355207', + expectedTmuxPaneId: '%28', + hookEventName, + sessionId, + turnId: stringValue(payload.turnId, payload.turn_id, sessionId), + lastAssistantMessage: message || null, + transcriptPath: stringValue(payload.transcriptPath, payload.transcript_path) || null, + cwd: stringValue(payload.cwd, payload.workspaceRoot, process.env.GROK_WORKSPACE_ROOT) || process.cwd(), + timestamp: Date.now() + }; + + if (event.dmuxPaneId !== event.expectedDmuxPaneId) { + process.exit(0); + } + + try { + fs.writeFileSync('/Users/ryan/DEV/Go/OpenFlare/.dmux/worktrees/dmux-2026-06-19-110554/.grok/dmux/dmux-1781838355207.json', JSON.stringify(event, null, 2)); + } catch (error) { + process.exit(0); + } +}); diff --git a/Wavelet/.dockerignore b/Wavelet/.dockerignore deleted file mode 100644 index 65420584..00000000 --- a/Wavelet/.dockerignore +++ /dev/null @@ -1,30 +0,0 @@ -.git -.idea -.vscode -.DS_Store -Thumbs.db - -config.yaml -.env -.env.* - -bin/ -build/ -dist/ -data/ -logs/ -uploads/ -s3_cache/ - -frontend/node_modules/ -frontend/.next/ -frontend/out/ -frontend/build/ -frontend/disk/ -frontend/.env -frontend/next-env.d.ts -frontend/*.tsbuildinfo -frontend/package-lock.json - -internal/router/dist/ -internal/router/root/dist/ diff --git a/Wavelet/.gitignore b/Wavelet/.gitignore deleted file mode 100644 index 47f0d767..00000000 --- a/Wavelet/.gitignore +++ /dev/null @@ -1,61 +0,0 @@ -# IDE -.idea/* -.idea -!.idea/icon.png -.vscode -.pnpm-store/ - -# logs -/logs/ -*.log - -# config -config.yaml -.env - -# sqlite -*.db -*.db-journal -*.db-shm -*.db-wal - -# frontend -frontend/build -frontend/disk -frontend/node_modules/* -frontend/.next/* -frontend/next-env.d.ts -frontend/package-lock.json -frontend/.env -.env.* -!.env.example -*.tsbuildinfo - -# os -.DS_Store -Thumbs.db - -# build -/build/ -/bin/ -/dist/ - -# go workspace -go.work -go.work.sum -main - -# upload -uploads/* - -s3_cache -/frontend/.next/ -/data/ -/internal/router/dist/ -/frontend/out/ -/.idea/ -/uploads/ -/*-source/ -/.cache/ -/internal/router/root/dist/ -.dmux/ diff --git a/Wavelet/docs/docs.go b/Wavelet/docs/docs.go deleted file mode 100644 index d553c93d..00000000 --- a/Wavelet/docs/docs.go +++ /dev/null @@ -1,17153 +0,0 @@ -// Package docs Code generated by swaggo/swag. DO NOT EDIT -package docs - -import "github.com/swaggo/swag" - -const docTemplate = `{ - "schemes": {{ marshal .Schemes }}, - "swagger": "2.0", - "info": { - "description": "{{escape .Description}}", - "title": "{{.Title}}", - "contact": { - "name": "OpenFlare", - "url": "https://github.com/Rain-kl/OpenFlare" - }, - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "version": "{{.Version}}" - }, - "host": "{{.Host}}", - "basePath": "{{.BasePath}}", - "paths": { - "/api/cap/challenge": { - "post": { - "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "cap" - ], - "summary": "生成人机验证难题", - "parameters": [ - { - "description": "可选范围限制参数", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/cap.challengeRequest" - } - } - ], - "responses": { - "200": { - "description": "成功返回 PoW 难题", - "schema": { - "$ref": "#/definitions/cap.ChallengeResponse" - } - }, - "500": { - "description": "内部服务错误", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - } - } - } - }, - "/api/cap/redeem": { - "post": { - "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "cap" - ], - "summary": "校验人机验证解答", - "parameters": [ - { - "description": "难题 Token 与解答 solutions 数组", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/cap.redeemRequest" - } - } - ], - "responses": { - "200": { - "description": "核销成功,返回 X-Cap-Token", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - }, - "400": { - "description": "参数错误或核销失败", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - }, - "500": { - "description": "内部服务错误", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - } - } - } - }, - "/api/health": { - "get": { - "description": "检查服务是否正常运行,可用于负载均衡存活探测", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "健康检查", - "responses": { - "200": { - "description": "服务正常", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/auth-sources": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取认证源列表", - "responses": { - "200": { - "description": "认证源列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建认证源", - "parameters": [ - { - "description": "创建认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功,返回认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或验证失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/auth-sources/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新认证源", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "认证源 ID 或名称", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功,返回更新后的认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或验证失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除认证源", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "认证源 ID 或名称", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "ID 无效或删除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/auth-sources/{id}/toggle": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "切换认证源启用状态", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "认证源 ID 或名称", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "启用状态", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "切换成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "验证失败或认证源不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/cache/clear": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "清空缓存", - "responses": { - "200": { - "description": "清理成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/cache/config": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新缓存配置", - "parameters": [ - { - "description": "缓存配置请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/cache.updateCacheConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/cache/status": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取缓存状态", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/diskcache.Status" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-export": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "admin" - ], - "summary": "导出数据库", - "responses": { - "200": { - "description": "数据库文件", - "schema": { - "type": "file" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "导出失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据库信息", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/status.DatabaseInfoResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-manage/overview": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据库运行概览", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/db_manage.DBOverviewResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-manage/query": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "执行 SQL 查询", - "parameters": [ - { - "description": "SQL 请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/db_manage.ExecuteSQLRequest" - } - } - ], - "responses": { - "200": { - "description": "执行完毕", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/db_manage.ExecuteSQLResponse" - } - } - } - ] - } - }, - "400": { - "description": "SQL 语句错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-manage/tables": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前数据库的所有用户自定义表名称列表,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据库所有表名", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取系统历史日志,cursor=0 获取最新日志,cursor\u003e0 获取更早日志", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取系统日志", - "parameters": [ - { - "type": "integer", - "default": 0, - "description": "日志游标,0=获取最新", - "name": "cursor", - "in": "query" - }, - { - "type": "integer", - "default": 200, - "description": "每页条数", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/logs.logsResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs/access": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取用户访问日志", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "页码", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "用户名模糊搜索", - "name": "username", - "in": "query" - }, - { - "type": "string", - "description": "接口路径模糊搜索", - "name": "path", - "in": "query" - }, - { - "type": "string", - "description": "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", - "name": "start_time", - "in": "query" - }, - { - "type": "string", - "description": "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", - "name": "end_time", - "in": "query" - } - ], - "responses": { - "200": { - "description": "访问日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/logs.accessLogsResponse" - } - } - } - ] - } - }, - "400": { - "description": "ClickHouse 未启用或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs/analytics": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取访问日志分析数据", - "responses": { - "200": { - "description": "分析统计数据", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/logs.logsAnalyticsResponse" - } - } - } - ] - } - }, - "400": { - "description": "ClickHouse 未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs/ws": { - "get": { - "description": "通过 WebSocket 实时推送系统日志,需要管理员权限", - "tags": [ - "admin" - ], - "summary": "系统日志实时推送", - "responses": {} - } - }, - "/api/v1/admin/push/channels": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统配置的所有消息通道列表,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有消息通道", - "responses": { - "200": { - "description": "消息通道列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushChannel" - } - } - } - } - ] - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "新建一个消息通道配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "创建消息通道", - "parameters": [ - { - "description": "创建参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.CreateChannelRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.PushChannel" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/channels/definitions": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有消息通道配置字段定义", - "responses": { - "200": { - "description": "通道配置定义列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/push.Definition" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/channels/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "触发一次临时的或现有的通道连通性推送测试,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "测试通道连通性", - "parameters": [ - { - "description": "测试参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.TestChannelRequest" - } - } - ], - "responses": { - "200": { - "description": "测试触发成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/push/channels/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "修改消息通道配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "更新消息通道", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "通道ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.UpdateChannelRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.PushChannel" - } - } - } - ] - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据ID删除消息通道,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "删除消息通道", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "通道ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/push/events": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有通知事件", - "responses": { - "200": { - "description": "通知事件列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushEvent" - } - } - } - } - ] - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "创建通知事件", - "parameters": [ - { - "description": "创建参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.CreateEventRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.PushEvent" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/events/builtin": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有内置通知事件", - "responses": { - "200": { - "description": "内置通知事件列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/push.EventMetadata" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/events/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "更新通知事件", - "parameters": [ - { - "type": "integer", - "description": "事件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.UpdateEventRequest" - } - } - ], - "responses": { - "200": { - "description": "修改成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除数据库中的特定通知事件配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "删除通知事件配置", - "parameters": [ - { - "type": "integer", - "description": "事件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/events/{id}/toggle": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "启用或禁用指定的通知事件", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "快捷切换通知事件启用状态", - "parameters": [ - { - "type": "integer", - "description": "事件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "切换成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/histories": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回分页的通知历史日志数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "分页获取通知推送历史", - "parameters": [ - { - "type": "integer", - "description": "当前页码", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "分页大小", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "过滤事件名称", - "name": "event_key", - "in": "query" - }, - { - "type": "string", - "description": "过滤发送状态", - "name": "status", - "in": "query" - } - ], - "responses": { - "200": { - "description": "推送历史列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/push.pushHistoriesResponse" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "测试推送通道发送", - "parameters": [ - { - "description": "测试请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.TestPushRequest" - } - } - ], - "responses": { - "200": { - "description": "测试成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/status": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取系统状态信息", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/status.SystemStatusResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/system-configs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取系统配置列表", - "parameters": [ - { - "type": "string", - "description": "配置类型(system/business)", - "name": "type", - "in": "query" - } - ], - "responses": { - "200": { - "description": "系统配置列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.SystemConfig" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建系统配置", - "parameters": [ - { - "description": "创建请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/system_config.CreateSystemConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或配置键已存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/system-configs/smtp/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "测试 SMTP 邮件发送", - "parameters": [ - { - "description": "测试请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/system_config.TestSMTPRequest" - } - } - ], - "responses": { - "200": { - "description": "测试执行完毕", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/system_config.TestSMTPResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/system-configs/{key}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据配置键获取对应的系统配置详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取单个系统配置", - "parameters": [ - { - "type": "string", - "description": "配置键", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "系统配置详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.SystemConfig" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "配置不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新系统配置", - "parameters": [ - { - "type": "string", - "description": "配置键", - "name": "key", - "in": "path", - "required": true - }, - { - "description": "更新请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "配置不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/dispatch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "下发异步任务", - "parameters": [ - { - "description": "任务请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/task.DispatchTaskRequest" - } - } - ], - "responses": { - "200": { - "description": "任务已入队", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "任务类型不存在或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "任务入队失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/executions": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "查询任务执行记录", - "parameters": [ - { - "type": "string", - "description": "状态筛选 (pending/running/succeeded/failed)", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "任务类型筛选", - "name": "task_type", - "in": "query" - }, - { - "type": "integer", - "default": 1, - "description": "页码", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "每页条数", - "name": "page_size", - "in": "query" - } - ], - "responses": { - "200": { - "description": "任务执行记录列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/executions/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "查询任务执行详情", - "parameters": [ - { - "type": "integer", - "description": "任务执行记录 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "任务执行详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TaskExecution" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/executions/{id}/retry": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "重新下发一条失败的任务,创建新的执行记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "重试失败任务", - "parameters": [ - { - "type": "integer", - "description": "任务执行记录 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "新任务的 TaskID", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "任务不支持重试或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "重试失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/schedules": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取定时任务列表", - "responses": { - "200": { - "description": "定时任务列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Schedule" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建定时任务", - "parameters": [ - { - "description": "创建定时任务请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/task.CreateScheduleRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功的定时任务信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Schedule" - } - } - } - ] - } - }, - "400": { - "description": "Cron 表达式无效、异步任务类型不存在或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "保存定时任务失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/schedules/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "修改定时任务", - "parameters": [ - { - "type": "integer", - "description": "定时任务 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "修改定时任务请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/task.UpdateScheduleRequest" - } - } - ], - "responses": { - "200": { - "description": "修改后的定时任务信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Schedule" - } - } - } - ] - } - }, - "400": { - "description": "Cron 表达式无效、参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "定时任务不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "修改定时任务失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定的定时任务配置,并触发调度器热加载,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除定时任务", - "parameters": [ - { - "type": "integer", - "description": "定时任务 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "删除定时任务失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/types": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取支持的任务类型", - "responses": { - "200": { - "description": "任务类型列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/task.TaskMeta" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/templates": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有通知模板列表,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取模板列表", - "responses": { - "200": { - "description": "模板列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Template" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建模板", - "parameters": [ - { - "description": "创建请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/template.CreateTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或模板标识符已存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/templates/{key}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据模板标识符获取对应的模板详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取单个模板", - "parameters": [ - { - "type": "string", - "description": "模板标识符", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "模板详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Template" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "模板不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据模板标识符更新对应的模板内容,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新模板", - "parameters": [ - { - "type": "string", - "description": "模板标识符", - "name": "key", - "in": "path", - "required": true - }, - { - "description": "更新请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/template.UpdateTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Template" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "模板不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除模板", - "parameters": [ - { - "type": "string", - "description": "模板标识符", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "不可删除系统模板", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "模板不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/update": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取应用更新状态", - "responses": { - "200": { - "description": "更新状态", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/updater.Status" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "查询失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/update/apply": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "下载并应用应用更新", - "responses": { - "200": { - "description": "升级已准备并即将重启", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "当前版本不可升级", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "升级准备失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取文件列表", - "parameters": [ - { - "type": "integer", - "description": "页码(默认 1)", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(默认 20,最大 100)", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "文件名关键词(模糊匹配)", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "description": "业务分类过滤", - "name": "type", - "in": "query" - }, - { - "type": "string", - "description": "扩展名过滤", - "name": "extension", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "上传用户 ID", - "name": "user_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.listFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/download/batch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", - "consumes": [ - "application/json" - ], - "produces": [ - "application/octet-stream" - ], - "tags": [ - "admin" - ], - "summary": "批量打包下载", - "parameters": [ - { - "description": "包含文件 ID 数组 of string 的请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.batchDownloadRequest" - } - } - ], - "responses": { - "200": { - "description": "成功下载打包后的 ZIP", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "打包失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/download/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "admin" - ], - "summary": "下载单文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "图片质量 (low, medium, high, origin),默认为 origin", - "name": "quality", - "in": "query" - } - ], - "responses": { - "200": { - "description": "成功下载文件", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/stats": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取文件统计数据", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.fileStatsResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/types": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回数据库中所有已上传文件实际拥有的业务类型列表", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取文件业务类型列表", - "responses": { - "200": { - "description": "业务类型列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/users": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取用户列表", - "parameters": [ - { - "minimum": 1, - "type": "integer", - "name": "page", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "name": "page_size", - "in": "query" - }, - { - "type": "integer", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "name": "username", - "in": "query" - } - ], - "responses": { - "200": { - "description": "用户列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.listUsersResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一个本地密码登录的新用户,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建用户", - "parameters": [ - { - "description": "创建用户参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.createUserRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.user" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或用户名已存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/users/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取用户详情", - "parameters": [ - { - "type": "integer", - "description": "用户 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "用户详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.user" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "用户不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除用户", - "parameters": [ - { - "type": "integer", - "description": "用户 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限、尝试删除管理员或当前用户", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "用户不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/users/{id}/status": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新用户状态", - "parameters": [ - { - "type": "integer", - "description": "用户 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "状态参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.updateUserStatusRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限或尝试禁用管理员", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "用户不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/config/public": { - "get": { - "description": "返回系统配置表中 visibility 为 1 的配置键值集合", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "config" - ], - "summary": "获取公共配置", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/custom/hello": { - "get": { - "description": "A sample business API for customization", - "produces": [ - "application/json" - ], - "tags": [ - "custom" - ], - "summary": "Sample Hello API", - "responses": { - "200": { - "description": "成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/d/access-logs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出访问日志", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "请求路径", - "name": "path", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "访问日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按保留天数清理过期访问日志记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "清理访问日志", - "parameters": [ - { - "description": "清理参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/observability.AccessLogCleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/folds": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按时间桶聚合访问日志并分页返回,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出折叠访问日志", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "请求路径", - "name": "path", - "in": "query" - }, - { - "type": "integer", - "description": "折叠时间窗口(分钟)", - "name": "fold_minutes", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "折叠访问日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.FoldedAccessLogList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/folds/ip-summary": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "在指定时间桶内按 IP 聚合访问统计,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出折叠访问日志 IP 汇总", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "请求路径", - "name": "path", - "in": "query" - }, - { - "type": "string", - "description": "时间桶起始时间", - "name": "bucket_started_at", - "in": "query" - }, - { - "type": "integer", - "description": "折叠时间窗口(分钟)", - "name": "fold_minutes", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "折叠 IP 汇总列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.FoldedAccessLogIPList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/ip-summary": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出访问日志 IP 汇总", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "IP 汇总列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogIPSummaryList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/ip-summary/trend": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "获取访问日志 IP 趋势", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "integer", - "description": "统计时间范围(小时)", - "name": "hours", - "in": "query" - }, - { - "type": "integer", - "description": "时间桶粒度(分钟)", - "name": "bucket_minutes", - "in": "query" - } - ], - "responses": { - "200": { - "description": "IP 访问趋势", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogIPTrendView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/acme-accounts/default": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统默认 ACME 账号配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "获取默认 ACME 账号", - "responses": { - "200": { - "description": "默认 ACME 账号", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AcmeAccount" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/apply-logs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-apply-log" - ], - "summary": "获取配置下发日志", - "parameters": [ - { - "type": "string", - "description": "节点 ID 筛选", - "name": "node_id", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "pageNo", - "in": "query" - }, - { - "type": "integer", - "description": "页码(别名)", - "name": "page_no", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量", - "name": "pageSize", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(别名)", - "name": "page_size", - "in": "query" - } - ], - "responses": { - "200": { - "description": "下发日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/apply_log.ListResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/apply-logs/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按保留天数清理历史下发记录,或删除全部记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-apply-log" - ], - "summary": "清理配置下发日志", - "parameters": [ - { - "description": "清理参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/apply_log.CleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/apply_log.CleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "获取配置版本列表", - "responses": { - "200": { - "description": "配置版本列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ConfigVersionSummary" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/active": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前正在使用的配置版本,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "获取当前活跃配置版本", - "responses": { - "200": { - "description": "活跃配置版本", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限、不存在或无活跃版本", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除超出保留数量的非活跃配置版本,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "清理历史配置版本", - "parameters": [ - { - "description": "清理参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/config_version.CleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/config_version.CleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/diff": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "对比当前草稿配置与活跃版本之间的差异,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "对比草稿与活跃配置", - "responses": { - "200": { - "description": "配置差异", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/config_version.ConfigDiffResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/preview": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "渲染并返回当前草稿配置的预览结果,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "预览当前草稿配置", - "responses": { - "200": { - "description": "配置预览", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/config_version.ConfigPreviewResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/publish": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将当前草稿配置发布为新版本,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "发布配置版本", - "parameters": [ - { - "type": "boolean", - "description": "是否强制发布", - "name": "force", - "in": "query" - } - ], - "responses": { - "200": { - "description": "发布成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定配置版本的完整快照与渲染内容,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "获取配置版本详情", - "parameters": [ - { - "type": "integer", - "description": "配置版本 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "配置版本详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或版本不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/{id}/activate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将指定历史版本设为当前活跃配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "激活配置版本", - "parameters": [ - { - "type": "integer", - "description": "配置版本 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "激活成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或版本不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dashboard/overview": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-dashboard" - ], - "summary": "获取仪表盘概览", - "responses": { - "200": { - "description": "仪表盘概览", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/dashboard.OverviewPayload" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dns-accounts": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 DNS 提供商账号,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "列出 DNS 账号", - "responses": { - "200": { - "description": "DNS 账号列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.DNSAccount" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 DNS 提供商账号,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "创建 DNS 账号", - "parameters": [ - { - "description": "DNS 账号参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.DNSAccountInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的 DNS 账号", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.DNSAccount" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dns-accounts/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 DNS 提供商账号,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "删除 DNS 账号", - "parameters": [ - { - "type": "integer", - "description": "DNS 账号 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dns-accounts/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 DNS 提供商账号,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新 DNS 账号", - "parameters": [ - { - "type": "integer", - "description": "DNS 账号 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "DNS 账号参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.DNSAccountInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的 DNS 账号", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.DNSAccount" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部托管域名及关联证书,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "列出托管域名", - "responses": { - "200": { - "description": "托管域名列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ManagedDomain" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的托管域名记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "创建托管域名", - "parameters": [ - { - "description": "托管域名参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ManagedDomainInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的托管域名", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ManagedDomain" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains/match": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按域名查询可用的证书匹配候选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "匹配托管域名证书", - "parameters": [ - { - "type": "string", - "description": "域名", - "name": "domain", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "证书匹配结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/tls.ManagedDomainMatchResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除托管域名,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "删除托管域名", - "parameters": [ - { - "type": "integer", - "description": "托管域名 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新托管域名,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新托管域名", - "parameters": [ - { - "type": "integer", - "description": "托管域名 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "托管域名参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ManagedDomainInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的托管域名", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ManagedDomain" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有节点及最新配置下发记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取节点列表", - "responses": { - "200": { - "description": "节点列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/node.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的边缘节点记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "创建节点", - "parameters": [ - { - "description": "节点参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/node.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/bootstrap-token": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全局节点发现引导令牌,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取引导令牌", - "responses": { - "200": { - "description": "引导令牌", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.BootstrapView" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/bootstrap-token/rotate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "重新生成全局节点发现引导令牌,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "轮换引导令牌", - "responses": { - "200": { - "description": "新引导令牌", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.BootstrapView" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/agent-release": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定节点可用的最新 Agent 版本信息,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取 Agent 发布信息", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "发布渠道", - "name": "channel", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Agent 发布信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.AgentReleaseInfo" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/agent-update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "向指定节点下发 Agent 自更新指令,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "请求 Agent 更新", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新参数(可选)", - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/node.AgentUpdateInput" - } - } - ], - "responses": { - "200": { - "description": "更新请求已下发", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定节点记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "删除节点", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/force-sync": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "向指定节点下发强制同步当前活跃配置的指令,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "请求强制同步配置", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "同步请求已下发", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/observability": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定节点的指标、健康事件与流量分析数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取节点可观测性数据", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "统计时间范围(小时)", - "name": "hours", - "in": "query" - }, - { - "type": "integer", - "description": "返回记录数量上限", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "可观测性数据", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.ObservabilityView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/observability/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清理指定节点的历史健康事件记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "清理节点健康事件", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.HealthEventCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/openresty-restart": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "向指定节点下发 OpenResty 重启指令,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "请求重启 OpenResty", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "重启请求已下发", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定节点的配置信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "更新节点", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "节点参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/node.Input" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/notice": { - "get": { - "description": "返回 OpenFlare 控制台公告文本,无需登录", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "获取系统公告", - "responses": { - "200": { - "description": "系统公告", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部非敏感 OpenFlare 配置项,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "列出 OpenFlare 配置项", - "responses": { - "200": { - "description": "配置项列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareOption" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/database/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按目标与保留天数清理可观测性相关数据表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "清理可观测性数据库", - "parameters": [ - { - "description": "清理参数", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/option.databaseCleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.databaseCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/geoip/lookup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按提供商与 IP 查询地理位置信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "GeoIP 地址查询", - "parameters": [ - { - "description": "查询参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/option.geoIPLookupRequest" - } - } - ], - "responses": { - "200": { - "description": "GeoIP 查询结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.geoIPLookupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新单个 OpenFlare 配置项,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "更新 OpenFlare 配置项", - "parameters": [ - { - "description": "配置项", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.OpenFlareOption" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/update-batch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "批量更新多个 OpenFlare 配置项,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "批量更新 OpenFlare 配置项", - "parameters": [ - { - "description": "批量配置项", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/option.optionBatchPayload" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有源站及关联代理规则数量,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "获取源站列表", - "responses": { - "200": { - "description": "源站列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/origin.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的上游源站记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "创建源站", - "parameters": [ - { - "description": "源站参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/origin.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/origin.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定源站信息及关联代理规则摘要,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "获取源站详情", - "parameters": [ - { - "type": "integer", - "description": "源站 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "源站详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/origin.DetailView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或源站不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定源站记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "删除源站", - "parameters": [ - { - "type": "integer", - "description": "源站 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或源站不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定源站的配置信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "更新源站", - "parameters": [ - { - "type": "integer", - "description": "源站 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "源站参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/origin.Input" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/origin.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或源站不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 OpenFlare Pages 项目,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "列出 Pages 项目", - "responses": { - "200": { - "description": "Pages 项目列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/pages.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 OpenFlare Pages 项目,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "创建 Pages 项目", - "parameters": [ - { - "description": "项目参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/pages.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功的项目", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/deployments/{deployment_id}/files": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定部署包含的文件清单,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "列出 Pages 部署文件", - "parameters": [ - { - "type": "integer", - "description": "部署 ID", - "name": "deployment_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "部署文件列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/pages.DeploymentFileView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "部署不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 Pages 项目详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "获取 Pages 项目详情", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Pages 项目详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 OpenFlare Pages 项目,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "删除 Pages 项目", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定项目的全部部署记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "列出 Pages 部署", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "部署列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/pages.DeploymentView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments/upload": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "为指定项目上传 ZIP 部署包,需要管理员权限", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "上传 Pages 部署包", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "file", - "description": "部署包 ZIP 文件", - "name": "package", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "部署记录", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.DeploymentView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments/{deployment_id}/activate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将指定部署设为项目当前生效版本,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "激活 Pages 部署", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "部署 ID", - "name": "deployment_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "激活后的项目", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目或部署不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments/{deployment_id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定项目的部署记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "删除 Pages 部署", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "部署 ID", - "name": "deployment_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目或部署不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 OpenFlare Pages 项目,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "更新 Pages 项目", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "项目参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/pages.Input" - } - } - ], - "responses": { - "200": { - "description": "更新后的项目", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有代理规则配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "获取代理规则列表", - "responses": { - "200": { - "description": "代理规则列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的反向代理规则,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "创建代理规则", - "parameters": [ - { - "description": "代理规则参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/proxy_route.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定代理规则的完整配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "获取代理规则详情", - "parameters": [ - { - "type": "integer", - "description": "代理规则 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "代理规则详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或规则不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定代理规则,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "删除代理规则", - "parameters": [ - { - "type": "integer", - "description": "代理规则 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或规则不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定代理规则的配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "更新代理规则", - "parameters": [ - { - "type": "integer", - "description": "代理规则 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "代理规则参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/proxy_route.Input" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或规则不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/status": { - "get": { - "description": "返回版本、认证源与系统公开配置,无需登录", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "获取 OpenFlare 公开状态", - "responses": { - "200": { - "description": "公开状态", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.statusView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 TLS 证书(不含 PEM),需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "列出 TLS 证书", - "responses": { - "200": { - "description": "证书列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "从 PEM 文本创建 TLS 证书,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "创建 TLS 证书", - "parameters": [ - { - "description": "证书参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.CertificateInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/apply": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "通过 ACME 申请新的 TLS 证书,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "申请 ACME 证书", - "parameters": [ - { - "description": "ACME 申请参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ApplyInput" - } - } - ], - "responses": { - "200": { - "description": "申请中的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/import-file": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "上传证书与私钥文件创建 TLS 证书,需要管理员权限", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "从文件导入 TLS 证书", - "parameters": [ - { - "type": "string", - "description": "证书名称", - "name": "name", - "in": "formData" - }, - { - "type": "string", - "description": "备注", - "name": "remark", - "in": "formData" - }, - { - "type": "file", - "description": "证书文件", - "name": "cert_file", - "in": "formData", - "required": true - }, - { - "type": "file", - "description": "私钥文件", - "name": "key_file", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "导入成功的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "获取 TLS 证书详情", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "证书详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/content": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回证书与私钥 PEM 内容,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "获取 TLS 证书 PEM 内容", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "证书 PEM 内容", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/tls.CertificateContent" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/convert-acme": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将已上传证书转换为 ACME 自动续期模式,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "将证书转为 ACME 管理", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "ACME 申请参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ApplyInput" - } - } - ], - "responses": { - "200": { - "description": "转换后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 TLS 证书,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "删除 TLS 证书", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/renew": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "手动触发 ACME 证书续期,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "续期 ACME 证书", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "续期后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 TLS 证书 PEM 信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新 TLS 证书", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "证书参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.CertificateInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/update-acme": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 ACME 证书申请配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新 ACME 证书配置", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "ACME 申请参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ApplyInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/uptimekuma/sync": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "同步 Uptime Kuma", - "responses": { - "200": { - "description": "同步成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 WAF IP 组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "列出 WAF IP 组", - "responses": { - "200": { - "description": "IP 组列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 WAF IP 组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "创建 WAF IP 组", - "parameters": [ - { - "description": "IP 组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IPGroupInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的 IP 组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "测试 WAF IP 组自动配置", - "parameters": [ - { - "description": "自动配置参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IPGroupAutoTestInput" - } - } - ], - "responses": { - "200": { - "description": "测试结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupAutoTestResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 WAF IP 组详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "获取 WAF IP 组详情", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "IP 组详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 WAF IP 组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "删除 WAF IP 组", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}/sync": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "手动触发 WAF IP 组外部 IP 同步,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "同步 WAF IP 组", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "同步结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupSyncResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 WAF IP 组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "更新 WAF IP 组", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "IP 组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IPGroupInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的 IP 组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 WAF 规则组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "列出 WAF 规则组", - "responses": { - "200": { - "description": "规则组列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 WAF 规则组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "创建 WAF 规则组", - "parameters": [ - { - "description": "规则组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 WAF 规则组详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "获取 WAF 规则组详情", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "规则组详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 WAF 规则组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "删除 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/sites": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "替换 WAF 规则组关联的代理站点列表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "替换规则组站点绑定", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "站点 ID 列表", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IDsRequest" - } - } - ], - "responses": { - "200": { - "description": "更新后的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 WAF 规则组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "更新 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "规则组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/sites/{route_id}/rule-groups": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回代理站点关联的 WAF 规则组绑定,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "获取站点 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "代理路由 ID", - "name": "route_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "站点规则组绑定", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.SiteRuleGroupsView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "替换代理站点关联的 WAF 规则组列表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "替换站点 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "代理路由 ID", - "name": "route_id", - "in": "path", - "required": true - }, - { - "description": "规则组 ID 列表", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IDsRequest" - } - } - ], - "responses": { - "200": { - "description": "更新后的站点规则组绑定", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.SiteRuleGroupsView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/callback": { - "post": { - "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "OAuth 回调处理", - "parameters": [ - { - "description": "回调请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oauth.CallbackRequest" - } - } - ], - "responses": { - "200": { - "description": "登录或绑定成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.OAuthCallbackResult" - } - } - } - ] - } - }, - "400": { - "description": "state 无效、参数错误或认证源错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "绑定场景未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "OAuth 认证失败或内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/external-accounts": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取外部帐号列表", - "responses": { - "200": { - "description": "外部帐号列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ExternalAccountView" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/external-accounts/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "解除外部帐号绑定", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "外部帐号绑定记录 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "解除绑定成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "ID 无效或解除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/login": { - "get": { - "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取登录授权地址", - "parameters": [ - { - "type": "string", - "description": "认证源名称,为空使用第一个启用的认证源", - "name": "source", - "in": "query" - } - ], - "responses": { - "200": { - "description": "授权 URL", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未配置", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常 or 构造 URL 失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/logout": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "退出登录", - "responses": { - "200": { - "description": "退出成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "500": { - "description": "Session 清除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/sources": { - "get": { - "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取可用登录源", - "responses": { - "200": { - "description": "登录源列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/oauth.AuthSourceView" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/oauth/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/{source}/authorize": { - "get": { - "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "发起指定认证源授权", - "parameters": [ - { - "type": "string", - "description": "认证源名称", - "name": "source", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", - "name": "purpose", - "in": "query" - } - ], - "responses": { - "200": { - "description": "授权 URL", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常或构造 URL 失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/upload": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "上传文件", - "parameters": [ - { - "type": "file", - "description": "要上传的文件", - "name": "file", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)", - "name": "type", - "in": "formData" - }, - { - "type": "string", - "description": "额外的 JSON 格式元数据", - "name": "metadata", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "上传成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Upload" - } - } - } - ] - } - }, - "400": { - "description": "请求参数错误或文件受限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/upload/my": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "获取我的文件列表", - "parameters": [ - { - "type": "integer", - "description": "页码(默认 1)", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(默认 20,最大 100)", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "文件名关键词(模糊匹配)", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "description": "业务分类过滤", - "name": "type", - "in": "query" - }, - { - "type": "string", - "description": "扩展名过滤", - "name": "extension", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.listMyFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/upload/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新当前用户本人的文件名或访问权限模式 (AccessMode)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "更新我的文件信息", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新字段", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.updateMyFileRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Upload" - } - } - } - ] - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将当前用户本人的文件状态置为 deleted(软删除)", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "删除我的文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/access-tokens": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的所有 active access tokens(脱敏后)", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "获取当前用户的 AccessToken 列表", - "responses": { - "200": { - "description": "令牌列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AccessToken" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "创建一个新的 AccessToken", - "parameters": [ - { - "description": "令牌名称", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.createTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "新建令牌成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或超限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/access-tokens/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "撤销并删除一个属于当前用户的 API 访问令牌", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "删除一个 AccessToken", - "parameters": [ - { - "type": "string", - "description": "令牌ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/access-tokens/{id}/rotate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "轮换一个 AccessToken", - "parameters": [ - { - "type": "string", - "description": "令牌ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "令牌轮换成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/change-password": { - "post": { - "description": "修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "修改用户密码", - "parameters": [ - { - "description": "修改密码请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.changePasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "修改密码成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "原密码错误或新密码不符合要求", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "请先登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/login": { - "post": { - "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "用户密码登录", - "parameters": [ - { - "description": "登录请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.loginRequest" - } - } - ], - "responses": { - "200": { - "description": "登录成功,返回用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "用户名或密码错误、帐号已禁用等", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/logout": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清除用户登录 Session,完成退出", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "用户退出登录", - "responses": { - "200": { - "description": "退出成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "500": { - "description": "Session 清除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/profile": { - "put": { - "description": "修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "修改当前登录用户的个人资料", - "parameters": [ - { - "description": "更新请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.updateProfileRequest" - } - } - ], - "responses": { - "200": { - "description": "修改成功,返回更新后的用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "邮箱已被占用或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/register": { - "post": { - "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "用户注册", - "parameters": [ - { - "description": "注册请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.registerRequest" - } - } - ], - "responses": { - "200": { - "description": "注册并登录成功,返回用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "参数错误、用户名已存在或注册已关闭", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/self": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/send-email-code": { - "post": { - "description": "向指定邮箱发送验证码(用于注册场景)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "发送邮箱验证码", - "parameters": [ - { - "description": "发送验证码请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.sendEmailCodeRequest" - } - } - ], - "responses": { - "200": { - "description": "发送成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/f/{id}": { - "get": { - "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "upload" - ], - "summary": "获取已上传文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "图片质量 (low, medium, high, origin),默认为 origin", - "name": "quality", - "in": "query" - } - ], - "responses": { - "200": { - "description": "成功获取文件内容", - "schema": { - "type": "file" - } - }, - "400": { - "description": "文件 ID 格式错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件未找到", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/robots.txt": { - "get": { - "description": "根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容", - "produces": [ - "text/plain" - ], - "tags": [ - "config" - ], - "summary": "获取 robots.txt", - "responses": { - "200": { - "description": "robots.txt 内容", - "schema": { - "type": "string" - } - } - } - } - } - }, - "definitions": { - "apply_log.CleanupInput": { - "type": "object", - "properties": { - "delete_all": { - "type": "boolean" - }, - "retention_days": { - "type": "integer" - } - } - }, - "apply_log.CleanupResult": { - "type": "object", - "properties": { - "cutoff": { - "type": "string" - }, - "delete_all": { - "type": "boolean" - }, - "deleted_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - } - } - }, - "apply_log.ListResult": { - "type": "object", - "properties": { - "current": { - "type": "integer" - }, - "rows": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareApplyLog" - } - }, - "total": { - "type": "integer" - }, - "totalPage": { - "type": "integer" - } - } - }, - "auth_source.AuthSourceRequest": { - "type": "object", - "properties": { - "client_id": { - "type": "string" - }, - "client_secret": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "openid_discovery_url": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "auth_source.ToggleAuthSourceRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "cache.updateCacheConfigRequest": { - "type": "object", - "required": [ - "max_size_mb", - "ttl_minutes" - ], - "properties": { - "lru_enabled": { - "type": "boolean" - }, - "max_size_mb": { - "type": "integer", - "minimum": 1 - }, - "ttl_minutes": { - "type": "integer", - "minimum": 0 - } - } - }, - "cap.ChallengeResponse": { - "type": "object", - "properties": { - "challenge": { - "type": "object", - "properties": { - "c": { - "type": "integer" - }, - "d": { - "type": "integer" - }, - "s": { - "type": "integer" - } - } - }, - "expires": { - "description": "ms timestamp", - "type": "integer" - }, - "token": { - "type": "string" - } - } - }, - "cap.challengeRequest": { - "type": "object", - "properties": { - "scope": { - "type": "string" - } - } - }, - "cap.redeemRequest": { - "type": "object", - "required": [ - "solutions", - "token" - ], - "properties": { - "scope": { - "type": "string" - }, - "solutions": { - "type": "array", - "items": { - "type": "integer" - } - }, - "token": { - "type": "string" - } - } - }, - "config_version.CleanupInput": { - "type": "object", - "properties": { - "keep_count": { - "type": "integer" - } - } - }, - "config_version.CleanupResult": { - "type": "object", - "properties": { - "deleted_count": { - "type": "integer" - }, - "message": { - "type": "string" - } - } - }, - "config_version.ConfigDiffResult": { - "type": "object", - "properties": { - "active_version": { - "type": "string" - }, - "active_website_count": { - "type": "integer" - }, - "added_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "added_sites": { - "type": "array", - "items": { - "type": "string" - } - }, - "changed_option_details": { - "type": "array", - "items": { - "$ref": "#/definitions/config_version.ConfigOptionDiffItem" - } - }, - "changed_option_keys": { - "type": "array", - "items": { - "type": "string" - } - }, - "current_website_count": { - "type": "integer" - }, - "main_config_changed": { - "type": "boolean" - }, - "modified_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "modified_sites": { - "type": "array", - "items": { - "type": "string" - } - }, - "removed_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "removed_sites": { - "type": "array", - "items": { - "type": "string" - } - }, - "waf_config_changed": { - "type": "boolean" - } - } - }, - "config_version.ConfigOptionDiffItem": { - "type": "object", - "properties": { - "current_value": { - "type": "string" - }, - "key": { - "type": "string" - }, - "previous_value": { - "type": "string" - } - } - }, - "config_version.ConfigPreviewResult": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "main_config": { - "type": "string" - }, - "rendered_config": { - "type": "string" - }, - "route_config": { - "type": "string" - }, - "route_count": { - "type": "integer" - }, - "snapshot_json": { - "type": "string" - }, - "support_files": { - "type": "array", - "items": { - "$ref": "#/definitions/config_version.SupportFile" - } - }, - "website_count": { - "type": "integer" - } - } - }, - "config_version.SupportFile": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "dashboard.Capacity": { - "type": "object", - "properties": { - "average_cpu_usage_percent": { - "type": "number" - }, - "average_memory_usage_percent": { - "type": "number" - }, - "high_cpu_nodes": { - "type": "integer" - }, - "high_memory_nodes": { - "type": "integer" - }, - "high_storage_nodes": { - "type": "integer" - } - } - }, - "dashboard.OverviewPayload": { - "type": "object", - "properties": { - "capacity": { - "$ref": "#/definitions/dashboard.Capacity" - }, - "distributions": { - "$ref": "#/definitions/dashboard.distributionsPayload" - }, - "generated_at": {}, - "nodes": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "summary": { - "$ref": "#/definitions/dashboard.Summary" - }, - "traffic": { - "$ref": "#/definitions/dashboard.Traffic" - }, - "trends": { - "$ref": "#/definitions/dashboard.trendsPayload" - } - } - }, - "dashboard.Summary": { - "type": "object", - "properties": { - "offline_nodes": { - "type": "integer" - }, - "online_nodes": { - "type": "integer" - }, - "pending_nodes": { - "type": "integer" - }, - "total_nodes": { - "type": "integer" - }, - "unhealthy_nodes": { - "type": "integer" - } - } - }, - "dashboard.Traffic": { - "type": "object", - "properties": { - "error_count": { - "type": "integer" - }, - "estimated_qps": { - "type": "number" - }, - "reported_nodes": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "unique_visitors": { - "type": "integer" - } - } - }, - "dashboard.distributionsPayload": { - "type": "object", - "properties": { - "source_countries": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "status_codes": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "top_domains": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - } - } - }, - "dashboard.trendsPayload": { - "type": "object", - "properties": { - "capacity_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "disk_io_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "network_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "traffic_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - } - } - }, - "db_manage.DBOverviewResponse": { - "type": "object", - "properties": { - "connections": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "size": { - "type": "string" - }, - "table_count": { - "type": "integer" - }, - "type": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "db_manage.ExecuteSQLRequest": { - "type": "object", - "required": [ - "sql" - ], - "properties": { - "sql": { - "type": "string" - } - } - }, - "db_manage.ExecuteSQLResponse": { - "type": "object", - "properties": { - "affected_rows": { - "type": "integer" - }, - "columns": { - "type": "array", - "items": { - "type": "string" - } - }, - "execution_time_ms": { - "type": "integer" - }, - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "type": { - "description": "\"select\" 或 \"exec\"", - "type": "string" - } - } - }, - "diskcache.Status": { - "type": "object", - "properties": { - "base_path": { - "type": "string" - }, - "keys_count": { - "type": "integer" - }, - "lru_enabled": { - "type": "boolean" - }, - "max_size_mb": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "ttl_minutes": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "expires": { - "type": "integer" - }, - "success": { - "type": "boolean" - }, - "token": { - "type": "string" - } - } - }, - "handler.batchDownloadRequest": { - "type": "object", - "required": [ - "ids" - ], - "properties": { - "ids": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - } - } - }, - "handler.distributionItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.fileStatsResponse": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - }, - "total_count": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.trendItem" - } - }, - "types": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - } - } - }, - "handler.listFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.listMyFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.updateMyFileRequest": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "file_name": { - "type": "string", - "maxLength": 255 - } - } - }, - "logger.LogEntry": { - "type": "object", - "properties": { - "data": { - "description": "一行日志原文(含换行符)", - "type": "string" - }, - "index": { - "description": "全局递增序号", - "type": "integer" - } - } - }, - "logs.accessLogItem": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "headers": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "ip": { - "type": "string" - }, - "latency": { - "type": "integer" - }, - "method": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "path": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "user_agent": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.accessLogsResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.accessLogItem" - } - }, - "total": { - "type": "integer" - } - } - }, - "logs.browserItem": { - "type": "object", - "properties": { - "browser": { - "type": "string" - }, - "count": { - "type": "integer" - } - } - }, - "logs.logsAnalyticsResponse": { - "type": "object", - "properties": { - "browsers": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.browserItem" - } - }, - "top_users": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.topUserItem" - } - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.trendItem" - } - } - } - }, - "logs.logsResponse": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "lines": { - "type": "array", - "items": { - "$ref": "#/definitions/logger.LogEntry" - } - }, - "next_cursor": { - "description": "用于加载更早日志的 cursor", - "type": "integer" - } - } - }, - "logs.topUserItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "nickname": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - } - } - }, - "model.AccessToken": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "masked_token": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "integer" - } - } - }, - "model.AcmeAccount": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "model.AuthSource": { - "type": "object", - "properties": { - "client_id": { - "type": "string" - }, - "client_secret_configured": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "openid_discovery_url": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.ConfigVersion": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "main_config": { - "type": "string" - }, - "rendered_config": { - "type": "string" - }, - "snapshot_json": { - "type": "string" - }, - "support_files_json": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "model.ConfigVersionSummary": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "version": { - "type": "string" - } - } - }, - "model.DNSAccount": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.ExternalAccountView": { - "type": "object", - "properties": { - "auth_source_id": { - "type": "integer" - }, - "auth_source_label": { - "type": "string" - }, - "auth_source_name": { - "type": "string" - }, - "auth_source_type": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "external_username": { - "type": "string" - }, - "id": { - "type": "integer" - } - } - }, - "model.ManagedDomain": { - "type": "object", - "properties": { - "cert_id": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "domain": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "remark": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.OpenFlareApplyLog": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "main_config_checksum": { - "type": "string" - }, - "message": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "route_config_checksum": { - "type": "string" - }, - "support_file_count": { - "type": "integer" - }, - "version": { - "type": "string" - } - } - }, - "model.OpenFlareHealthEvent": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "event_type": { - "type": "string" - }, - "first_triggered_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "last_triggered_at": { - "type": "string" - }, - "message": { - "type": "string" - }, - "metadata_json": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "reported_at": { - "type": "string" - }, - "resolved_at": { - "type": "string" - }, - "severity": { - "type": "string" - }, - "status": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.OpenFlareMetricSnapshot": { - "type": "object", - "properties": { - "captured_at": { - "type": "string" - }, - "cpu_usage_percent": { - "type": "number" - }, - "created_at": { - "type": "string" - }, - "disk_read_bytes": { - "type": "integer" - }, - "disk_write_bytes": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "memory_total_bytes": { - "type": "integer" - }, - "memory_used_bytes": { - "type": "integer" - }, - "network_rx_bytes": { - "type": "integer" - }, - "network_tx_bytes": { - "type": "integer" - }, - "node_id": { - "type": "string" - }, - "storage_total_bytes": { - "type": "integer" - }, - "storage_used_bytes": { - "type": "integer" - } - } - }, - "model.OpenFlareNodeSystemProfile": { - "type": "object", - "properties": { - "architecture": { - "type": "string" - }, - "cpu_cores": { - "type": "integer" - }, - "cpu_model": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "kernel_version": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "os_name": { - "type": "string" - }, - "os_version": { - "type": "string" - }, - "reported_at": { - "type": "string" - }, - "total_disk_bytes": { - "type": "integer" - }, - "total_memory_bytes": { - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "uptime_seconds": { - "type": "integer" - } - } - }, - "model.OpenFlareOption": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "model.OpenFlareRequestReport": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "error_count": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "node_id": { - "type": "string" - }, - "request_count": { - "type": "integer" - }, - "source_countries_json": { - "type": "string" - }, - "status_codes_json": { - "type": "string" - }, - "top_domains_json": { - "type": "string" - }, - "unique_visitor_count": { - "type": "integer" - }, - "window_ended_at": { - "type": "string" - }, - "window_started_at": { - "type": "string" - } - } - }, - "model.PushChannel": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "description": "备注", - "type": "string" - }, - "enabled": { - "description": "通道是否启用", - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "通道名称,仅英文字母和下划线,唯一", - "type": "string" - }, - "other": { - "description": "请求体/SMTP 密码等", - "type": "string" - }, - "token": { - "description": "鉴权令牌或发信用户名等", - "type": "string" - }, - "type": { - "description": "通道类型:custom, lark, email", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "url": { - "description": "请求地址,HTTPS 协议或 SMTP 地址", - "type": "string" - } - } - }, - "model.PushEvent": { - "type": "object", - "properties": { - "channels": { - "description": "推送渠道列表,如 [\"lark\"]", - "type": "array", - "items": { - "type": "string" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "description": "是否启用", - "type": "boolean" - }, - "event_key": { - "description": "如 admin_login", - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "如 管理员登录", - "type": "string" - }, - "targets": { - "description": "推送目标用户/邮箱列表", - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "description": "消息模板 JSON", - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.PushHistory": { - "type": "object", - "properties": { - "channel": { - "type": "string" - }, - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "error_msg": { - "type": "string" - }, - "event_key": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "level": { - "type": "string" - }, - "status": { - "description": "success / failed", - "type": "string" - }, - "target": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "model.Schedule": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "cron": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.SystemConfig": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer" - } - } - }, - "model.TLSCertificate": { - "type": "object", - "properties": { - "acme_account_id": { - "type": "integer" - }, - "apply_message": { - "type": "string" - }, - "apply_status": { - "type": "string" - }, - "auto_renew": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "disable_cname": { - "type": "boolean" - }, - "dns1": { - "type": "string" - }, - "dns2": { - "type": "string" - }, - "dns_account_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "key_algorithm": { - "type": "string" - }, - "name": { - "type": "string" - }, - "not_after": { - "type": "string" - }, - "not_before": { - "type": "string" - }, - "other_domains": { - "type": "string" - }, - "primary_domain": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "skip_dns": { - "type": "boolean" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.TaskExecution": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "error_message": { - "type": "string" - }, - "finished_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "log": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "payload": { - "type": "string" - }, - "result": { - "type": "string" - }, - "retry_count": { - "type": "integer" - }, - "retryable": { - "type": "boolean" - }, - "started_at": { - "type": "string" - }, - "status": { - "$ref": "#/definitions/model.TaskExecutionStatus" - }, - "task_id": { - "type": "string" - }, - "task_name": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "triggered_by": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.TaskExecutionStatus": { - "type": "string", - "enum": [ - "pending", - "running", - "succeeded", - "failed" - ], - "x-enum-varnames": [ - "TaskExecutionStatusPending", - "TaskExecutionStatusRunning", - "TaskExecutionStatusSucceeded", - "TaskExecutionStatusFailed" - ] - }, - "model.Template": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_system": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.Upload": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "extension": { - "description": "文件后缀名 (不含点,如 png, pdf)", - "type": "string" - }, - "file_name": { - "description": "原始文件名 (例如: image.png)", - "type": "string" - }, - "file_path": { - "description": "文件相对路径 / S3 Key", - "type": "string" - }, - "file_size": { - "description": "文件大小(字节)", - "type": "integer" - }, - "hash": { - "description": "文件哈希 (SHA-256/MD5,可用于排重)", - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "metadata": { - "description": "业务扩展元数据", - "allOf": [ - { - "$ref": "#/definitions/model.UploadMetadata" - } - ] - }, - "mime_type": { - "description": "媒体类型 (MIME, 如 image/png)", - "type": "string" - }, - "status": { - "description": "状态", - "allOf": [ - { - "$ref": "#/definitions/model.UploadStatus" - } - ] - }, - "type": { - "description": "业务标识类型 (如 avatar, doc, attachment)", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - } - } - }, - "model.UploadMetadata": { - "type": "object", - "properties": { - "bucket": { - "description": "存储桶名称 (适用于 S3 等)", - "type": "string" - }, - "client_ip": { - "description": "上传者 IP", - "type": "string" - }, - "duration": { - "description": "音视频时长 (s)", - "type": "number" - }, - "extra": { - "description": "其它任意业务自定义元数据", - "type": "object", - "additionalProperties": {} - }, - "height": { - "description": "图像/视频高度 (px)", - "type": "integer" - }, - "original_mime": { - "description": "原始 MIME 类型", - "type": "string" - }, - "user_agent": { - "description": "上传者的 UA", - "type": "string" - }, - "width": { - "description": "图像/视频宽度 (px)", - "type": "integer" - } - } - }, - "model.UploadStatus": { - "type": "string", - "enum": [ - "pending", - "used", - "deleted" - ], - "x-enum-comments": { - "UploadStatusDeleted": "已删除", - "UploadStatusPending": "待使用", - "UploadStatusUsed": "已使用" - }, - "x-enum-descriptions": [ - "待使用", - "已使用", - "已删除" - ], - "x-enum-varnames": [ - "UploadStatusPending", - "UploadStatusUsed", - "UploadStatusDeleted" - ] - }, - "node.AgentReleaseInfo": { - "type": "object", - "properties": { - "body": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "current_version": { - "type": "string" - }, - "has_update": { - "type": "boolean" - }, - "html_url": { - "type": "string" - }, - "prerelease": { - "type": "boolean" - }, - "published_at": { - "type": "string" - }, - "requested_channel": { - "type": "string" - }, - "requested_tag": { - "type": "string" - }, - "tag_name": { - "type": "string" - }, - "update_requested": { - "type": "boolean" - } - } - }, - "node.AgentUpdateInput": { - "type": "object", - "properties": { - "channel": { - "type": "string" - }, - "tag_name": { - "type": "string" - } - } - }, - "node.BootstrapView": { - "type": "object", - "properties": { - "discovery_token": { - "type": "string" - } - } - }, - "node.HealthEventCleanupResult": { - "type": "object", - "properties": { - "deleted_count": { - "type": "integer" - }, - "node_id": { - "type": "string" - } - } - }, - "node.Input": { - "type": "object", - "properties": { - "auto_update_enabled": { - "type": "boolean" - }, - "geo_latitude": { - "type": "number" - }, - "geo_longitude": { - "type": "number" - }, - "geo_manual_override": { - "type": "boolean" - }, - "geo_name": { - "type": "string" - }, - "ip": { - "type": "string" - }, - "ip_manual_override": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "node_type": { - "type": "string" - }, - "relay_agent_access_addr": { - "type": "string" - }, - "relay_bind_port": { - "type": "integer" - }, - "relay_client_access_addr": { - "type": "string" - }, - "relay_client_proxy_url": { - "type": "string" - }, - "relay_vhost_http_port": { - "type": "integer" - }, - "relay_web_server_enabled": { - "type": "boolean" - } - } - }, - "node.ObservabilityView": { - "type": "object", - "properties": { - "analytics": { - "$ref": "#/definitions/observability.NodeAnalytics" - }, - "health_events": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareHealthEvent" - } - }, - "metric_snapshots": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareMetricSnapshot" - } - }, - "node_id": { - "type": "string" - }, - "profile": { - "$ref": "#/definitions/model.OpenFlareNodeSystemProfile" - }, - "relay_dashboard": { - "$ref": "#/definitions/observability.RelayDashboardSnapshot" - }, - "traffic_reports": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareRequestReport" - } - }, - "trends": { - "$ref": "#/definitions/observability.NodeTrends" - } - } - }, - "node.View": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "auto_update_enabled": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "current_version": { - "type": "string" - }, - "ext_version": { - "type": "string" - }, - "geo_latitude": { - "type": "number" - }, - "geo_longitude": { - "type": "number" - }, - "geo_manual_override": { - "type": "boolean" - }, - "geo_name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "ip": { - "type": "string" - }, - "ip_manual_override": { - "type": "boolean" - }, - "last_error": { - "type": "string" - }, - "last_seen_at": {}, - "latest_apply_at": { - "type": "string" - }, - "latest_apply_checksum": { - "type": "string" - }, - "latest_apply_message": { - "type": "string" - }, - "latest_apply_result": { - "type": "string" - }, - "latest_main_config_checksum": { - "type": "string" - }, - "latest_route_config_checksum": { - "type": "string" - }, - "latest_support_file_count": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "node_type": { - "type": "string" - }, - "openresty_message": { - "type": "string" - }, - "openresty_status": { - "type": "string" - }, - "relay_agent_access_addr": { - "type": "string" - }, - "relay_bind_port": { - "type": "integer" - }, - "relay_client_access_addr": { - "type": "string" - }, - "relay_client_proxy_url": { - "type": "string" - }, - "relay_status": { - "type": "string" - }, - "relay_vhost_http_port": { - "type": "integer" - }, - "relay_web_server_enabled": { - "type": "boolean" - }, - "restart_openresty_requested": { - "type": "boolean" - }, - "status": { - "type": "string" - }, - "update_channel": { - "type": "string" - }, - "update_requested": { - "type": "boolean" - }, - "update_tag": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "oauth.AuthSourceView": { - "type": "object", - "properties": { - "client_secret_configured": { - "type": "boolean" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "oauth.BasicUserInfo": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "need_change_password": { - "type": "boolean" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "username": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "oauth.CallbackRequest": { - "type": "object", - "required": [ - "code", - "state" - ], - "properties": { - "code": { - "type": "string" - }, - "state": { - "type": "string" - } - } - }, - "oauth.OAuthAuthorizeResponse": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - } - } - }, - "oauth.OAuthCallbackResult": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - }, - "observability.AccessLogCleanupInput": { - "type": "object", - "properties": { - "retention_days": { - "type": "integer" - } - } - }, - "observability.AccessLogCleanupResult": { - "type": "object", - "properties": { - "cutoff": { - "type": "string" - }, - "deleted_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - } - } - }, - "observability.AccessLogIPSummaryList": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.AccessLogIPSummaryView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "sort_by": { - "type": "string" - }, - "sort_order": { - "type": "string" - }, - "total_ip": { - "type": "integer" - } - } - }, - "observability.AccessLogIPSummaryView": { - "type": "object", - "properties": { - "last_seen_at": { - "type": "string" - }, - "recent_requests": { - "type": "integer" - }, - "remote_addr": { - "type": "string" - }, - "total_requests": { - "type": "integer" - } - } - }, - "observability.AccessLogIPTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "request_count": { - "type": "integer" - } - } - }, - "observability.AccessLogIPTrendView": { - "type": "object", - "properties": { - "bucket_minutes": { - "type": "integer" - }, - "hours": { - "type": "integer" - }, - "points": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.AccessLogIPTrendPoint" - } - }, - "remote_addr": { - "type": "string" - } - } - }, - "observability.AccessLogList": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.AccessLogView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_ip": { - "type": "integer" - }, - "total_record": { - "type": "integer" - } - } - }, - "observability.AccessLogView": { - "type": "object", - "properties": { - "host": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "logged_at": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "node_name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "region": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "status_code": { - "type": "integer" - } - } - }, - "observability.CapacityTrendPoint": { - "type": "object", - "properties": { - "average_cpu_usage_percent": { - "type": "number" - }, - "average_memory_usage_percent": { - "type": "number" - }, - "bucket_started_at": { - "type": "string" - }, - "reported_nodes": { - "type": "integer" - } - } - }, - "observability.DiskIOTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "disk_read_bytes": { - "type": "integer" - }, - "disk_write_bytes": { - "type": "integer" - }, - "reported_nodes": { - "type": "integer" - } - } - }, - "observability.DistributionItem": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogIPList": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "fold_minutes": { - "type": "integer" - }, - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.FoldedAccessLogIPView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "sort_by": { - "type": "string" - }, - "sort_order": { - "type": "string" - }, - "total_ip": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogIPView": { - "type": "object", - "properties": { - "client_error_count": { - "type": "integer" - }, - "last_seen_at": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "request_count": { - "type": "integer" - }, - "server_error_count": { - "type": "integer" - }, - "success_count": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogList": { - "type": "object", - "properties": { - "fold_minutes": { - "type": "integer" - }, - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.FoldedAccessLogView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_bucket": { - "type": "integer" - }, - "total_ip": { - "type": "integer" - }, - "total_record": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogView": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "client_error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "server_error_count": { - "type": "integer" - }, - "success_count": { - "type": "integer" - }, - "unique_host_count": { - "type": "integer" - }, - "unique_ip_count": { - "type": "integer" - } - } - }, - "observability.HealthSummary": { - "type": "object", - "properties": { - "active_alerts": { - "type": "integer" - }, - "critical_alerts": { - "type": "integer" - }, - "has_capacity_risk": { - "type": "boolean" - }, - "has_runtime_risk": { - "type": "boolean" - }, - "has_traffic_risk": { - "type": "boolean" - }, - "info_alerts": { - "type": "integer" - }, - "resolved_alerts": { - "type": "integer" - }, - "warning_alerts": { - "type": "integer" - } - } - }, - "observability.NetworkTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "network_rx_bytes": { - "type": "integer" - }, - "network_tx_bytes": { - "type": "integer" - }, - "openresty_rx_bytes": { - "type": "integer" - }, - "openresty_tx_bytes": { - "type": "integer" - }, - "reported_nodes": { - "type": "integer" - } - } - }, - "observability.NodeAnalytics": { - "type": "object", - "properties": { - "distributions": { - "$ref": "#/definitions/observability.TrafficDistributions" - }, - "health": { - "$ref": "#/definitions/observability.HealthSummary" - }, - "traffic": { - "$ref": "#/definitions/observability.TrafficWindowSummary" - } - } - }, - "observability.NodeTrends": { - "type": "object", - "properties": { - "capacity_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.CapacityTrendPoint" - } - }, - "disk_io_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DiskIOTrendPoint" - } - }, - "network_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.NetworkTrendPoint" - } - }, - "traffic_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.TrafficTrendPoint" - } - } - } - }, - "observability.RelayDashboardSnapshot": { - "type": "object", - "properties": { - "client_counts": { - "type": "integer" - }, - "offline_proxies": { - "type": "integer" - }, - "online_proxies": { - "type": "integer" - }, - "proxies": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.RelayProxyStat" - } - }, - "total_connections": { - "type": "integer" - }, - "total_proxies": { - "type": "integer" - } - } - }, - "observability.RelayProxyStat": { - "type": "object", - "properties": { - "client_addr": { - "type": "string" - }, - "client_version": { - "type": "string" - }, - "last_close_time": { - "type": "string" - }, - "last_start_time": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "observability.TrafficDistributions": { - "type": "object", - "properties": { - "source_countries": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DistributionItem" - } - }, - "status_codes": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DistributionItem" - } - }, - "top_domains": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DistributionItem" - } - } - } - }, - "observability.TrafficTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "unique_visitor_count": { - "type": "integer" - } - } - }, - "observability.TrafficWindowSummary": { - "type": "object", - "properties": { - "error_count": { - "type": "integer" - }, - "error_rate_percent": { - "type": "number" - }, - "estimated_qps": { - "type": "number" - }, - "request_count": { - "type": "integer" - }, - "unique_visitor_count": { - "type": "integer" - }, - "window_ended_at": { - "type": "string" - }, - "window_started_at": { - "type": "string" - } - } - }, - "option.databaseCleanupInput": { - "type": "object", - "properties": { - "retention_days": { - "type": "integer" - }, - "target": { - "type": "string" - } - } - }, - "option.databaseCleanupResult": { - "type": "object", - "properties": { - "delete_all": { - "type": "boolean" - }, - "deleted_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - }, - "target": { - "type": "string" - }, - "target_label": { - "type": "string" - } - } - }, - "option.geoIPLookupRequest": { - "type": "object", - "properties": { - "ip": { - "type": "string" - }, - "provider": { - "type": "string" - } - } - }, - "option.geoIPLookupView": { - "type": "object", - "properties": { - "ip": { - "type": "string" - }, - "iso_code": { - "type": "string" - }, - "latitude": { - "type": "number" - }, - "longitude": { - "type": "number" - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - } - }, - "option.optionBatchPayload": { - "type": "object", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareOption" - } - } - } - }, - "option.publicAuthSourceView": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "option.statusView": { - "type": "object", - "properties": { - "auth_sources": { - "type": "array", - "items": { - "$ref": "#/definitions/option.publicAuthSourceView" - } - }, - "cap_login_enabled": { - "type": "boolean" - }, - "email_verification": { - "type": "boolean" - }, - "footer_html": { - "type": "string" - }, - "github_client_id": { - "type": "string" - }, - "github_oauth": { - "type": "boolean" - }, - "home_page_link": { - "type": "string" - }, - "password_register_enabled": { - "type": "boolean" - }, - "server_address": { - "type": "string" - }, - "start_time": { - "type": "integer" - }, - "system_name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "wechat_login": { - "type": "boolean" - }, - "wechat_qrcode": { - "type": "string" - } - } - }, - "origin.DetailView": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "route_count": { - "type": "integer" - }, - "routes": { - "type": "array", - "items": { - "$ref": "#/definitions/origin.RouteSummary" - } - }, - "updated_at": { - "type": "string" - } - } - }, - "origin.Input": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - } - } - }, - "origin.RouteSummary": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "origin_url": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "origin.View": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "route_count": { - "type": "integer" - }, - "updated_at": { - "type": "string" - } - } - }, - "pages.DeploymentFileView": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "deployment_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "path": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "pages.DeploymentView": { - "type": "object", - "properties": { - "activated_at": { - "type": "string" - }, - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "deployment_number": { - "type": "integer" - }, - "file_count": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "total_size": { - "type": "integer" - } - } - }, - "pages.Input": { - "type": "object", - "properties": { - "api_proxy_enabled": { - "type": "boolean" - }, - "api_proxy_pass": { - "type": "string" - }, - "api_proxy_path": { - "type": "string" - }, - "api_proxy_rewrite": { - "type": "string" - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "entry_file": { - "type": "string" - }, - "name": { - "type": "string" - }, - "root_dir": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "spa_fallback_enabled": { - "type": "boolean" - }, - "spa_fallback_path": { - "type": "string" - } - } - }, - "pages.View": { - "type": "object", - "properties": { - "active_deployment": { - "$ref": "#/definitions/pages.DeploymentView" - }, - "active_deployment_id": { - "type": "integer" - }, - "api_proxy_enabled": { - "type": "boolean" - }, - "api_proxy_pass": { - "type": "string" - }, - "api_proxy_path": { - "type": "string" - }, - "api_proxy_rewrite": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "deployment_count": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "entry_file": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "root_dir": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "spa_fallback_enabled": { - "type": "boolean" - }, - "spa_fallback_path": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "proxy_route.CustomHeaderInput": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "proxy_route.Input": { - "type": "object", - "properties": { - "basic_auth_enabled": { - "type": "boolean" - }, - "basic_auth_password": { - "type": "string" - }, - "basic_auth_username": { - "type": "string" - }, - "cache_enabled": { - "type": "boolean" - }, - "cache_policy": { - "type": "string" - }, - "cache_rules": { - "type": "array", - "items": { - "type": "string" - } - }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "custom_headers": { - "type": "array", - "items": { - "$ref": "#/definitions/proxy_route.CustomHeaderInput" - } - }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "enable_https": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "limit_conn_per_ip": { - "type": "integer" - }, - "limit_conn_per_server": { - "type": "integer" - }, - "limit_rate": { - "type": "string" - }, - "origin_address": { - "type": "string" - }, - "origin_host": { - "type": "string" - }, - "origin_id": { - "type": "integer" - }, - "origin_port": { - "type": "string" - }, - "origin_scheme": { - "type": "string" - }, - "origin_uri": { - "type": "string" - }, - "origin_url": { - "type": "string" - }, - "pages_project_id": { - "type": "integer" - }, - "redirect_http": { - "type": "boolean" - }, - "remark": { - "type": "string" - }, - "site_name": { - "type": "string" - }, - "tunnel_id": { - "type": "integer" - }, - "tunnel_node_id": { - "type": "integer" - }, - "tunnel_target_addr": { - "type": "string" - }, - "tunnel_target_protocol": { - "type": "string" - }, - "upstream_type": { - "type": "string" - }, - "upstreams": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "proxy_route.View": { - "type": "object", - "properties": { - "basic_auth_enabled": { - "type": "boolean" - }, - "basic_auth_password": { - "type": "string" - }, - "basic_auth_username": { - "type": "string" - }, - "cache_enabled": { - "type": "boolean" - }, - "cache_policy": { - "type": "string" - }, - "cache_rule_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "cache_rules": { - "type": "string" - }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "created_at": { - "type": "string" - }, - "custom_header_list": { - "type": "array", - "items": { - "$ref": "#/definitions/proxy_route.CustomHeaderInput" - } - }, - "custom_headers": { - "type": "string" - }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domain_count": { - "type": "integer" - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "enable_https": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "limit_conn_per_ip": { - "type": "integer" - }, - "limit_conn_per_server": { - "type": "integer" - }, - "limit_rate": { - "type": "string" - }, - "origin_host": { - "type": "string" - }, - "origin_id": { - "type": "integer" - }, - "origin_url": { - "type": "string" - }, - "pages_project_id": { - "type": "integer" - }, - "primary_domain": { - "type": "string" - }, - "redirect_http": { - "type": "boolean" - }, - "remark": { - "type": "string" - }, - "site_name": { - "type": "string" - }, - "tunnel_id": { - "type": "integer" - }, - "tunnel_node_id": { - "type": "integer" - }, - "tunnel_target_addr": { - "type": "string" - }, - "tunnel_target_protocol": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "upstream_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "upstream_type": { - "type": "string" - }, - "upstreams": { - "type": "string" - } - } - }, - "push.Config": { - "type": "object", - "properties": { - "channel": { - "description": "渠道名称,例如 \"lark\", \"custom\", \"email\" 等,唯一标识", - "type": "string" - }, - "ext": { - "description": "预留拓展 JSON 配置", - "type": "object", - "additionalProperties": {} - }, - "key": { - "description": "AppID 或 SMTP 用户名", - "type": "string" - }, - "secret": { - "description": "签名密钥或 SMTP 密码/Token", - "type": "string" - }, - "url": { - "description": "Webhook 地址或 SMTP 地址", - "type": "string" - } - } - }, - "push.CreateChannelRequest": { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "other": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.CreateEventRequest": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "event_key": { - "type": "string" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "type": "string" - } - } - }, - "push.Definition": { - "type": "object", - "properties": { - "description": { - "description": "short description", - "type": "string" - }, - "fields": { - "description": "form fields", - "type": "array", - "items": { - "$ref": "#/definitions/push.Field" - } - }, - "name": { - "description": "display name", - "type": "string" - }, - "type": { - "description": "channel type (e.g., custom, lark, email)", - "type": "string" - } - } - }, - "push.EventMetadata": { - "type": "object", - "properties": { - "default_template": { - "$ref": "#/definitions/push.NotificationMessage" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "push.Field": { - "type": "object", - "properties": { - "description": { - "description": "field explanation/help text", - "type": "string" - }, - "key": { - "description": "unique key for the field (e.g. url, token, other)", - "type": "string" - }, - "label": { - "description": "human readable label (e.g. \"Webhook 地址\")", - "type": "string" - }, - "placeholder": { - "description": "input placeholder", - "type": "string" - }, - "required": { - "description": "whether this field is required", - "type": "boolean" - }, - "type": { - "description": "input type: \"text\" | \"password\" | \"textarea\"", - "type": "string" - } - } - }, - "push.NotificationMessage": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "ext": { - "type": "object", - "additionalProperties": {} - }, - "level": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "push.TestChannelRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "other": { - "type": "string" - }, - "target": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.TestPushRequest": { - "type": "object", - "required": [ - "config" - ], - "properties": { - "config": { - "$ref": "#/definitions/push.Config" - }, - "target": { - "type": "string" - } - } - }, - "push.UpdateChannelRequest": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "other": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.UpdateEventRequest": { - "type": "object", - "required": [ - "template" - ], - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "string" - } - } - }, - "push.pushHistoriesResponse": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushHistory" - } - }, - "total": { - "type": "integer" - } - } - }, - "response.Any": { - "type": "object", - "properties": { - "data": {}, - "error_msg": { - "type": "string", - "example": "" - } - } - }, - "status.DatabaseInfoResponse": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "status.SystemStatusResponse": { - "type": "object", - "properties": { - "alloc": { - "type": "string" - }, - "buck_hash_sys": { - "type": "string" - }, - "frees": { - "type": "integer" - }, - "gc_sys": { - "type": "string" - }, - "heap_alloc": { - "type": "string" - }, - "heap_idle": { - "type": "string" - }, - "heap_inuse": { - "type": "string" - }, - "heap_objects": { - "type": "integer" - }, - "heap_released": { - "type": "string" - }, - "heap_sys": { - "type": "string" - }, - "last_gc_time": { - "type": "string" - }, - "last_pause": { - "type": "string" - }, - "lookups": { - "type": "integer" - }, - "mallocs": { - "type": "integer" - }, - "mcache_inuse": { - "type": "string" - }, - "mcache_sys": { - "type": "string" - }, - "mspan_inuse": { - "type": "string" - }, - "mspan_sys": { - "type": "string" - }, - "next_gc": { - "type": "string" - }, - "num_gc": { - "type": "integer" - }, - "num_goroutine": { - "type": "integer" - }, - "other_sys": { - "type": "string" - }, - "pause_total_ns": { - "type": "string" - }, - "stack_inuse": { - "type": "string" - }, - "stack_sys": { - "type": "string" - }, - "sys": { - "type": "string" - }, - "total_alloc": { - "type": "string" - }, - "uptime": { - "type": "string" - } - } - }, - "system_config.CreateSystemConfigRequest": { - "type": "object", - "required": [ - "key", - "type", - "value" - ], - "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 64 - }, - "type": { - "type": "string", - "enum": [ - "system", - "business" - ] - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - } - }, - "system_config.TestSMTPRequest": { - "type": "object", - "required": [ - "smtp_host", - "smtp_password", - "smtp_port", - "smtp_username", - "to" - ], - "properties": { - "smtp_host": { - "type": "string", - "maxLength": 255 - }, - "smtp_password": { - "type": "string", - "maxLength": 255 - }, - "smtp_port": { - "type": "integer" - }, - "smtp_username": { - "type": "string", - "maxLength": 255 - }, - "to": { - "type": "string" - } - } - }, - "system_config.TestSMTPResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "log": { - "type": "string" - }, - "success": { - "type": "boolean" - } - } - }, - "system_config.UpdateSystemConfigRequest": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - } - }, - "task.CreateScheduleRequest": { - "type": "object", - "required": [ - "cron", - "is_active", - "name", - "task_type" - ], - "properties": { - "cron": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - } - } - }, - "task.DispatchTaskRequest": { - "type": "object", - "required": [ - "task_type" - ], - "properties": { - "end_time": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "start_time": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "user_id": { - "type": "integer" - } - } - }, - "task.TaskMeta": { - "type": "object", - "properties": { - "asynq_task": { - "type": "string" - }, - "description": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "params": { - "type": "array", - "items": { - "$ref": "#/definitions/task.TaskParam" - } - }, - "queue": { - "type": "string" - }, - "retryable": { - "description": "是否支持手动重试", - "type": "boolean" - }, - "supports_time": { - "type": "boolean" - }, - "type": { - "type": "string" - } - } - }, - "task.TaskParam": { - "type": "object", - "properties": { - "description": { - "description": "描述", - "type": "string" - }, - "label": { - "description": "显示名称", - "type": "string" - }, - "name": { - "description": "参数键名", - "type": "string" - }, - "placeholder": { - "description": "占位符", - "type": "string" - }, - "required": { - "description": "是否必填", - "type": "boolean" - }, - "type": { - "description": "类型:string, text, number, boolean", - "type": "string" - } - } - }, - "task.UpdateScheduleRequest": { - "type": "object", - "required": [ - "cron", - "is_active", - "name", - "task_type" - ], - "properties": { - "cron": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - } - } - }, - "template.CreateTemplateRequest": { - "type": "object", - "required": [ - "content", - "key", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 80 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "template.UpdateTemplateRequest": { - "type": "object", - "required": [ - "content", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "tls.ApplyInput": { - "type": "object", - "properties": { - "acme_account_id": { - "type": "integer" - }, - "auto_renew": { - "type": "boolean" - }, - "disable_cname": { - "type": "boolean" - }, - "dns1": { - "type": "string" - }, - "dns2": { - "type": "string" - }, - "dns_account_id": { - "type": "integer" - }, - "key_algorithm": { - "type": "string" - }, - "name": { - "type": "string" - }, - "other_domains": { - "type": "string" - }, - "primary_domain": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "skip_dns": { - "type": "boolean" - } - } - }, - "tls.CertificateContent": { - "type": "object", - "properties": { - "acme_account_id": { - "type": "integer" - }, - "apply_message": { - "type": "string" - }, - "apply_status": { - "type": "string" - }, - "auto_renew": { - "type": "boolean" - }, - "cert_pem": { - "type": "string" - }, - "disable_cname": { - "type": "boolean" - }, - "dns1": { - "type": "string" - }, - "dns2": { - "type": "string" - }, - "dns_account_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "key_algorithm": { - "type": "string" - }, - "key_pem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "other_domains": { - "type": "string" - }, - "primary_domain": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "skip_dns": { - "type": "boolean" - } - } - }, - "tls.CertificateInput": { - "type": "object", - "properties": { - "cert_pem": { - "type": "string" - }, - "key_pem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - } - } - }, - "tls.DNSAccountInput": { - "type": "object", - "properties": { - "authorization": { - "type": "string" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "tls.ManagedDomainInput": { - "type": "object", - "properties": { - "cert_id": { - "type": "integer" - }, - "domain": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "remark": { - "type": "string" - } - } - }, - "tls.ManagedDomainMatchCandidate": { - "type": "object", - "properties": { - "certificate_id": { - "type": "integer" - }, - "certificate_name": { - "type": "string" - }, - "domain": { - "type": "string" - }, - "managed_domain_id": { - "type": "integer" - }, - "match_type": { - "type": "string" - } - } - }, - "tls.ManagedDomainMatchResult": { - "type": "object", - "properties": { - "candidate": { - "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" - }, - "candidates": { - "type": "array", - "items": { - "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" - } - }, - "domain": { - "type": "string" - }, - "matched": { - "type": "boolean" - } - } - }, - "updater.Status": { - "type": "object", - "properties": { - "asset_name": { - "type": "string" - }, - "build_time": { - "type": "string" - }, - "can_upgrade": { - "type": "boolean" - }, - "current_version": { - "type": "string" - }, - "latest_version": { - "type": "string" - }, - "platform": { - "type": "string" - }, - "prerelease": { - "type": "boolean" - }, - "published_at": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "release_notes": { - "type": "string" - }, - "release_url": { - "type": "string" - }, - "update_available": { - "type": "boolean" - }, - "upstream_repository": { - "type": "string" - } - } - }, - "user.changePasswordRequest": { - "type": "object", - "properties": { - "new_password": { - "type": "string" - }, - "old_password": { - "type": "string" - } - } - }, - "user.createTokenRequest": { - "type": "object", - "properties": { - "is_admin": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "user.createUserRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string", - "maxLength": 255 - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "nickname": { - "type": "string", - "maxLength": 64 - }, - "password": { - "type": "string", - "maxLength": 64, - "minLength": 8 - }, - "username": { - "type": "string", - "maxLength": 64, - "minLength": 3 - } - } - }, - "user.listUsersResponse": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "users": { - "type": "array", - "items": { - "$ref": "#/definitions/user.user" - } - } - } - }, - "user.loginRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.registerRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "email": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.sendEmailCodeRequest": { - "type": "object", - "required": [ - "email", - "scene" - ], - "properties": { - "email": { - "type": "string" - }, - "scene": { - "type": "string" - } - } - }, - "user.tokenResponse": { - "type": "object", - "properties": { - "record": { - "$ref": "#/definitions/model.AccessToken" - }, - "token": { - "type": "string" - } - } - }, - "user.updateProfileRequest": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "user.updateUserStatusRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "user.user": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "last_login_at": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "waf.IDsRequest": { - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "waf.IPGroupAutoTestInput": { - "type": "object", - "properties": { - "auto_config": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "waf.IPGroupAutoTestResult": { - "type": "object", - "properties": { - "lookback_minutes": { - "type": "integer" - }, - "matched_count": { - "type": "integer" - }, - "matched_ips": { - "type": "array", - "items": { - "type": "string" - } - }, - "rule_count": { - "type": "integer" - }, - "tested_at": { - "type": "string" - } - } - }, - "waf.IPGroupExtIPView": { - "type": "object", - "properties": { - "captured_at": { - "type": "string" - }, - "ip": { - "type": "string" - } - } - }, - "waf.IPGroupInput": { - "type": "object", - "properties": { - "auto_config": { - "type": "array", - "items": { - "type": "integer" - } - }, - "enabled": { - "type": "boolean" - }, - "ip_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "subscription_format": { - "type": "string" - }, - "subscription_mapping_rule": { - "type": "string" - }, - "subscription_url": { - "type": "string" - }, - "sync_interval_minutes": { - "type": "integer" - }, - "type": { - "type": "string" - } - } - }, - "waf.IPGroupSyncResult": { - "type": "object", - "properties": { - "group": { - "$ref": "#/definitions/waf.IPGroupView" - }, - "ip_count": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "next_sync_at": { - "type": "string" - }, - "status": { - "type": "string" - }, - "synced_at": { - "type": "string" - } - } - }, - "waf.IPGroupView": { - "type": "object", - "properties": { - "auto_config": { - "type": "array", - "items": { - "type": "integer" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "ext_ips": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.IPGroupExtIPView" - } - }, - "id": { - "type": "integer" - }, - "ip_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "last_sync_message": { - "type": "string" - }, - "last_sync_status": { - "type": "string" - }, - "last_synced_at": { - "type": "string" - }, - "name": { - "type": "string" - }, - "next_sync_at": { - "type": "string" - }, - "referenced_by_rule_count": { - "type": "integer" - }, - "remark": { - "type": "string" - }, - "subscription_format": { - "type": "string" - }, - "subscription_mapping_rule": { - "type": "string" - }, - "subscription_url": { - "type": "string" - }, - "sync_interval_minutes": { - "type": "integer" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "waf.PoWConfig": { - "type": "object", - "properties": { - "algorithm": { - "type": "string" - }, - "blacklist": { - "$ref": "#/definitions/waf.PoWListConfig" - }, - "challenge_ttl": { - "type": "integer" - }, - "difficulty": { - "type": "integer" - }, - "session_ttl": { - "type": "integer" - }, - "whitelist": { - "$ref": "#/definitions/waf.PoWListConfig" - } - } - }, - "waf.PoWListConfig": { - "type": "object", - "properties": { - "ip_cidrs": { - "type": "array", - "items": { - "type": "string" - } - }, - "ips": { - "type": "array", - "items": { - "type": "string" - } - }, - "path_regexes": { - "type": "array", - "items": { - "type": "string" - } - }, - "paths": { - "type": "array", - "items": { - "type": "string" - } - }, - "user_agents": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "waf.RuleGroupInput": { - "type": "object", - "properties": { - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "name": { - "type": "string" - }, - "pow_config": { - "type": "array", - "items": { - "type": "integer" - } - }, - "pow_enabled": { - "type": "boolean" - }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "remark": { - "type": "string" - } - } - }, - "waf.RuleGroupView": { - "type": "object", - "properties": { - "applied_site_count": { - "type": "integer" - }, - "applied_site_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "is_global": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "pow_config": { - "$ref": "#/definitions/waf.PoWConfig" - }, - "pow_enabled": { - "type": "boolean" - }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "remark": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "waf.SiteRuleGroupsView": { - "type": "object", - "properties": { - "applied_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "applied_rule_groups": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.RuleGroupView" - } - }, - "global_rule_group": { - "$ref": "#/definitions/waf.RuleGroupView" - }, - "route_id": { - "type": "integer" - }, - "rule_groups": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - } - }, - "securityDefinitions": { - "SessionCookie": { - "type": "apiKey", - "name": "session", - "in": "cookie" - } - } -}` - -// SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = &swag.Spec{ - Version: "1.0.0", - Host: "", - BasePath: "/", - Schemes: []string{}, - Title: "OpenFlare API", - Description: "OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。", - InfoInstanceName: "swagger", - SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", -} - -func init() { - swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) -} diff --git a/Wavelet/docs/swagger.json b/Wavelet/docs/swagger.json deleted file mode 100644 index ade96ab2..00000000 --- a/Wavelet/docs/swagger.json +++ /dev/null @@ -1,17128 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "description": "OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。", - "title": "OpenFlare API", - "contact": { - "name": "OpenFlare", - "url": "https://github.com/Rain-kl/OpenFlare" - }, - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "version": "1.0.0" - }, - "basePath": "/", - "paths": { - "/api/cap/challenge": { - "post": { - "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "cap" - ], - "summary": "生成人机验证难题", - "parameters": [ - { - "description": "可选范围限制参数", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/cap.challengeRequest" - } - } - ], - "responses": { - "200": { - "description": "成功返回 PoW 难题", - "schema": { - "$ref": "#/definitions/cap.ChallengeResponse" - } - }, - "500": { - "description": "内部服务错误", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - } - } - } - }, - "/api/cap/redeem": { - "post": { - "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "cap" - ], - "summary": "校验人机验证解答", - "parameters": [ - { - "description": "难题 Token 与解答 solutions 数组", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/cap.redeemRequest" - } - } - ], - "responses": { - "200": { - "description": "核销成功,返回 X-Cap-Token", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - }, - "400": { - "description": "参数错误或核销失败", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - }, - "500": { - "description": "内部服务错误", - "schema": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" - } - } - } - } - }, - "/api/health": { - "get": { - "description": "检查服务是否正常运行,可用于负载均衡存活探测", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "健康检查", - "responses": { - "200": { - "description": "服务正常", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/auth-sources": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取认证源列表", - "responses": { - "200": { - "description": "认证源列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建认证源", - "parameters": [ - { - "description": "创建认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功,返回认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或验证失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/auth-sources/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新认证源", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "认证源 ID 或名称", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功,返回更新后的认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或验证失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除认证源", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "认证源 ID 或名称", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "ID 无效或删除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/auth-sources/{id}/toggle": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "切换认证源启用状态", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "认证源 ID 或名称", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "启用状态", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "切换成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "验证失败或认证源不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/cache/clear": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "清空缓存", - "responses": { - "200": { - "description": "清理成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/cache/config": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新缓存配置", - "parameters": [ - { - "description": "缓存配置请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/cache.updateCacheConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/cache/status": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取缓存状态", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/diskcache.Status" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-export": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "admin" - ], - "summary": "导出数据库", - "responses": { - "200": { - "description": "数据库文件", - "schema": { - "type": "file" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "导出失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据库信息", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/status.DatabaseInfoResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-manage/overview": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据库运行概览", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/db_manage.DBOverviewResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-manage/query": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "执行 SQL 查询", - "parameters": [ - { - "description": "SQL 请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/db_manage.ExecuteSQLRequest" - } - } - ], - "responses": { - "200": { - "description": "执行完毕", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/db_manage.ExecuteSQLResponse" - } - } - } - ] - } - }, - "400": { - "description": "SQL 语句错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/db-manage/tables": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前数据库的所有用户自定义表名称列表,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据库所有表名", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取系统历史日志,cursor=0 获取最新日志,cursor\u003e0 获取更早日志", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取系统日志", - "parameters": [ - { - "type": "integer", - "default": 0, - "description": "日志游标,0=获取最新", - "name": "cursor", - "in": "query" - }, - { - "type": "integer", - "default": 200, - "description": "每页条数", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/logs.logsResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs/access": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取用户访问日志", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "页码", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "用户名模糊搜索", - "name": "username", - "in": "query" - }, - { - "type": "string", - "description": "接口路径模糊搜索", - "name": "path", - "in": "query" - }, - { - "type": "string", - "description": "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", - "name": "start_time", - "in": "query" - }, - { - "type": "string", - "description": "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", - "name": "end_time", - "in": "query" - } - ], - "responses": { - "200": { - "description": "访问日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/logs.accessLogsResponse" - } - } - } - ] - } - }, - "400": { - "description": "ClickHouse 未启用或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs/analytics": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取访问日志分析数据", - "responses": { - "200": { - "description": "分析统计数据", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/logs.logsAnalyticsResponse" - } - } - } - ] - } - }, - "400": { - "description": "ClickHouse 未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/logs/ws": { - "get": { - "description": "通过 WebSocket 实时推送系统日志,需要管理员权限", - "tags": [ - "admin" - ], - "summary": "系统日志实时推送", - "responses": {} - } - }, - "/api/v1/admin/push/channels": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统配置的所有消息通道列表,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有消息通道", - "responses": { - "200": { - "description": "消息通道列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushChannel" - } - } - } - } - ] - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "新建一个消息通道配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "创建消息通道", - "parameters": [ - { - "description": "创建参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.CreateChannelRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.PushChannel" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/channels/definitions": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有消息通道配置字段定义", - "responses": { - "200": { - "description": "通道配置定义列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/push.Definition" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/channels/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "触发一次临时的或现有的通道连通性推送测试,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "测试通道连通性", - "parameters": [ - { - "description": "测试参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.TestChannelRequest" - } - } - ], - "responses": { - "200": { - "description": "测试触发成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/push/channels/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "修改消息通道配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "更新消息通道", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "通道ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.UpdateChannelRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.PushChannel" - } - } - } - ] - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据ID删除消息通道,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "删除消息通道", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "通道ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/push/events": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有通知事件", - "responses": { - "200": { - "description": "通知事件列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushEvent" - } - } - } - } - ] - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "创建通知事件", - "parameters": [ - { - "description": "创建参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.CreateEventRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.PushEvent" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/events/builtin": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "获取所有内置通知事件", - "responses": { - "200": { - "description": "内置通知事件列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/push.EventMetadata" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/events/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "更新通知事件", - "parameters": [ - { - "type": "integer", - "description": "事件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.UpdateEventRequest" - } - } - ], - "responses": { - "200": { - "description": "修改成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除数据库中的特定通知事件配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "删除通知事件配置", - "parameters": [ - { - "type": "integer", - "description": "事件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/events/{id}/toggle": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "启用或禁用指定的通知事件", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "快捷切换通知事件启用状态", - "parameters": [ - { - "type": "integer", - "description": "事件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "切换成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/histories": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回分页的通知历史日志数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "分页获取通知推送历史", - "parameters": [ - { - "type": "integer", - "description": "当前页码", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "分页大小", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "过滤事件名称", - "name": "event_key", - "in": "query" - }, - { - "type": "string", - "description": "过滤发送状态", - "name": "status", - "in": "query" - } - ], - "responses": { - "200": { - "description": "推送历史列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/push.pushHistoriesResponse" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/push/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-push" - ], - "summary": "测试推送通道发送", - "parameters": [ - { - "description": "测试请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/push.TestPushRequest" - } - } - ], - "responses": { - "200": { - "description": "测试成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/status": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取系统状态信息", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/status.SystemStatusResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/system-configs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取系统配置列表", - "parameters": [ - { - "type": "string", - "description": "配置类型(system/business)", - "name": "type", - "in": "query" - } - ], - "responses": { - "200": { - "description": "系统配置列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.SystemConfig" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建系统配置", - "parameters": [ - { - "description": "创建请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/system_config.CreateSystemConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或配置键已存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/system-configs/smtp/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "测试 SMTP 邮件发送", - "parameters": [ - { - "description": "测试请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/system_config.TestSMTPRequest" - } - } - ], - "responses": { - "200": { - "description": "测试执行完毕", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/system_config.TestSMTPResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/system-configs/{key}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据配置键获取对应的系统配置详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取单个系统配置", - "parameters": [ - { - "type": "string", - "description": "配置键", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "系统配置详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.SystemConfig" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "配置不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新系统配置", - "parameters": [ - { - "type": "string", - "description": "配置键", - "name": "key", - "in": "path", - "required": true - }, - { - "description": "更新请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "配置不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/dispatch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "下发异步任务", - "parameters": [ - { - "description": "任务请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/task.DispatchTaskRequest" - } - } - ], - "responses": { - "200": { - "description": "任务已入队", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "任务类型不存在或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "任务入队失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/executions": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "查询任务执行记录", - "parameters": [ - { - "type": "string", - "description": "状态筛选 (pending/running/succeeded/failed)", - "name": "status", - "in": "query" - }, - { - "type": "string", - "description": "任务类型筛选", - "name": "task_type", - "in": "query" - }, - { - "type": "integer", - "default": 1, - "description": "页码", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "每页条数", - "name": "page_size", - "in": "query" - } - ], - "responses": { - "200": { - "description": "任务执行记录列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "object" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/executions/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "查询任务执行详情", - "parameters": [ - { - "type": "integer", - "description": "任务执行记录 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "任务执行详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TaskExecution" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/executions/{id}/retry": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "重新下发一条失败的任务,创建新的执行记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "重试失败任务", - "parameters": [ - { - "type": "integer", - "description": "任务执行记录 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "新任务的 TaskID", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "任务不支持重试或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "重试失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/schedules": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取定时任务列表", - "responses": { - "200": { - "description": "定时任务列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Schedule" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建定时任务", - "parameters": [ - { - "description": "创建定时任务请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/task.CreateScheduleRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功的定时任务信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Schedule" - } - } - } - ] - } - }, - "400": { - "description": "Cron 表达式无效、异步任务类型不存在或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "保存定时任务失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/schedules/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "修改定时任务", - "parameters": [ - { - "type": "integer", - "description": "定时任务 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "修改定时任务请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/task.UpdateScheduleRequest" - } - } - ], - "responses": { - "200": { - "description": "修改后的定时任务信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Schedule" - } - } - } - ] - } - }, - "400": { - "description": "Cron 表达式无效、参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "定时任务不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "修改定时任务失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定的定时任务配置,并触发调度器热加载,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除定时任务", - "parameters": [ - { - "type": "integer", - "description": "定时任务 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "删除定时任务失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/tasks/types": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取支持的任务类型", - "responses": { - "200": { - "description": "任务类型列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/task.TaskMeta" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/templates": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有通知模板列表,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取模板列表", - "responses": { - "200": { - "description": "模板列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Template" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建模板", - "parameters": [ - { - "description": "创建请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/template.CreateTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或模板标识符已存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/templates/{key}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据模板标识符获取对应的模板详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取单个模板", - "parameters": [ - { - "type": "string", - "description": "模板标识符", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "模板详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Template" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "模板不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据模板标识符更新对应的模板内容,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新模板", - "parameters": [ - { - "type": "string", - "description": "模板标识符", - "name": "key", - "in": "path", - "required": true - }, - { - "description": "更新请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/template.UpdateTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Template" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "模板不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除模板", - "parameters": [ - { - "type": "string", - "description": "模板标识符", - "name": "key", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "不可删除系统模板", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "模板不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/update": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取应用更新状态", - "responses": { - "200": { - "description": "更新状态", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/updater.Status" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "查询失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/update/apply": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "下载并应用应用更新", - "responses": { - "200": { - "description": "升级已准备并即将重启", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "当前版本不可升级", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "升级准备失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取文件列表", - "parameters": [ - { - "type": "integer", - "description": "页码(默认 1)", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(默认 20,最大 100)", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "文件名关键词(模糊匹配)", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "description": "业务分类过滤", - "name": "type", - "in": "query" - }, - { - "type": "string", - "description": "扩展名过滤", - "name": "extension", - "in": "query" - }, - { - "type": "integer", - "format": "int64", - "description": "上传用户 ID", - "name": "user_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.listFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/download/batch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", - "consumes": [ - "application/json" - ], - "produces": [ - "application/octet-stream" - ], - "tags": [ - "admin" - ], - "summary": "批量打包下载", - "parameters": [ - { - "description": "包含文件 ID 数组 of string 的请求体", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.batchDownloadRequest" - } - } - ], - "responses": { - "200": { - "description": "成功下载打包后的 ZIP", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "打包失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/download/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "admin" - ], - "summary": "下载单文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "图片质量 (low, medium, high, origin),默认为 origin", - "name": "quality", - "in": "query" - } - ], - "responses": { - "200": { - "description": "成功下载文件", - "schema": { - "type": "file" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/stats": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取文件统计数据", - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.fileStatsResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/types": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回数据库中所有已上传文件实际拥有的业务类型列表", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取文件业务类型列表", - "responses": { - "200": { - "description": "业务类型列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/users": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取用户列表", - "parameters": [ - { - "minimum": 1, - "type": "integer", - "name": "page", - "in": "query" - }, - { - "maximum": 100, - "minimum": 1, - "type": "integer", - "name": "page_size", - "in": "query" - }, - { - "type": "integer", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "name": "username", - "in": "query" - } - ], - "responses": { - "200": { - "description": "用户列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.listUsersResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一个本地密码登录的新用户,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建用户", - "parameters": [ - { - "description": "创建用户参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.createUserRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.user" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或用户名已存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/users/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取用户详情", - "parameters": [ - { - "type": "integer", - "description": "用户 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "用户详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.user" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "用户不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "删除用户", - "parameters": [ - { - "type": "integer", - "description": "用户 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限、尝试删除管理员或当前用户", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "用户不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/users/{id}/status": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "更新用户状态", - "parameters": [ - { - "type": "integer", - "description": "用户 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "状态参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.updateUserStatusRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限或尝试禁用管理员", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "用户不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/config/public": { - "get": { - "description": "返回系统配置表中 visibility 为 1 的配置键值集合", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "config" - ], - "summary": "获取公共配置", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/custom/hello": { - "get": { - "description": "A sample business API for customization", - "produces": [ - "application/json" - ], - "tags": [ - "custom" - ], - "summary": "Sample Hello API", - "responses": { - "200": { - "description": "成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/d/access-logs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出访问日志", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "请求路径", - "name": "path", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "访问日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按保留天数清理过期访问日志记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "清理访问日志", - "parameters": [ - { - "description": "清理参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/observability.AccessLogCleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/folds": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按时间桶聚合访问日志并分页返回,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出折叠访问日志", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "请求路径", - "name": "path", - "in": "query" - }, - { - "type": "integer", - "description": "折叠时间窗口(分钟)", - "name": "fold_minutes", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "折叠访问日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.FoldedAccessLogList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/folds/ip-summary": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "在指定时间桶内按 IP 聚合访问统计,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出折叠访问日志 IP 汇总", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "请求路径", - "name": "path", - "in": "query" - }, - { - "type": "string", - "description": "时间桶起始时间", - "name": "bucket_started_at", - "in": "query" - }, - { - "type": "integer", - "description": "折叠时间窗口(分钟)", - "name": "fold_minutes", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "折叠 IP 汇总列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.FoldedAccessLogIPList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/ip-summary": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "列出访问日志 IP 汇总", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "每页条数", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "排序字段", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "排序方向", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "IP 汇总列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogIPSummaryList" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/access-logs/ip-summary/trend": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-observability" - ], - "summary": "获取访问日志 IP 趋势", - "parameters": [ - { - "type": "string", - "description": "节点 ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "客户端 IP", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "请求 Host", - "name": "host", - "in": "query" - }, - { - "type": "integer", - "description": "统计时间范围(小时)", - "name": "hours", - "in": "query" - }, - { - "type": "integer", - "description": "时间桶粒度(分钟)", - "name": "bucket_minutes", - "in": "query" - } - ], - "responses": { - "200": { - "description": "IP 访问趋势", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/observability.AccessLogIPTrendView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/acme-accounts/default": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回系统默认 ACME 账号配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "获取默认 ACME 账号", - "responses": { - "200": { - "description": "默认 ACME 账号", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AcmeAccount" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/apply-logs": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-apply-log" - ], - "summary": "获取配置下发日志", - "parameters": [ - { - "type": "string", - "description": "节点 ID 筛选", - "name": "node_id", - "in": "query" - }, - { - "type": "integer", - "description": "页码", - "name": "pageNo", - "in": "query" - }, - { - "type": "integer", - "description": "页码(别名)", - "name": "page_no", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量", - "name": "pageSize", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(别名)", - "name": "page_size", - "in": "query" - } - ], - "responses": { - "200": { - "description": "下发日志列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/apply_log.ListResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/apply-logs/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按保留天数清理历史下发记录,或删除全部记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-apply-log" - ], - "summary": "清理配置下发日志", - "parameters": [ - { - "description": "清理参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/apply_log.CleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/apply_log.CleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "获取配置版本列表", - "responses": { - "200": { - "description": "配置版本列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ConfigVersionSummary" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/active": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前正在使用的配置版本,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "获取当前活跃配置版本", - "responses": { - "200": { - "description": "活跃配置版本", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限、不存在或无活跃版本", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除超出保留数量的非活跃配置版本,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "清理历史配置版本", - "parameters": [ - { - "description": "清理参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/config_version.CleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/config_version.CleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/diff": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "对比当前草稿配置与活跃版本之间的差异,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "对比草稿与活跃配置", - "responses": { - "200": { - "description": "配置差异", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/config_version.ConfigDiffResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/preview": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "渲染并返回当前草稿配置的预览结果,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "预览当前草稿配置", - "responses": { - "200": { - "description": "配置预览", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/config_version.ConfigPreviewResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/publish": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将当前草稿配置发布为新版本,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "发布配置版本", - "parameters": [ - { - "type": "boolean", - "description": "是否强制发布", - "name": "force", - "in": "query" - } - ], - "responses": { - "200": { - "description": "发布成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定配置版本的完整快照与渲染内容,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "获取配置版本详情", - "parameters": [ - { - "type": "integer", - "description": "配置版本 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "配置版本详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或版本不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/config-versions/{id}/activate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将指定历史版本设为当前活跃配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-config-version" - ], - "summary": "激活配置版本", - "parameters": [ - { - "type": "integer", - "description": "配置版本 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "激活成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ConfigVersion" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或版本不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dashboard/overview": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-dashboard" - ], - "summary": "获取仪表盘概览", - "responses": { - "200": { - "description": "仪表盘概览", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/dashboard.OverviewPayload" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dns-accounts": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 DNS 提供商账号,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "列出 DNS 账号", - "responses": { - "200": { - "description": "DNS 账号列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.DNSAccount" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 DNS 提供商账号,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "创建 DNS 账号", - "parameters": [ - { - "description": "DNS 账号参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.DNSAccountInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的 DNS 账号", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.DNSAccount" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dns-accounts/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 DNS 提供商账号,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "删除 DNS 账号", - "parameters": [ - { - "type": "integer", - "description": "DNS 账号 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/dns-accounts/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 DNS 提供商账号,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新 DNS 账号", - "parameters": [ - { - "type": "integer", - "description": "DNS 账号 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "DNS 账号参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.DNSAccountInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的 DNS 账号", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.DNSAccount" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部托管域名及关联证书,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "列出托管域名", - "responses": { - "200": { - "description": "托管域名列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ManagedDomain" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的托管域名记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "创建托管域名", - "parameters": [ - { - "description": "托管域名参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ManagedDomainInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的托管域名", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ManagedDomain" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains/match": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按域名查询可用的证书匹配候选,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "匹配托管域名证书", - "parameters": [ - { - "type": "string", - "description": "域名", - "name": "domain", - "in": "query", - "required": true - } - ], - "responses": { - "200": { - "description": "证书匹配结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/tls.ManagedDomainMatchResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除托管域名,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "删除托管域名", - "parameters": [ - { - "type": "integer", - "description": "托管域名 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/managed-domains/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新托管域名,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新托管域名", - "parameters": [ - { - "type": "integer", - "description": "托管域名 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "托管域名参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ManagedDomainInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的托管域名", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.ManagedDomain" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有节点及最新配置下发记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取节点列表", - "responses": { - "200": { - "description": "节点列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/node.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的边缘节点记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "创建节点", - "parameters": [ - { - "description": "节点参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/node.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/bootstrap-token": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全局节点发现引导令牌,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取引导令牌", - "responses": { - "200": { - "description": "引导令牌", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.BootstrapView" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/bootstrap-token/rotate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "重新生成全局节点发现引导令牌,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "轮换引导令牌", - "responses": { - "200": { - "description": "新引导令牌", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.BootstrapView" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/agent-release": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定节点可用的最新 Agent 版本信息,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取 Agent 发布信息", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "发布渠道", - "name": "channel", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Agent 发布信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.AgentReleaseInfo" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/agent-update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "向指定节点下发 Agent 自更新指令,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "请求 Agent 更新", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新参数(可选)", - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/node.AgentUpdateInput" - } - } - ], - "responses": { - "200": { - "description": "更新请求已下发", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定节点记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "删除节点", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/force-sync": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "向指定节点下发强制同步当前活跃配置的指令,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "请求强制同步配置", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "同步请求已下发", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/observability": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定节点的指标、健康事件与流量分析数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "获取节点可观测性数据", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "统计时间范围(小时)", - "name": "hours", - "in": "query" - }, - { - "type": "integer", - "description": "返回记录数量上限", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "可观测性数据", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.ObservabilityView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/observability/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清理指定节点的历史健康事件记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "清理节点健康事件", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.HealthEventCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/openresty-restart": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "向指定节点下发 OpenResty 重启指令,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "请求重启 OpenResty", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "重启请求已下发", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/nodes/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定节点的配置信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-node" - ], - "summary": "更新节点", - "parameters": [ - { - "type": "integer", - "description": "节点 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "节点参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/node.Input" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/node.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或节点不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/notice": { - "get": { - "description": "返回 OpenFlare 控制台公告文本,无需登录", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "获取系统公告", - "responses": { - "200": { - "description": "系统公告", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部非敏感 OpenFlare 配置项,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "列出 OpenFlare 配置项", - "responses": { - "200": { - "description": "配置项列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareOption" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/database/cleanup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按目标与保留天数清理可观测性相关数据表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "清理可观测性数据库", - "parameters": [ - { - "description": "清理参数", - "name": "request", - "in": "body", - "schema": { - "$ref": "#/definitions/option.databaseCleanupInput" - } - } - ], - "responses": { - "200": { - "description": "清理结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.databaseCleanupResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/geoip/lookup": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按提供商与 IP 查询地理位置信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "GeoIP 地址查询", - "parameters": [ - { - "description": "查询参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/option.geoIPLookupRequest" - } - } - ], - "responses": { - "200": { - "description": "GeoIP 查询结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.geoIPLookupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新单个 OpenFlare 配置项,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "更新 OpenFlare 配置项", - "parameters": [ - { - "description": "配置项", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.OpenFlareOption" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/option/update-batch": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "批量更新多个 OpenFlare 配置项,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "批量更新 OpenFlare 配置项", - "parameters": [ - { - "description": "批量配置项", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/option.optionBatchPayload" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有源站及关联代理规则数量,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "获取源站列表", - "responses": { - "200": { - "description": "源站列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/origin.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的上游源站记录,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "创建源站", - "parameters": [ - { - "description": "源站参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/origin.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/origin.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定源站信息及关联代理规则摘要,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "获取源站详情", - "parameters": [ - { - "type": "integer", - "description": "源站 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "源站详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/origin.DetailView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或源站不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定源站记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "删除源站", - "parameters": [ - { - "type": "integer", - "description": "源站 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或源站不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/origins/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定源站的配置信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-origin" - ], - "summary": "更新源站", - "parameters": [ - { - "type": "integer", - "description": "源站 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "源站参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/origin.Input" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/origin.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或源站不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 OpenFlare Pages 项目,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "列出 Pages 项目", - "responses": { - "200": { - "description": "Pages 项目列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/pages.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 OpenFlare Pages 项目,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "创建 Pages 项目", - "parameters": [ - { - "description": "项目参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/pages.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功的项目", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/deployments/{deployment_id}/files": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定部署包含的文件清单,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "列出 Pages 部署文件", - "parameters": [ - { - "type": "integer", - "description": "部署 ID", - "name": "deployment_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "部署文件列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/pages.DeploymentFileView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "部署不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 Pages 项目详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "获取 Pages 项目详情", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Pages 项目详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 OpenFlare Pages 项目,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "删除 Pages 项目", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定项目的全部部署记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "列出 Pages 部署", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "部署列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/pages.DeploymentView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments/upload": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "为指定项目上传 ZIP 部署包,需要管理员权限", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "上传 Pages 部署包", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "file", - "description": "部署包 ZIP 文件", - "name": "package", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "部署记录", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.DeploymentView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments/{deployment_id}/activate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将指定部署设为项目当前生效版本,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "激活 Pages 部署", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "部署 ID", - "name": "deployment_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "激活后的项目", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目或部署不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/deployments/{deployment_id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定项目的部署记录,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "删除 Pages 部署", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "部署 ID", - "name": "deployment_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目或部署不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/pages/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 OpenFlare Pages 项目,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-pages" - ], - "summary": "更新 Pages 项目", - "parameters": [ - { - "type": "integer", - "description": "项目 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "项目参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/pages.Input" - } - } - ], - "responses": { - "200": { - "description": "更新后的项目", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/pages.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "项目不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有代理规则配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "获取代理规则列表", - "responses": { - "200": { - "description": "代理规则列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的反向代理规则,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "创建代理规则", - "parameters": [ - { - "description": "代理规则参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/proxy_route.Input" - } - } - ], - "responses": { - "200": { - "description": "创建成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回指定代理规则的完整配置,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "获取代理规则详情", - "parameters": [ - { - "type": "integer", - "description": "代理规则 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "代理规则详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或规则不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "删除指定代理规则,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "删除代理规则", - "parameters": [ - { - "type": "integer", - "description": "代理规则 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或规则不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/proxy-routes/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定代理规则的配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-proxy-route" - ], - "summary": "更新代理规则", - "parameters": [ - { - "type": "integer", - "description": "代理规则 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "代理规则参数", - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/proxy_route.Input" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/proxy_route.View" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "无权限或规则不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/status": { - "get": { - "description": "返回版本、认证源与系统公开配置,无需登录", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "获取 OpenFlare 公开状态", - "responses": { - "200": { - "description": "公开状态", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/option.statusView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 TLS 证书(不含 PEM),需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "列出 TLS 证书", - "responses": { - "200": { - "description": "证书列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "从 PEM 文本创建 TLS 证书,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "创建 TLS 证书", - "parameters": [ - { - "description": "证书参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.CertificateInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/apply": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "通过 ACME 申请新的 TLS 证书,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "申请 ACME 证书", - "parameters": [ - { - "description": "ACME 申请参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ApplyInput" - } - } - ], - "responses": { - "200": { - "description": "申请中的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/import-file": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "上传证书与私钥文件创建 TLS 证书,需要管理员权限", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "从文件导入 TLS 证书", - "parameters": [ - { - "type": "string", - "description": "证书名称", - "name": "name", - "in": "formData" - }, - { - "type": "string", - "description": "备注", - "name": "remark", - "in": "formData" - }, - { - "type": "file", - "description": "证书文件", - "name": "cert_file", - "in": "formData", - "required": true - }, - { - "type": "file", - "description": "私钥文件", - "name": "key_file", - "in": "formData", - "required": true - } - ], - "responses": { - "200": { - "description": "导入成功的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "获取 TLS 证书详情", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "证书详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/content": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回证书与私钥 PEM 内容,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "获取 TLS 证书 PEM 内容", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "证书 PEM 内容", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/tls.CertificateContent" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/convert-acme": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将已上传证书转换为 ACME 自动续期模式,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "将证书转为 ACME 管理", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "ACME 申请参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ApplyInput" - } - } - ], - "responses": { - "200": { - "description": "转换后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 TLS 证书,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "删除 TLS 证书", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/renew": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "手动触发 ACME 证书续期,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "续期 ACME 证书", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "续期后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 TLS 证书 PEM 信息,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新 TLS 证书", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "证书参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.CertificateInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/tls-certificates/{id}/update-acme": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 ACME 证书申请配置,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-tls" - ], - "summary": "更新 ACME 证书配置", - "parameters": [ - { - "type": "integer", - "description": "证书 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "ACME 申请参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/tls.ApplyInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的证书", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.TLSCertificate" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/uptimekuma/sync": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-option" - ], - "summary": "同步 Uptime Kuma", - "responses": { - "200": { - "description": "同步成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 WAF IP 组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "列出 WAF IP 组", - "responses": { - "200": { - "description": "IP 组列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 WAF IP 组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "创建 WAF IP 组", - "parameters": [ - { - "description": "IP 组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IPGroupInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的 IP 组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/test": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "测试 WAF IP 组自动配置", - "parameters": [ - { - "description": "自动配置参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IPGroupAutoTestInput" - } - } - ], - "responses": { - "200": { - "description": "测试结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupAutoTestResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 WAF IP 组详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "获取 WAF IP 组详情", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "IP 组详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 WAF IP 组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "删除 WAF IP 组", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}/sync": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "手动触发 WAF IP 组外部 IP 同步,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "同步 WAF IP 组", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "同步结果", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupSyncResult" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/ip-groups/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 WAF IP 组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "更新 WAF IP 组", - "parameters": [ - { - "type": "integer", - "description": "IP 组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "IP 组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IPGroupInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的 IP 组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.IPGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回全部 WAF 规则组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "列出 WAF 规则组", - "responses": { - "200": { - "description": "规则组列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建新的 WAF 规则组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "创建 WAF 规则组", - "parameters": [ - { - "description": "规则组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" - } - } - ], - "responses": { - "200": { - "description": "创建成功的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 返回 WAF 规则组详情,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "获取 WAF 规则组详情", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "规则组详情", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 删除 WAF 规则组,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "删除 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/sites": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "替换 WAF 规则组关联的代理站点列表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "替换规则组站点绑定", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "站点 ID 列表", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IDsRequest" - } - } - ], - "responses": { - "200": { - "description": "更新后的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/rule-groups/{id}/update": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "按 ID 更新 WAF 规则组,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "更新 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "规则组 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "规则组参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.RuleGroupInput" - } - } - ], - "responses": { - "200": { - "description": "更新后的规则组", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/d/waf/sites/{route_id}/rule-groups": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回代理站点关联的 WAF 规则组绑定,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "获取站点 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "代理路由 ID", - "name": "route_id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "站点规则组绑定", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.SiteRuleGroupsView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "替换代理站点关联的 WAF 规则组列表,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "openflare-waf" - ], - "summary": "替换站点 WAF 规则组", - "parameters": [ - { - "type": "integer", - "description": "代理路由 ID", - "name": "route_id", - "in": "path", - "required": true - }, - { - "description": "规则组 ID 列表", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/waf.IDsRequest" - } - } - ], - "responses": { - "200": { - "description": "更新后的站点规则组绑定", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/waf.SiteRuleGroupsView" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "记录不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/callback": { - "post": { - "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "OAuth 回调处理", - "parameters": [ - { - "description": "回调请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/oauth.CallbackRequest" - } - } - ], - "responses": { - "200": { - "description": "登录或绑定成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.OAuthCallbackResult" - } - } - } - ] - } - }, - "400": { - "description": "state 无效、参数错误或认证源错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "绑定场景未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "OAuth 认证失败或内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/external-accounts": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取外部帐号列表", - "responses": { - "200": { - "description": "外部帐号列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ExternalAccountView" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/external-accounts/{id}/delete": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "解除外部帐号绑定", - "parameters": [ - { - "type": "integer", - "format": "int64", - "description": "外部帐号绑定记录 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "解除绑定成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "ID 无效或解除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/login": { - "get": { - "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取登录授权地址", - "parameters": [ - { - "type": "string", - "description": "认证源名称,为空使用第一个启用的认证源", - "name": "source", - "in": "query" - } - ], - "responses": { - "200": { - "description": "授权 URL", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未配置", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常 or 构造 URL 失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/logout": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "退出登录", - "responses": { - "200": { - "description": "退出成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "500": { - "description": "Session 清除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/sources": { - "get": { - "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取可用登录源", - "responses": { - "200": { - "description": "登录源列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/oauth.AuthSourceView" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/oauth/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/oauth/{source}/authorize": { - "get": { - "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "发起指定认证源授权", - "parameters": [ - { - "type": "string", - "description": "认证源名称", - "name": "source", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", - "name": "purpose", - "in": "query" - } - ], - "responses": { - "200": { - "description": "授权 URL", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常或构造 URL 失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/upload": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "上传文件", - "parameters": [ - { - "type": "file", - "description": "要上传的文件", - "name": "file", - "in": "formData", - "required": true - }, - { - "type": "string", - "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)", - "name": "type", - "in": "formData" - }, - { - "type": "string", - "description": "额外的 JSON 格式元数据", - "name": "metadata", - "in": "formData" - } - ], - "responses": { - "200": { - "description": "上传成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Upload" - } - } - } - ] - } - }, - "400": { - "description": "请求参数错误或文件受限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/upload/my": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "获取我的文件列表", - "parameters": [ - { - "type": "integer", - "description": "页码(默认 1)", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页数量(默认 20,最大 100)", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "文件名关键词(模糊匹配)", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "description": "业务分类过滤", - "name": "type", - "in": "query" - }, - { - "type": "string", - "description": "扩展名过滤", - "name": "extension", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.listMyFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/upload/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新当前用户本人的文件名或访问权限模式 (AccessMode)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "更新我的文件信息", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "更新字段", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/handler.updateMyFileRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Upload" - } - } - } - ] - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将当前用户本人的文件状态置为 deleted(软删除)", - "produces": [ - "application/json" - ], - "tags": [ - "upload" - ], - "summary": "删除我的文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/access-tokens": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的所有 active access tokens(脱敏后)", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "获取当前用户的 AccessToken 列表", - "responses": { - "200": { - "description": "令牌列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AccessToken" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "创建一个新的 AccessToken", - "parameters": [ - { - "description": "令牌名称", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.createTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "新建令牌成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或超限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/access-tokens/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "撤销并删除一个属于当前用户的 API 访问令牌", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "删除一个 AccessToken", - "parameters": [ - { - "type": "string", - "description": "令牌ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "删除成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/access-tokens/{id}/rotate": { - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "轮换一个 AccessToken", - "parameters": [ - { - "type": "string", - "description": "令牌ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "令牌轮换成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/change-password": { - "post": { - "description": "修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "修改用户密码", - "parameters": [ - { - "description": "修改密码请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.changePasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "修改密码成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "400": { - "description": "原密码错误或新密码不符合要求", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "请先登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/login": { - "post": { - "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "用户密码登录", - "parameters": [ - { - "description": "登录请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.loginRequest" - } - } - ], - "responses": { - "200": { - "description": "登录成功,返回用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "用户名或密码错误、帐号已禁用等", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/logout": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "清除用户登录 Session,完成退出", - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "用户退出登录", - "responses": { - "200": { - "description": "退出成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - }, - "500": { - "description": "Session 清除失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/profile": { - "put": { - "description": "修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "修改当前登录用户的个人资料", - "parameters": [ - { - "description": "更新请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.updateProfileRequest" - } - } - ], - "responses": { - "200": { - "description": "修改成功,返回更新后的用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "邮箱已被占用或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/register": { - "post": { - "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "用户注册", - "parameters": [ - { - "description": "注册请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.registerRequest" - } - } - ], - "responses": { - "200": { - "description": "注册并登录成功,返回用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "参数错误、用户名已存在或注册已关闭", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/self": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/send-email-code": { - "post": { - "description": "向指定邮箱发送验证码(用于注册场景)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "发送邮箱验证码", - "parameters": [ - { - "description": "发送验证码请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.sendEmailCodeRequest" - } - } - ], - "responses": { - "200": { - "description": "发送成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/f/{id}": { - "get": { - "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "upload" - ], - "summary": "获取已上传文件", - "parameters": [ - { - "type": "string", - "description": "文件 ID", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "图片质量 (low, medium, high, origin),默认为 origin", - "name": "quality", - "in": "query" - } - ], - "responses": { - "200": { - "description": "成功获取文件内容", - "schema": { - "type": "file" - } - }, - "400": { - "description": "文件 ID 格式错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件未找到", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/robots.txt": { - "get": { - "description": "根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容", - "produces": [ - "text/plain" - ], - "tags": [ - "config" - ], - "summary": "获取 robots.txt", - "responses": { - "200": { - "description": "robots.txt 内容", - "schema": { - "type": "string" - } - } - } - } - } - }, - "definitions": { - "apply_log.CleanupInput": { - "type": "object", - "properties": { - "delete_all": { - "type": "boolean" - }, - "retention_days": { - "type": "integer" - } - } - }, - "apply_log.CleanupResult": { - "type": "object", - "properties": { - "cutoff": { - "type": "string" - }, - "delete_all": { - "type": "boolean" - }, - "deleted_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - } - } - }, - "apply_log.ListResult": { - "type": "object", - "properties": { - "current": { - "type": "integer" - }, - "rows": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareApplyLog" - } - }, - "total": { - "type": "integer" - }, - "totalPage": { - "type": "integer" - } - } - }, - "auth_source.AuthSourceRequest": { - "type": "object", - "properties": { - "client_id": { - "type": "string" - }, - "client_secret": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "openid_discovery_url": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "auth_source.ToggleAuthSourceRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "cache.updateCacheConfigRequest": { - "type": "object", - "required": [ - "max_size_mb", - "ttl_minutes" - ], - "properties": { - "lru_enabled": { - "type": "boolean" - }, - "max_size_mb": { - "type": "integer", - "minimum": 1 - }, - "ttl_minutes": { - "type": "integer", - "minimum": 0 - } - } - }, - "cap.ChallengeResponse": { - "type": "object", - "properties": { - "challenge": { - "type": "object", - "properties": { - "c": { - "type": "integer" - }, - "d": { - "type": "integer" - }, - "s": { - "type": "integer" - } - } - }, - "expires": { - "description": "ms timestamp", - "type": "integer" - }, - "token": { - "type": "string" - } - } - }, - "cap.challengeRequest": { - "type": "object", - "properties": { - "scope": { - "type": "string" - } - } - }, - "cap.redeemRequest": { - "type": "object", - "required": [ - "solutions", - "token" - ], - "properties": { - "scope": { - "type": "string" - }, - "solutions": { - "type": "array", - "items": { - "type": "integer" - } - }, - "token": { - "type": "string" - } - } - }, - "config_version.CleanupInput": { - "type": "object", - "properties": { - "keep_count": { - "type": "integer" - } - } - }, - "config_version.CleanupResult": { - "type": "object", - "properties": { - "deleted_count": { - "type": "integer" - }, - "message": { - "type": "string" - } - } - }, - "config_version.ConfigDiffResult": { - "type": "object", - "properties": { - "active_version": { - "type": "string" - }, - "active_website_count": { - "type": "integer" - }, - "added_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "added_sites": { - "type": "array", - "items": { - "type": "string" - } - }, - "changed_option_details": { - "type": "array", - "items": { - "$ref": "#/definitions/config_version.ConfigOptionDiffItem" - } - }, - "changed_option_keys": { - "type": "array", - "items": { - "type": "string" - } - }, - "current_website_count": { - "type": "integer" - }, - "main_config_changed": { - "type": "boolean" - }, - "modified_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "modified_sites": { - "type": "array", - "items": { - "type": "string" - } - }, - "removed_domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "removed_sites": { - "type": "array", - "items": { - "type": "string" - } - }, - "waf_config_changed": { - "type": "boolean" - } - } - }, - "config_version.ConfigOptionDiffItem": { - "type": "object", - "properties": { - "current_value": { - "type": "string" - }, - "key": { - "type": "string" - }, - "previous_value": { - "type": "string" - } - } - }, - "config_version.ConfigPreviewResult": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "main_config": { - "type": "string" - }, - "rendered_config": { - "type": "string" - }, - "route_config": { - "type": "string" - }, - "route_count": { - "type": "integer" - }, - "snapshot_json": { - "type": "string" - }, - "support_files": { - "type": "array", - "items": { - "$ref": "#/definitions/config_version.SupportFile" - } - }, - "website_count": { - "type": "integer" - } - } - }, - "config_version.SupportFile": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "path": { - "type": "string" - } - } - }, - "dashboard.Capacity": { - "type": "object", - "properties": { - "average_cpu_usage_percent": { - "type": "number" - }, - "average_memory_usage_percent": { - "type": "number" - }, - "high_cpu_nodes": { - "type": "integer" - }, - "high_memory_nodes": { - "type": "integer" - }, - "high_storage_nodes": { - "type": "integer" - } - } - }, - "dashboard.OverviewPayload": { - "type": "object", - "properties": { - "capacity": { - "$ref": "#/definitions/dashboard.Capacity" - }, - "distributions": { - "$ref": "#/definitions/dashboard.distributionsPayload" - }, - "generated_at": {}, - "nodes": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "summary": { - "$ref": "#/definitions/dashboard.Summary" - }, - "traffic": { - "$ref": "#/definitions/dashboard.Traffic" - }, - "trends": { - "$ref": "#/definitions/dashboard.trendsPayload" - } - } - }, - "dashboard.Summary": { - "type": "object", - "properties": { - "offline_nodes": { - "type": "integer" - }, - "online_nodes": { - "type": "integer" - }, - "pending_nodes": { - "type": "integer" - }, - "total_nodes": { - "type": "integer" - }, - "unhealthy_nodes": { - "type": "integer" - } - } - }, - "dashboard.Traffic": { - "type": "object", - "properties": { - "error_count": { - "type": "integer" - }, - "estimated_qps": { - "type": "number" - }, - "reported_nodes": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "unique_visitors": { - "type": "integer" - } - } - }, - "dashboard.distributionsPayload": { - "type": "object", - "properties": { - "source_countries": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "status_codes": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "top_domains": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - } - } - }, - "dashboard.trendsPayload": { - "type": "object", - "properties": { - "capacity_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "disk_io_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "network_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - }, - "traffic_24h": { - "type": "array", - "items": { - "type": "array", - "items": {} - } - } - } - }, - "db_manage.DBOverviewResponse": { - "type": "object", - "properties": { - "connections": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "size": { - "type": "string" - }, - "table_count": { - "type": "integer" - }, - "type": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "db_manage.ExecuteSQLRequest": { - "type": "object", - "required": [ - "sql" - ], - "properties": { - "sql": { - "type": "string" - } - } - }, - "db_manage.ExecuteSQLResponse": { - "type": "object", - "properties": { - "affected_rows": { - "type": "integer" - }, - "columns": { - "type": "array", - "items": { - "type": "string" - } - }, - "execution_time_ms": { - "type": "integer" - }, - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "type": { - "description": "\"select\" 或 \"exec\"", - "type": "string" - } - } - }, - "diskcache.Status": { - "type": "object", - "properties": { - "base_path": { - "type": "string" - }, - "keys_count": { - "type": "integer" - }, - "lru_enabled": { - "type": "boolean" - }, - "max_size_mb": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "ttl_minutes": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "expires": { - "type": "integer" - }, - "success": { - "type": "boolean" - }, - "token": { - "type": "string" - } - } - }, - "handler.batchDownloadRequest": { - "type": "object", - "required": [ - "ids" - ], - "properties": { - "ids": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - } - } - }, - "handler.distributionItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.fileStatsResponse": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - }, - "total_count": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.trendItem" - } - }, - "types": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - } - } - }, - "handler.listFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.listMyFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.updateMyFileRequest": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "file_name": { - "type": "string", - "maxLength": 255 - } - } - }, - "logger.LogEntry": { - "type": "object", - "properties": { - "data": { - "description": "一行日志原文(含换行符)", - "type": "string" - }, - "index": { - "description": "全局递增序号", - "type": "integer" - } - } - }, - "logs.accessLogItem": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "headers": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "ip": { - "type": "string" - }, - "latency": { - "type": "integer" - }, - "method": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "path": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "user_agent": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.accessLogsResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.accessLogItem" - } - }, - "total": { - "type": "integer" - } - } - }, - "logs.browserItem": { - "type": "object", - "properties": { - "browser": { - "type": "string" - }, - "count": { - "type": "integer" - } - } - }, - "logs.logsAnalyticsResponse": { - "type": "object", - "properties": { - "browsers": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.browserItem" - } - }, - "top_users": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.topUserItem" - } - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.trendItem" - } - } - } - }, - "logs.logsResponse": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "lines": { - "type": "array", - "items": { - "$ref": "#/definitions/logger.LogEntry" - } - }, - "next_cursor": { - "description": "用于加载更早日志的 cursor", - "type": "integer" - } - } - }, - "logs.topUserItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "nickname": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - } - } - }, - "model.AccessToken": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "masked_token": { - "type": "string" - }, - "name": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "integer" - } - } - }, - "model.AcmeAccount": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "model.AuthSource": { - "type": "object", - "properties": { - "client_id": { - "type": "string" - }, - "client_secret_configured": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "openid_discovery_url": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.ConfigVersion": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "main_config": { - "type": "string" - }, - "rendered_config": { - "type": "string" - }, - "snapshot_json": { - "type": "string" - }, - "support_files_json": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "model.ConfigVersionSummary": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "version": { - "type": "string" - } - } - }, - "model.DNSAccount": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.ExternalAccountView": { - "type": "object", - "properties": { - "auth_source_id": { - "type": "integer" - }, - "auth_source_label": { - "type": "string" - }, - "auth_source_name": { - "type": "string" - }, - "auth_source_type": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "external_username": { - "type": "string" - }, - "id": { - "type": "integer" - } - } - }, - "model.ManagedDomain": { - "type": "object", - "properties": { - "cert_id": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "domain": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "remark": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.OpenFlareApplyLog": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "main_config_checksum": { - "type": "string" - }, - "message": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "result": { - "type": "string" - }, - "route_config_checksum": { - "type": "string" - }, - "support_file_count": { - "type": "integer" - }, - "version": { - "type": "string" - } - } - }, - "model.OpenFlareHealthEvent": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "event_type": { - "type": "string" - }, - "first_triggered_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "last_triggered_at": { - "type": "string" - }, - "message": { - "type": "string" - }, - "metadata_json": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "reported_at": { - "type": "string" - }, - "resolved_at": { - "type": "string" - }, - "severity": { - "type": "string" - }, - "status": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.OpenFlareMetricSnapshot": { - "type": "object", - "properties": { - "captured_at": { - "type": "string" - }, - "cpu_usage_percent": { - "type": "number" - }, - "created_at": { - "type": "string" - }, - "disk_read_bytes": { - "type": "integer" - }, - "disk_write_bytes": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "memory_total_bytes": { - "type": "integer" - }, - "memory_used_bytes": { - "type": "integer" - }, - "network_rx_bytes": { - "type": "integer" - }, - "network_tx_bytes": { - "type": "integer" - }, - "node_id": { - "type": "string" - }, - "storage_total_bytes": { - "type": "integer" - }, - "storage_used_bytes": { - "type": "integer" - } - } - }, - "model.OpenFlareNodeSystemProfile": { - "type": "object", - "properties": { - "architecture": { - "type": "string" - }, - "cpu_cores": { - "type": "integer" - }, - "cpu_model": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "kernel_version": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "os_name": { - "type": "string" - }, - "os_version": { - "type": "string" - }, - "reported_at": { - "type": "string" - }, - "total_disk_bytes": { - "type": "integer" - }, - "total_memory_bytes": { - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "uptime_seconds": { - "type": "integer" - } - } - }, - "model.OpenFlareOption": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "model.OpenFlareRequestReport": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "error_count": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "node_id": { - "type": "string" - }, - "request_count": { - "type": "integer" - }, - "source_countries_json": { - "type": "string" - }, - "status_codes_json": { - "type": "string" - }, - "top_domains_json": { - "type": "string" - }, - "unique_visitor_count": { - "type": "integer" - }, - "window_ended_at": { - "type": "string" - }, - "window_started_at": { - "type": "string" - } - } - }, - "model.PushChannel": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "description": "备注", - "type": "string" - }, - "enabled": { - "description": "通道是否启用", - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "通道名称,仅英文字母和下划线,唯一", - "type": "string" - }, - "other": { - "description": "请求体/SMTP 密码等", - "type": "string" - }, - "token": { - "description": "鉴权令牌或发信用户名等", - "type": "string" - }, - "type": { - "description": "通道类型:custom, lark, email", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "url": { - "description": "请求地址,HTTPS 协议或 SMTP 地址", - "type": "string" - } - } - }, - "model.PushEvent": { - "type": "object", - "properties": { - "channels": { - "description": "推送渠道列表,如 [\"lark\"]", - "type": "array", - "items": { - "type": "string" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "description": "是否启用", - "type": "boolean" - }, - "event_key": { - "description": "如 admin_login", - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "如 管理员登录", - "type": "string" - }, - "targets": { - "description": "推送目标用户/邮箱列表", - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "description": "消息模板 JSON", - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.PushHistory": { - "type": "object", - "properties": { - "channel": { - "type": "string" - }, - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "error_msg": { - "type": "string" - }, - "event_key": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "level": { - "type": "string" - }, - "status": { - "description": "success / failed", - "type": "string" - }, - "target": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "model.Schedule": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "cron": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.SystemConfig": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer" - } - } - }, - "model.TLSCertificate": { - "type": "object", - "properties": { - "acme_account_id": { - "type": "integer" - }, - "apply_message": { - "type": "string" - }, - "apply_status": { - "type": "string" - }, - "auto_renew": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "disable_cname": { - "type": "boolean" - }, - "dns1": { - "type": "string" - }, - "dns2": { - "type": "string" - }, - "dns_account_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "key_algorithm": { - "type": "string" - }, - "name": { - "type": "string" - }, - "not_after": { - "type": "string" - }, - "not_before": { - "type": "string" - }, - "other_domains": { - "type": "string" - }, - "primary_domain": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "skip_dns": { - "type": "boolean" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.TaskExecution": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "error_message": { - "type": "string" - }, - "finished_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "log": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "payload": { - "type": "string" - }, - "result": { - "type": "string" - }, - "retry_count": { - "type": "integer" - }, - "retryable": { - "type": "boolean" - }, - "started_at": { - "type": "string" - }, - "status": { - "$ref": "#/definitions/model.TaskExecutionStatus" - }, - "task_id": { - "type": "string" - }, - "task_name": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "triggered_by": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.TaskExecutionStatus": { - "type": "string", - "enum": [ - "pending", - "running", - "succeeded", - "failed" - ], - "x-enum-varnames": [ - "TaskExecutionStatusPending", - "TaskExecutionStatusRunning", - "TaskExecutionStatusSucceeded", - "TaskExecutionStatusFailed" - ] - }, - "model.Template": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_system": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.Upload": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "extension": { - "description": "文件后缀名 (不含点,如 png, pdf)", - "type": "string" - }, - "file_name": { - "description": "原始文件名 (例如: image.png)", - "type": "string" - }, - "file_path": { - "description": "文件相对路径 / S3 Key", - "type": "string" - }, - "file_size": { - "description": "文件大小(字节)", - "type": "integer" - }, - "hash": { - "description": "文件哈希 (SHA-256/MD5,可用于排重)", - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "metadata": { - "description": "业务扩展元数据", - "allOf": [ - { - "$ref": "#/definitions/model.UploadMetadata" - } - ] - }, - "mime_type": { - "description": "媒体类型 (MIME, 如 image/png)", - "type": "string" - }, - "status": { - "description": "状态", - "allOf": [ - { - "$ref": "#/definitions/model.UploadStatus" - } - ] - }, - "type": { - "description": "业务标识类型 (如 avatar, doc, attachment)", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - } - } - }, - "model.UploadMetadata": { - "type": "object", - "properties": { - "bucket": { - "description": "存储桶名称 (适用于 S3 等)", - "type": "string" - }, - "client_ip": { - "description": "上传者 IP", - "type": "string" - }, - "duration": { - "description": "音视频时长 (s)", - "type": "number" - }, - "extra": { - "description": "其它任意业务自定义元数据", - "type": "object", - "additionalProperties": {} - }, - "height": { - "description": "图像/视频高度 (px)", - "type": "integer" - }, - "original_mime": { - "description": "原始 MIME 类型", - "type": "string" - }, - "user_agent": { - "description": "上传者的 UA", - "type": "string" - }, - "width": { - "description": "图像/视频宽度 (px)", - "type": "integer" - } - } - }, - "model.UploadStatus": { - "type": "string", - "enum": [ - "pending", - "used", - "deleted" - ], - "x-enum-comments": { - "UploadStatusDeleted": "已删除", - "UploadStatusPending": "待使用", - "UploadStatusUsed": "已使用" - }, - "x-enum-descriptions": [ - "待使用", - "已使用", - "已删除" - ], - "x-enum-varnames": [ - "UploadStatusPending", - "UploadStatusUsed", - "UploadStatusDeleted" - ] - }, - "node.AgentReleaseInfo": { - "type": "object", - "properties": { - "body": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "current_version": { - "type": "string" - }, - "has_update": { - "type": "boolean" - }, - "html_url": { - "type": "string" - }, - "prerelease": { - "type": "boolean" - }, - "published_at": { - "type": "string" - }, - "requested_channel": { - "type": "string" - }, - "requested_tag": { - "type": "string" - }, - "tag_name": { - "type": "string" - }, - "update_requested": { - "type": "boolean" - } - } - }, - "node.AgentUpdateInput": { - "type": "object", - "properties": { - "channel": { - "type": "string" - }, - "tag_name": { - "type": "string" - } - } - }, - "node.BootstrapView": { - "type": "object", - "properties": { - "discovery_token": { - "type": "string" - } - } - }, - "node.HealthEventCleanupResult": { - "type": "object", - "properties": { - "deleted_count": { - "type": "integer" - }, - "node_id": { - "type": "string" - } - } - }, - "node.Input": { - "type": "object", - "properties": { - "auto_update_enabled": { - "type": "boolean" - }, - "geo_latitude": { - "type": "number" - }, - "geo_longitude": { - "type": "number" - }, - "geo_manual_override": { - "type": "boolean" - }, - "geo_name": { - "type": "string" - }, - "ip": { - "type": "string" - }, - "ip_manual_override": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "node_type": { - "type": "string" - }, - "relay_agent_access_addr": { - "type": "string" - }, - "relay_bind_port": { - "type": "integer" - }, - "relay_client_access_addr": { - "type": "string" - }, - "relay_client_proxy_url": { - "type": "string" - }, - "relay_vhost_http_port": { - "type": "integer" - }, - "relay_web_server_enabled": { - "type": "boolean" - } - } - }, - "node.ObservabilityView": { - "type": "object", - "properties": { - "analytics": { - "$ref": "#/definitions/observability.NodeAnalytics" - }, - "health_events": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareHealthEvent" - } - }, - "metric_snapshots": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareMetricSnapshot" - } - }, - "node_id": { - "type": "string" - }, - "profile": { - "$ref": "#/definitions/model.OpenFlareNodeSystemProfile" - }, - "relay_dashboard": { - "$ref": "#/definitions/observability.RelayDashboardSnapshot" - }, - "traffic_reports": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareRequestReport" - } - }, - "trends": { - "$ref": "#/definitions/observability.NodeTrends" - } - } - }, - "node.View": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "auto_update_enabled": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "current_version": { - "type": "string" - }, - "ext_version": { - "type": "string" - }, - "geo_latitude": { - "type": "number" - }, - "geo_longitude": { - "type": "number" - }, - "geo_manual_override": { - "type": "boolean" - }, - "geo_name": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "ip": { - "type": "string" - }, - "ip_manual_override": { - "type": "boolean" - }, - "last_error": { - "type": "string" - }, - "last_seen_at": {}, - "latest_apply_at": { - "type": "string" - }, - "latest_apply_checksum": { - "type": "string" - }, - "latest_apply_message": { - "type": "string" - }, - "latest_apply_result": { - "type": "string" - }, - "latest_main_config_checksum": { - "type": "string" - }, - "latest_route_config_checksum": { - "type": "string" - }, - "latest_support_file_count": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "node_type": { - "type": "string" - }, - "openresty_message": { - "type": "string" - }, - "openresty_status": { - "type": "string" - }, - "relay_agent_access_addr": { - "type": "string" - }, - "relay_bind_port": { - "type": "integer" - }, - "relay_client_access_addr": { - "type": "string" - }, - "relay_client_proxy_url": { - "type": "string" - }, - "relay_status": { - "type": "string" - }, - "relay_vhost_http_port": { - "type": "integer" - }, - "relay_web_server_enabled": { - "type": "boolean" - }, - "restart_openresty_requested": { - "type": "boolean" - }, - "status": { - "type": "string" - }, - "update_channel": { - "type": "string" - }, - "update_requested": { - "type": "boolean" - }, - "update_tag": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "oauth.AuthSourceView": { - "type": "object", - "properties": { - "client_secret_configured": { - "type": "boolean" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "oauth.BasicUserInfo": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_admin": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "need_change_password": { - "type": "boolean" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "username": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "oauth.CallbackRequest": { - "type": "object", - "required": [ - "code", - "state" - ], - "properties": { - "code": { - "type": "string" - }, - "state": { - "type": "string" - } - } - }, - "oauth.OAuthAuthorizeResponse": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - } - } - }, - "oauth.OAuthCallbackResult": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - }, - "observability.AccessLogCleanupInput": { - "type": "object", - "properties": { - "retention_days": { - "type": "integer" - } - } - }, - "observability.AccessLogCleanupResult": { - "type": "object", - "properties": { - "cutoff": { - "type": "string" - }, - "deleted_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - } - } - }, - "observability.AccessLogIPSummaryList": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.AccessLogIPSummaryView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "sort_by": { - "type": "string" - }, - "sort_order": { - "type": "string" - }, - "total_ip": { - "type": "integer" - } - } - }, - "observability.AccessLogIPSummaryView": { - "type": "object", - "properties": { - "last_seen_at": { - "type": "string" - }, - "recent_requests": { - "type": "integer" - }, - "remote_addr": { - "type": "string" - }, - "total_requests": { - "type": "integer" - } - } - }, - "observability.AccessLogIPTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "request_count": { - "type": "integer" - } - } - }, - "observability.AccessLogIPTrendView": { - "type": "object", - "properties": { - "bucket_minutes": { - "type": "integer" - }, - "hours": { - "type": "integer" - }, - "points": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.AccessLogIPTrendPoint" - } - }, - "remote_addr": { - "type": "string" - } - } - }, - "observability.AccessLogList": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.AccessLogView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_ip": { - "type": "integer" - }, - "total_record": { - "type": "integer" - } - } - }, - "observability.AccessLogView": { - "type": "object", - "properties": { - "host": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "logged_at": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "node_name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "region": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "status_code": { - "type": "integer" - } - } - }, - "observability.CapacityTrendPoint": { - "type": "object", - "properties": { - "average_cpu_usage_percent": { - "type": "number" - }, - "average_memory_usage_percent": { - "type": "number" - }, - "bucket_started_at": { - "type": "string" - }, - "reported_nodes": { - "type": "integer" - } - } - }, - "observability.DiskIOTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "disk_read_bytes": { - "type": "integer" - }, - "disk_write_bytes": { - "type": "integer" - }, - "reported_nodes": { - "type": "integer" - } - } - }, - "observability.DistributionItem": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogIPList": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "fold_minutes": { - "type": "integer" - }, - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.FoldedAccessLogIPView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "sort_by": { - "type": "string" - }, - "sort_order": { - "type": "string" - }, - "total_ip": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogIPView": { - "type": "object", - "properties": { - "client_error_count": { - "type": "integer" - }, - "last_seen_at": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "request_count": { - "type": "integer" - }, - "server_error_count": { - "type": "integer" - }, - "success_count": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogList": { - "type": "object", - "properties": { - "fold_minutes": { - "type": "integer" - }, - "has_more": { - "type": "boolean" - }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.FoldedAccessLogView" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total_bucket": { - "type": "integer" - }, - "total_ip": { - "type": "integer" - }, - "total_record": { - "type": "integer" - } - } - }, - "observability.FoldedAccessLogView": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "client_error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "server_error_count": { - "type": "integer" - }, - "success_count": { - "type": "integer" - }, - "unique_host_count": { - "type": "integer" - }, - "unique_ip_count": { - "type": "integer" - } - } - }, - "observability.HealthSummary": { - "type": "object", - "properties": { - "active_alerts": { - "type": "integer" - }, - "critical_alerts": { - "type": "integer" - }, - "has_capacity_risk": { - "type": "boolean" - }, - "has_runtime_risk": { - "type": "boolean" - }, - "has_traffic_risk": { - "type": "boolean" - }, - "info_alerts": { - "type": "integer" - }, - "resolved_alerts": { - "type": "integer" - }, - "warning_alerts": { - "type": "integer" - } - } - }, - "observability.NetworkTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "network_rx_bytes": { - "type": "integer" - }, - "network_tx_bytes": { - "type": "integer" - }, - "openresty_rx_bytes": { - "type": "integer" - }, - "openresty_tx_bytes": { - "type": "integer" - }, - "reported_nodes": { - "type": "integer" - } - } - }, - "observability.NodeAnalytics": { - "type": "object", - "properties": { - "distributions": { - "$ref": "#/definitions/observability.TrafficDistributions" - }, - "health": { - "$ref": "#/definitions/observability.HealthSummary" - }, - "traffic": { - "$ref": "#/definitions/observability.TrafficWindowSummary" - } - } - }, - "observability.NodeTrends": { - "type": "object", - "properties": { - "capacity_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.CapacityTrendPoint" - } - }, - "disk_io_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DiskIOTrendPoint" - } - }, - "network_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.NetworkTrendPoint" - } - }, - "traffic_24h": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.TrafficTrendPoint" - } - } - } - }, - "observability.RelayDashboardSnapshot": { - "type": "object", - "properties": { - "client_counts": { - "type": "integer" - }, - "offline_proxies": { - "type": "integer" - }, - "online_proxies": { - "type": "integer" - }, - "proxies": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.RelayProxyStat" - } - }, - "total_connections": { - "type": "integer" - }, - "total_proxies": { - "type": "integer" - } - } - }, - "observability.RelayProxyStat": { - "type": "object", - "properties": { - "client_addr": { - "type": "string" - }, - "client_version": { - "type": "string" - }, - "last_close_time": { - "type": "string" - }, - "last_start_time": { - "type": "string" - }, - "name": { - "type": "string" - }, - "status": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "observability.TrafficDistributions": { - "type": "object", - "properties": { - "source_countries": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DistributionItem" - } - }, - "status_codes": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DistributionItem" - } - }, - "top_domains": { - "type": "array", - "items": { - "$ref": "#/definitions/observability.DistributionItem" - } - } - } - }, - "observability.TrafficTrendPoint": { - "type": "object", - "properties": { - "bucket_started_at": { - "type": "string" - }, - "error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "unique_visitor_count": { - "type": "integer" - } - } - }, - "observability.TrafficWindowSummary": { - "type": "object", - "properties": { - "error_count": { - "type": "integer" - }, - "error_rate_percent": { - "type": "number" - }, - "estimated_qps": { - "type": "number" - }, - "request_count": { - "type": "integer" - }, - "unique_visitor_count": { - "type": "integer" - }, - "window_ended_at": { - "type": "string" - }, - "window_started_at": { - "type": "string" - } - } - }, - "option.databaseCleanupInput": { - "type": "object", - "properties": { - "retention_days": { - "type": "integer" - }, - "target": { - "type": "string" - } - } - }, - "option.databaseCleanupResult": { - "type": "object", - "properties": { - "delete_all": { - "type": "boolean" - }, - "deleted_count": { - "type": "integer" - }, - "retention_days": { - "type": "integer" - }, - "target": { - "type": "string" - }, - "target_label": { - "type": "string" - } - } - }, - "option.geoIPLookupRequest": { - "type": "object", - "properties": { - "ip": { - "type": "string" - }, - "provider": { - "type": "string" - } - } - }, - "option.geoIPLookupView": { - "type": "object", - "properties": { - "ip": { - "type": "string" - }, - "iso_code": { - "type": "string" - }, - "latitude": { - "type": "number" - }, - "longitude": { - "type": "number" - }, - "name": { - "type": "string" - }, - "provider": { - "type": "string" - } - } - }, - "option.optionBatchPayload": { - "type": "object", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/definitions/model.OpenFlareOption" - } - } - } - }, - "option.publicAuthSourceView": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "option.statusView": { - "type": "object", - "properties": { - "auth_sources": { - "type": "array", - "items": { - "$ref": "#/definitions/option.publicAuthSourceView" - } - }, - "cap_login_enabled": { - "type": "boolean" - }, - "email_verification": { - "type": "boolean" - }, - "footer_html": { - "type": "string" - }, - "github_client_id": { - "type": "string" - }, - "github_oauth": { - "type": "boolean" - }, - "home_page_link": { - "type": "string" - }, - "password_register_enabled": { - "type": "boolean" - }, - "server_address": { - "type": "string" - }, - "start_time": { - "type": "integer" - }, - "system_name": { - "type": "string" - }, - "version": { - "type": "string" - }, - "wechat_login": { - "type": "boolean" - }, - "wechat_qrcode": { - "type": "string" - } - } - }, - "origin.DetailView": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "route_count": { - "type": "integer" - }, - "routes": { - "type": "array", - "items": { - "$ref": "#/definitions/origin.RouteSummary" - } - }, - "updated_at": { - "type": "string" - } - } - }, - "origin.Input": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - } - } - }, - "origin.RouteSummary": { - "type": "object", - "properties": { - "domain": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "origin_url": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "origin.View": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "route_count": { - "type": "integer" - }, - "updated_at": { - "type": "string" - } - } - }, - "pages.DeploymentFileView": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "deployment_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "path": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "pages.DeploymentView": { - "type": "object", - "properties": { - "activated_at": { - "type": "string" - }, - "checksum": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "deployment_number": { - "type": "integer" - }, - "file_count": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "total_size": { - "type": "integer" - } - } - }, - "pages.Input": { - "type": "object", - "properties": { - "api_proxy_enabled": { - "type": "boolean" - }, - "api_proxy_pass": { - "type": "string" - }, - "api_proxy_path": { - "type": "string" - }, - "api_proxy_rewrite": { - "type": "string" - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "entry_file": { - "type": "string" - }, - "name": { - "type": "string" - }, - "root_dir": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "spa_fallback_enabled": { - "type": "boolean" - }, - "spa_fallback_path": { - "type": "string" - } - } - }, - "pages.View": { - "type": "object", - "properties": { - "active_deployment": { - "$ref": "#/definitions/pages.DeploymentView" - }, - "active_deployment_id": { - "type": "integer" - }, - "api_proxy_enabled": { - "type": "boolean" - }, - "api_proxy_pass": { - "type": "string" - }, - "api_proxy_path": { - "type": "string" - }, - "api_proxy_rewrite": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "deployment_count": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "entry_file": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "root_dir": { - "type": "string" - }, - "slug": { - "type": "string" - }, - "spa_fallback_enabled": { - "type": "boolean" - }, - "spa_fallback_path": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "proxy_route.CustomHeaderInput": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "proxy_route.Input": { - "type": "object", - "properties": { - "basic_auth_enabled": { - "type": "boolean" - }, - "basic_auth_password": { - "type": "string" - }, - "basic_auth_username": { - "type": "string" - }, - "cache_enabled": { - "type": "boolean" - }, - "cache_policy": { - "type": "string" - }, - "cache_rules": { - "type": "array", - "items": { - "type": "string" - } - }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "custom_headers": { - "type": "array", - "items": { - "$ref": "#/definitions/proxy_route.CustomHeaderInput" - } - }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "enable_https": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "limit_conn_per_ip": { - "type": "integer" - }, - "limit_conn_per_server": { - "type": "integer" - }, - "limit_rate": { - "type": "string" - }, - "origin_address": { - "type": "string" - }, - "origin_host": { - "type": "string" - }, - "origin_id": { - "type": "integer" - }, - "origin_port": { - "type": "string" - }, - "origin_scheme": { - "type": "string" - }, - "origin_uri": { - "type": "string" - }, - "origin_url": { - "type": "string" - }, - "pages_project_id": { - "type": "integer" - }, - "redirect_http": { - "type": "boolean" - }, - "remark": { - "type": "string" - }, - "site_name": { - "type": "string" - }, - "tunnel_id": { - "type": "integer" - }, - "tunnel_node_id": { - "type": "integer" - }, - "tunnel_target_addr": { - "type": "string" - }, - "tunnel_target_protocol": { - "type": "string" - }, - "upstream_type": { - "type": "string" - }, - "upstreams": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "proxy_route.View": { - "type": "object", - "properties": { - "basic_auth_enabled": { - "type": "boolean" - }, - "basic_auth_password": { - "type": "string" - }, - "basic_auth_username": { - "type": "string" - }, - "cache_enabled": { - "type": "boolean" - }, - "cache_policy": { - "type": "string" - }, - "cache_rule_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "cache_rules": { - "type": "string" - }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "created_at": { - "type": "string" - }, - "custom_header_list": { - "type": "array", - "items": { - "$ref": "#/definitions/proxy_route.CustomHeaderInput" - } - }, - "custom_headers": { - "type": "string" - }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domain_count": { - "type": "integer" - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, - "enable_https": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "limit_conn_per_ip": { - "type": "integer" - }, - "limit_conn_per_server": { - "type": "integer" - }, - "limit_rate": { - "type": "string" - }, - "origin_host": { - "type": "string" - }, - "origin_id": { - "type": "integer" - }, - "origin_url": { - "type": "string" - }, - "pages_project_id": { - "type": "integer" - }, - "primary_domain": { - "type": "string" - }, - "redirect_http": { - "type": "boolean" - }, - "remark": { - "type": "string" - }, - "site_name": { - "type": "string" - }, - "tunnel_id": { - "type": "integer" - }, - "tunnel_node_id": { - "type": "integer" - }, - "tunnel_target_addr": { - "type": "string" - }, - "tunnel_target_protocol": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "upstream_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "upstream_type": { - "type": "string" - }, - "upstreams": { - "type": "string" - } - } - }, - "push.Config": { - "type": "object", - "properties": { - "channel": { - "description": "渠道名称,例如 \"lark\", \"custom\", \"email\" 等,唯一标识", - "type": "string" - }, - "ext": { - "description": "预留拓展 JSON 配置", - "type": "object", - "additionalProperties": {} - }, - "key": { - "description": "AppID 或 SMTP 用户名", - "type": "string" - }, - "secret": { - "description": "签名密钥或 SMTP 密码/Token", - "type": "string" - }, - "url": { - "description": "Webhook 地址或 SMTP 地址", - "type": "string" - } - } - }, - "push.CreateChannelRequest": { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "other": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.CreateEventRequest": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "event_key": { - "type": "string" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "type": "string" - } - } - }, - "push.Definition": { - "type": "object", - "properties": { - "description": { - "description": "short description", - "type": "string" - }, - "fields": { - "description": "form fields", - "type": "array", - "items": { - "$ref": "#/definitions/push.Field" - } - }, - "name": { - "description": "display name", - "type": "string" - }, - "type": { - "description": "channel type (e.g., custom, lark, email)", - "type": "string" - } - } - }, - "push.EventMetadata": { - "type": "object", - "properties": { - "default_template": { - "$ref": "#/definitions/push.NotificationMessage" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "push.Field": { - "type": "object", - "properties": { - "description": { - "description": "field explanation/help text", - "type": "string" - }, - "key": { - "description": "unique key for the field (e.g. url, token, other)", - "type": "string" - }, - "label": { - "description": "human readable label (e.g. \"Webhook 地址\")", - "type": "string" - }, - "placeholder": { - "description": "input placeholder", - "type": "string" - }, - "required": { - "description": "whether this field is required", - "type": "boolean" - }, - "type": { - "description": "input type: \"text\" | \"password\" | \"textarea\"", - "type": "string" - } - } - }, - "push.NotificationMessage": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "ext": { - "type": "object", - "additionalProperties": {} - }, - "level": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "push.TestChannelRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "other": { - "type": "string" - }, - "target": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.TestPushRequest": { - "type": "object", - "required": [ - "config" - ], - "properties": { - "config": { - "$ref": "#/definitions/push.Config" - }, - "target": { - "type": "string" - } - } - }, - "push.UpdateChannelRequest": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "other": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.UpdateEventRequest": { - "type": "object", - "required": [ - "template" - ], - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "string" - } - } - }, - "push.pushHistoriesResponse": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushHistory" - } - }, - "total": { - "type": "integer" - } - } - }, - "response.Any": { - "type": "object", - "properties": { - "data": {}, - "error_msg": { - "type": "string", - "example": "" - } - } - }, - "status.DatabaseInfoResponse": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "status.SystemStatusResponse": { - "type": "object", - "properties": { - "alloc": { - "type": "string" - }, - "buck_hash_sys": { - "type": "string" - }, - "frees": { - "type": "integer" - }, - "gc_sys": { - "type": "string" - }, - "heap_alloc": { - "type": "string" - }, - "heap_idle": { - "type": "string" - }, - "heap_inuse": { - "type": "string" - }, - "heap_objects": { - "type": "integer" - }, - "heap_released": { - "type": "string" - }, - "heap_sys": { - "type": "string" - }, - "last_gc_time": { - "type": "string" - }, - "last_pause": { - "type": "string" - }, - "lookups": { - "type": "integer" - }, - "mallocs": { - "type": "integer" - }, - "mcache_inuse": { - "type": "string" - }, - "mcache_sys": { - "type": "string" - }, - "mspan_inuse": { - "type": "string" - }, - "mspan_sys": { - "type": "string" - }, - "next_gc": { - "type": "string" - }, - "num_gc": { - "type": "integer" - }, - "num_goroutine": { - "type": "integer" - }, - "other_sys": { - "type": "string" - }, - "pause_total_ns": { - "type": "string" - }, - "stack_inuse": { - "type": "string" - }, - "stack_sys": { - "type": "string" - }, - "sys": { - "type": "string" - }, - "total_alloc": { - "type": "string" - }, - "uptime": { - "type": "string" - } - } - }, - "system_config.CreateSystemConfigRequest": { - "type": "object", - "required": [ - "key", - "type", - "value" - ], - "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 64 - }, - "type": { - "type": "string", - "enum": [ - "system", - "business" - ] - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - } - }, - "system_config.TestSMTPRequest": { - "type": "object", - "required": [ - "smtp_host", - "smtp_password", - "smtp_port", - "smtp_username", - "to" - ], - "properties": { - "smtp_host": { - "type": "string", - "maxLength": 255 - }, - "smtp_password": { - "type": "string", - "maxLength": 255 - }, - "smtp_port": { - "type": "integer" - }, - "smtp_username": { - "type": "string", - "maxLength": 255 - }, - "to": { - "type": "string" - } - } - }, - "system_config.TestSMTPResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "log": { - "type": "string" - }, - "success": { - "type": "boolean" - } - } - }, - "system_config.UpdateSystemConfigRequest": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - } - }, - "task.CreateScheduleRequest": { - "type": "object", - "required": [ - "cron", - "is_active", - "name", - "task_type" - ], - "properties": { - "cron": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - } - } - }, - "task.DispatchTaskRequest": { - "type": "object", - "required": [ - "task_type" - ], - "properties": { - "end_time": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "start_time": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "user_id": { - "type": "integer" - } - } - }, - "task.TaskMeta": { - "type": "object", - "properties": { - "asynq_task": { - "type": "string" - }, - "description": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "params": { - "type": "array", - "items": { - "$ref": "#/definitions/task.TaskParam" - } - }, - "queue": { - "type": "string" - }, - "retryable": { - "description": "是否支持手动重试", - "type": "boolean" - }, - "supports_time": { - "type": "boolean" - }, - "type": { - "type": "string" - } - } - }, - "task.TaskParam": { - "type": "object", - "properties": { - "description": { - "description": "描述", - "type": "string" - }, - "label": { - "description": "显示名称", - "type": "string" - }, - "name": { - "description": "参数键名", - "type": "string" - }, - "placeholder": { - "description": "占位符", - "type": "string" - }, - "required": { - "description": "是否必填", - "type": "boolean" - }, - "type": { - "description": "类型:string, text, number, boolean", - "type": "string" - } - } - }, - "task.UpdateScheduleRequest": { - "type": "object", - "required": [ - "cron", - "is_active", - "name", - "task_type" - ], - "properties": { - "cron": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - } - } - }, - "template.CreateTemplateRequest": { - "type": "object", - "required": [ - "content", - "key", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 80 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "template.UpdateTemplateRequest": { - "type": "object", - "required": [ - "content", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "tls.ApplyInput": { - "type": "object", - "properties": { - "acme_account_id": { - "type": "integer" - }, - "auto_renew": { - "type": "boolean" - }, - "disable_cname": { - "type": "boolean" - }, - "dns1": { - "type": "string" - }, - "dns2": { - "type": "string" - }, - "dns_account_id": { - "type": "integer" - }, - "key_algorithm": { - "type": "string" - }, - "name": { - "type": "string" - }, - "other_domains": { - "type": "string" - }, - "primary_domain": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "skip_dns": { - "type": "boolean" - } - } - }, - "tls.CertificateContent": { - "type": "object", - "properties": { - "acme_account_id": { - "type": "integer" - }, - "apply_message": { - "type": "string" - }, - "apply_status": { - "type": "string" - }, - "auto_renew": { - "type": "boolean" - }, - "cert_pem": { - "type": "string" - }, - "disable_cname": { - "type": "boolean" - }, - "dns1": { - "type": "string" - }, - "dns2": { - "type": "string" - }, - "dns_account_id": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "key_algorithm": { - "type": "string" - }, - "key_pem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "other_domains": { - "type": "string" - }, - "primary_domain": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "skip_dns": { - "type": "boolean" - } - } - }, - "tls.CertificateInput": { - "type": "object", - "properties": { - "cert_pem": { - "type": "string" - }, - "key_pem": { - "type": "string" - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - } - } - }, - "tls.DNSAccountInput": { - "type": "object", - "properties": { - "authorization": { - "type": "string" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "tls.ManagedDomainInput": { - "type": "object", - "properties": { - "cert_id": { - "type": "integer" - }, - "domain": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "remark": { - "type": "string" - } - } - }, - "tls.ManagedDomainMatchCandidate": { - "type": "object", - "properties": { - "certificate_id": { - "type": "integer" - }, - "certificate_name": { - "type": "string" - }, - "domain": { - "type": "string" - }, - "managed_domain_id": { - "type": "integer" - }, - "match_type": { - "type": "string" - } - } - }, - "tls.ManagedDomainMatchResult": { - "type": "object", - "properties": { - "candidate": { - "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" - }, - "candidates": { - "type": "array", - "items": { - "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" - } - }, - "domain": { - "type": "string" - }, - "matched": { - "type": "boolean" - } - } - }, - "updater.Status": { - "type": "object", - "properties": { - "asset_name": { - "type": "string" - }, - "build_time": { - "type": "string" - }, - "can_upgrade": { - "type": "boolean" - }, - "current_version": { - "type": "string" - }, - "latest_version": { - "type": "string" - }, - "platform": { - "type": "string" - }, - "prerelease": { - "type": "boolean" - }, - "published_at": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "release_notes": { - "type": "string" - }, - "release_url": { - "type": "string" - }, - "update_available": { - "type": "boolean" - }, - "upstream_repository": { - "type": "string" - } - } - }, - "user.changePasswordRequest": { - "type": "object", - "properties": { - "new_password": { - "type": "string" - }, - "old_password": { - "type": "string" - } - } - }, - "user.createTokenRequest": { - "type": "object", - "properties": { - "is_admin": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "user.createUserRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string", - "maxLength": 255 - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "nickname": { - "type": "string", - "maxLength": 64 - }, - "password": { - "type": "string", - "maxLength": 64, - "minLength": 8 - }, - "username": { - "type": "string", - "maxLength": 64, - "minLength": 3 - } - } - }, - "user.listUsersResponse": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "users": { - "type": "array", - "items": { - "$ref": "#/definitions/user.user" - } - } - } - }, - "user.loginRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.registerRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "email": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.sendEmailCodeRequest": { - "type": "object", - "required": [ - "email", - "scene" - ], - "properties": { - "email": { - "type": "string" - }, - "scene": { - "type": "string" - } - } - }, - "user.tokenResponse": { - "type": "object", - "properties": { - "record": { - "$ref": "#/definitions/model.AccessToken" - }, - "token": { - "type": "string" - } - } - }, - "user.updateProfileRequest": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "user.updateUserStatusRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "user.user": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "last_login_at": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "waf.IDsRequest": { - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "waf.IPGroupAutoTestInput": { - "type": "object", - "properties": { - "auto_config": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - "waf.IPGroupAutoTestResult": { - "type": "object", - "properties": { - "lookback_minutes": { - "type": "integer" - }, - "matched_count": { - "type": "integer" - }, - "matched_ips": { - "type": "array", - "items": { - "type": "string" - } - }, - "rule_count": { - "type": "integer" - }, - "tested_at": { - "type": "string" - } - } - }, - "waf.IPGroupExtIPView": { - "type": "object", - "properties": { - "captured_at": { - "type": "string" - }, - "ip": { - "type": "string" - } - } - }, - "waf.IPGroupInput": { - "type": "object", - "properties": { - "auto_config": { - "type": "array", - "items": { - "type": "integer" - } - }, - "enabled": { - "type": "boolean" - }, - "ip_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "remark": { - "type": "string" - }, - "subscription_format": { - "type": "string" - }, - "subscription_mapping_rule": { - "type": "string" - }, - "subscription_url": { - "type": "string" - }, - "sync_interval_minutes": { - "type": "integer" - }, - "type": { - "type": "string" - } - } - }, - "waf.IPGroupSyncResult": { - "type": "object", - "properties": { - "group": { - "$ref": "#/definitions/waf.IPGroupView" - }, - "ip_count": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "next_sync_at": { - "type": "string" - }, - "status": { - "type": "string" - }, - "synced_at": { - "type": "string" - } - } - }, - "waf.IPGroupView": { - "type": "object", - "properties": { - "auto_config": { - "type": "array", - "items": { - "type": "integer" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "ext_ips": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.IPGroupExtIPView" - } - }, - "id": { - "type": "integer" - }, - "ip_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "last_sync_message": { - "type": "string" - }, - "last_sync_status": { - "type": "string" - }, - "last_synced_at": { - "type": "string" - }, - "name": { - "type": "string" - }, - "next_sync_at": { - "type": "string" - }, - "referenced_by_rule_count": { - "type": "integer" - }, - "remark": { - "type": "string" - }, - "subscription_format": { - "type": "string" - }, - "subscription_mapping_rule": { - "type": "string" - }, - "subscription_url": { - "type": "string" - }, - "sync_interval_minutes": { - "type": "integer" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "waf.PoWConfig": { - "type": "object", - "properties": { - "algorithm": { - "type": "string" - }, - "blacklist": { - "$ref": "#/definitions/waf.PoWListConfig" - }, - "challenge_ttl": { - "type": "integer" - }, - "difficulty": { - "type": "integer" - }, - "session_ttl": { - "type": "integer" - }, - "whitelist": { - "$ref": "#/definitions/waf.PoWListConfig" - } - } - }, - "waf.PoWListConfig": { - "type": "object", - "properties": { - "ip_cidrs": { - "type": "array", - "items": { - "type": "string" - } - }, - "ips": { - "type": "array", - "items": { - "type": "string" - } - }, - "path_regexes": { - "type": "array", - "items": { - "type": "string" - } - }, - "paths": { - "type": "array", - "items": { - "type": "string" - } - }, - "user_agents": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "waf.RuleGroupInput": { - "type": "object", - "properties": { - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "name": { - "type": "string" - }, - "pow_config": { - "type": "array", - "items": { - "type": "integer" - } - }, - "pow_enabled": { - "type": "boolean" - }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "remark": { - "type": "string" - } - } - }, - "waf.RuleGroupView": { - "type": "object", - "properties": { - "applied_site_count": { - "type": "integer" - }, - "applied_site_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "block_response_body": { - "type": "string" - }, - "block_status_code": { - "type": "integer" - }, - "country_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "country_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "ip_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_blacklist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "ip_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "ip_whitelist_group_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "is_global": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "pow_config": { - "$ref": "#/definitions/waf.PoWConfig" - }, - "pow_enabled": { - "type": "boolean" - }, - "region_blacklist": { - "type": "array", - "items": { - "type": "string" - } - }, - "region_whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "remark": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "waf.SiteRuleGroupsView": { - "type": "object", - "properties": { - "applied_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "applied_rule_groups": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.RuleGroupView" - } - }, - "global_rule_group": { - "$ref": "#/definitions/waf.RuleGroupView" - }, - "route_id": { - "type": "integer" - }, - "rule_groups": { - "type": "array", - "items": { - "$ref": "#/definitions/waf.RuleGroupView" - } - } - } - } - }, - "securityDefinitions": { - "SessionCookie": { - "type": "apiKey", - "name": "session", - "in": "cookie" - } - } -} \ No newline at end of file diff --git a/Wavelet/docs/swagger.yaml b/Wavelet/docs/swagger.yaml deleted file mode 100644 index a29730ad..00000000 --- a/Wavelet/docs/swagger.yaml +++ /dev/null @@ -1,10628 +0,0 @@ -basePath: / -definitions: - apply_log.CleanupInput: - properties: - delete_all: - type: boolean - retention_days: - type: integer - type: object - apply_log.CleanupResult: - properties: - cutoff: - type: string - delete_all: - type: boolean - deleted_count: - type: integer - retention_days: - type: integer - type: object - apply_log.ListResult: - properties: - current: - type: integer - rows: - items: - $ref: '#/definitions/model.OpenFlareApplyLog' - type: array - total: - type: integer - totalPage: - type: integer - type: object - auth_source.AuthSourceRequest: - properties: - client_id: - type: string - client_secret: - type: string - display_name: - type: string - icon_url: - type: string - is_active: - type: boolean - name: - type: string - openid_discovery_url: - type: string - scopes: - type: string - type: - type: string - type: object - auth_source.ToggleAuthSourceRequest: - properties: - is_active: - type: boolean - type: object - cache.updateCacheConfigRequest: - properties: - lru_enabled: - type: boolean - max_size_mb: - minimum: 1 - type: integer - ttl_minutes: - minimum: 0 - type: integer - required: - - max_size_mb - - ttl_minutes - type: object - cap.ChallengeResponse: - properties: - challenge: - properties: - c: - type: integer - d: - type: integer - s: - type: integer - type: object - expires: - description: ms timestamp - type: integer - token: - type: string - type: object - cap.challengeRequest: - properties: - scope: - type: string - type: object - cap.redeemRequest: - properties: - scope: - type: string - solutions: - items: - type: integer - type: array - token: - type: string - required: - - solutions - - token - type: object - config_version.CleanupInput: - properties: - keep_count: - type: integer - type: object - config_version.CleanupResult: - properties: - deleted_count: - type: integer - message: - type: string - type: object - config_version.ConfigDiffResult: - properties: - active_version: - type: string - active_website_count: - type: integer - added_domains: - items: - type: string - type: array - added_sites: - items: - type: string - type: array - changed_option_details: - items: - $ref: '#/definitions/config_version.ConfigOptionDiffItem' - type: array - changed_option_keys: - items: - type: string - type: array - current_website_count: - type: integer - main_config_changed: - type: boolean - modified_domains: - items: - type: string - type: array - modified_sites: - items: - type: string - type: array - removed_domains: - items: - type: string - type: array - removed_sites: - items: - type: string - type: array - waf_config_changed: - type: boolean - type: object - config_version.ConfigOptionDiffItem: - properties: - current_value: - type: string - key: - type: string - previous_value: - type: string - type: object - config_version.ConfigPreviewResult: - properties: - checksum: - type: string - main_config: - type: string - rendered_config: - type: string - route_config: - type: string - route_count: - type: integer - snapshot_json: - type: string - support_files: - items: - $ref: '#/definitions/config_version.SupportFile' - type: array - website_count: - type: integer - type: object - config_version.SupportFile: - properties: - content: - type: string - path: - type: string - type: object - dashboard.Capacity: - properties: - average_cpu_usage_percent: - type: number - average_memory_usage_percent: - type: number - high_cpu_nodes: - type: integer - high_memory_nodes: - type: integer - high_storage_nodes: - type: integer - type: object - dashboard.OverviewPayload: - properties: - capacity: - $ref: '#/definitions/dashboard.Capacity' - distributions: - $ref: '#/definitions/dashboard.distributionsPayload' - generated_at: {} - nodes: - items: - items: {} - type: array - type: array - summary: - $ref: '#/definitions/dashboard.Summary' - traffic: - $ref: '#/definitions/dashboard.Traffic' - trends: - $ref: '#/definitions/dashboard.trendsPayload' - type: object - dashboard.Summary: - properties: - offline_nodes: - type: integer - online_nodes: - type: integer - pending_nodes: - type: integer - total_nodes: - type: integer - unhealthy_nodes: - type: integer - type: object - dashboard.Traffic: - properties: - error_count: - type: integer - estimated_qps: - type: number - reported_nodes: - type: integer - request_count: - type: integer - unique_visitors: - type: integer - type: object - dashboard.distributionsPayload: - properties: - source_countries: - items: - items: {} - type: array - type: array - status_codes: - items: - items: {} - type: array - type: array - top_domains: - items: - items: {} - type: array - type: array - type: object - dashboard.trendsPayload: - properties: - capacity_24h: - items: - items: {} - type: array - type: array - disk_io_24h: - items: - items: {} - type: array - type: array - network_24h: - items: - items: {} - type: array - type: array - traffic_24h: - items: - items: {} - type: array - type: array - type: object - db_manage.DBOverviewResponse: - properties: - connections: - type: integer - name: - type: string - size: - type: string - table_count: - type: integer - type: - type: string - version: - type: string - type: object - db_manage.ExecuteSQLRequest: - properties: - sql: - type: string - required: - - sql - type: object - db_manage.ExecuteSQLResponse: - properties: - affected_rows: - type: integer - columns: - items: - type: string - type: array - execution_time_ms: - type: integer - results: - items: - additionalProperties: true - type: object - type: array - type: - description: '"select" 或 "exec"' - type: string - type: object - diskcache.Status: - properties: - base_path: - type: string - keys_count: - type: integer - lru_enabled: - type: boolean - max_size_mb: - type: integer - total_size: - type: integer - ttl_minutes: - type: integer - type: object - github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse: - properties: - error: - type: string - expires: - type: integer - success: - type: boolean - token: - type: string - type: object - handler.batchDownloadRequest: - properties: - ids: - items: - type: string - minItems: 1 - type: array - required: - - ids - type: object - handler.distributionItem: - properties: - count: - type: integer - name: - type: string - size: - type: integer - type: object - handler.fileStatsResponse: - properties: - categories: - items: - $ref: '#/definitions/handler.distributionItem' - type: array - total_count: - type: integer - total_size: - type: integer - trend: - items: - $ref: '#/definitions/handler.trendItem' - type: array - types: - items: - $ref: '#/definitions/handler.distributionItem' - type: array - type: object - handler.listFilesResponse: - properties: - items: - items: - $ref: '#/definitions/model.Upload' - type: array - page: - type: integer - page_size: - type: integer - total: - type: integer - type: object - handler.listMyFilesResponse: - properties: - items: - items: - $ref: '#/definitions/model.Upload' - type: array - page: - type: integer - page_size: - type: integer - total: - type: integer - type: object - handler.trendItem: - properties: - count: - type: integer - date: - type: string - size: - type: integer - type: object - handler.updateMyFileRequest: - properties: - access_mode: - enum: - - 0 - - 1 - type: integer - file_name: - maxLength: 255 - type: string - type: object - logger.LogEntry: - properties: - data: - description: 一行日志原文(含换行符) - type: string - index: - description: 全局递增序号 - type: integer - type: object - logs.accessLogItem: - properties: - created_at: - type: string - headers: - type: string - id: - example: "0" - type: string - ip: - type: string - latency: - type: integer - method: - type: string - nickname: - type: string - path: - type: string - status: - type: integer - user_agent: - type: string - user_id: - example: "0" - type: string - username: - type: string - type: object - logs.accessLogsResponse: - properties: - list: - items: - $ref: '#/definitions/logs.accessLogItem' - type: array - total: - type: integer - type: object - logs.browserItem: - properties: - browser: - type: string - count: - type: integer - type: object - logs.logsAnalyticsResponse: - properties: - browsers: - items: - $ref: '#/definitions/logs.browserItem' - type: array - top_users: - items: - $ref: '#/definitions/logs.topUserItem' - type: array - trend: - items: - $ref: '#/definitions/logs.trendItem' - type: array - type: object - logs.logsResponse: - properties: - has_more: - type: boolean - lines: - items: - $ref: '#/definitions/logger.LogEntry' - type: array - next_cursor: - description: 用于加载更早日志的 cursor - type: integer - type: object - logs.topUserItem: - properties: - count: - type: integer - nickname: - type: string - user_id: - example: "0" - type: string - username: - type: string - type: object - logs.trendItem: - properties: - count: - type: integer - date: - type: string - type: object - model.AccessToken: - properties: - created_at: - type: string - id: - type: integer - is_admin: - type: boolean - masked_token: - type: string - name: - type: string - updated_at: - type: string - user_id: - type: integer - type: object - model.AcmeAccount: - properties: - created_at: - type: string - email: - type: string - id: - type: integer - updated_at: - type: string - url: - type: string - type: object - model.AuthSource: - properties: - client_id: - type: string - client_secret_configured: - type: boolean - created_at: - type: string - display_name: - type: string - icon_url: - type: string - id: - type: integer - is_active: - type: boolean - name: - type: string - openid_discovery_url: - type: string - scopes: - type: string - type: - type: string - updated_at: - type: string - type: object - model.ConfigVersion: - properties: - checksum: - type: string - created_at: - type: string - created_by: - type: string - id: - type: integer - is_active: - type: boolean - main_config: - type: string - rendered_config: - type: string - snapshot_json: - type: string - support_files_json: - type: string - version: - type: string - type: object - model.ConfigVersionSummary: - properties: - checksum: - type: string - created_at: - type: string - created_by: - type: string - id: - type: integer - is_active: - type: boolean - version: - type: string - type: object - model.DNSAccount: - properties: - created_at: - type: string - id: - type: integer - name: - type: string - type: - type: string - updated_at: - type: string - type: object - model.ExternalAccountView: - properties: - auth_source_id: - type: integer - auth_source_label: - type: string - auth_source_name: - type: string - auth_source_type: - type: string - created_at: - type: string - email: - type: string - external_username: - type: string - id: - type: integer - type: object - model.ManagedDomain: - properties: - cert_id: - type: integer - created_at: - type: string - domain: - type: string - enabled: - type: boolean - id: - type: integer - remark: - type: string - updated_at: - type: string - type: object - model.OpenFlareApplyLog: - properties: - checksum: - type: string - created_at: - type: string - id: - type: integer - main_config_checksum: - type: string - message: - type: string - node_id: - type: string - result: - type: string - route_config_checksum: - type: string - support_file_count: - type: integer - version: - type: string - type: object - model.OpenFlareHealthEvent: - properties: - created_at: - type: string - event_type: - type: string - first_triggered_at: - type: string - id: - type: integer - last_triggered_at: - type: string - message: - type: string - metadata_json: - type: string - node_id: - type: string - reported_at: - type: string - resolved_at: - type: string - severity: - type: string - status: - type: string - updated_at: - type: string - type: object - model.OpenFlareMetricSnapshot: - properties: - captured_at: - type: string - cpu_usage_percent: - type: number - created_at: - type: string - disk_read_bytes: - type: integer - disk_write_bytes: - type: integer - id: - type: integer - memory_total_bytes: - type: integer - memory_used_bytes: - type: integer - network_rx_bytes: - type: integer - network_tx_bytes: - type: integer - node_id: - type: string - storage_total_bytes: - type: integer - storage_used_bytes: - type: integer - type: object - model.OpenFlareNodeSystemProfile: - properties: - architecture: - type: string - cpu_cores: - type: integer - cpu_model: - type: string - created_at: - type: string - hostname: - type: string - id: - type: integer - kernel_version: - type: string - node_id: - type: string - os_name: - type: string - os_version: - type: string - reported_at: - type: string - total_disk_bytes: - type: integer - total_memory_bytes: - type: integer - updated_at: - type: string - uptime_seconds: - type: integer - type: object - model.OpenFlareOption: - properties: - key: - type: string - value: - type: string - type: object - model.OpenFlareRequestReport: - properties: - created_at: - type: string - error_count: - type: integer - id: - type: integer - node_id: - type: string - request_count: - type: integer - source_countries_json: - type: string - status_codes_json: - type: string - top_domains_json: - type: string - unique_visitor_count: - type: integer - window_ended_at: - type: string - window_started_at: - type: string - type: object - model.PushChannel: - properties: - created_at: - type: string - description: - description: 备注 - type: string - enabled: - description: 通道是否启用 - type: boolean - id: - type: integer - name: - description: 通道名称,仅英文字母和下划线,唯一 - type: string - other: - description: 请求体/SMTP 密码等 - type: string - token: - description: 鉴权令牌或发信用户名等 - type: string - type: - description: 通道类型:custom, lark, email - type: string - updated_at: - type: string - url: - description: 请求地址,HTTPS 协议或 SMTP 地址 - type: string - type: object - model.PushEvent: - properties: - channels: - description: 推送渠道列表,如 ["lark"] - items: - type: string - type: array - created_at: - type: string - enabled: - description: 是否启用 - type: boolean - event_key: - description: 如 admin_login - type: string - id: - type: integer - name: - description: 如 管理员登录 - type: string - targets: - description: 推送目标用户/邮箱列表 - items: - type: string - type: array - task_type: - description: 关联的异步任务类型 - type: string - template: - description: 消息模板 JSON - type: string - updated_at: - type: string - type: object - model.PushHistory: - properties: - channel: - type: string - content: - type: string - created_at: - type: string - error_msg: - type: string - event_key: - type: string - id: - type: integer - level: - type: string - status: - description: success / failed - type: string - target: - type: string - title: - type: string - type: object - model.Schedule: - properties: - created_at: - type: string - cron: - type: string - id: - example: "0" - type: string - is_active: - type: boolean - name: - type: string - payload: - type: string - task_type: - type: string - updated_at: - type: string - type: object - model.SystemConfig: - properties: - created_at: - type: string - description: - type: string - key: - type: string - type: - type: string - updated_at: - type: string - value: - type: string - visibility: - type: integer - type: object - model.TLSCertificate: - properties: - acme_account_id: - type: integer - apply_message: - type: string - apply_status: - type: string - auto_renew: - type: boolean - created_at: - type: string - disable_cname: - type: boolean - dns_account_id: - type: integer - dns1: - type: string - dns2: - type: string - id: - type: integer - key_algorithm: - type: string - name: - type: string - not_after: - type: string - not_before: - type: string - other_domains: - type: string - primary_domain: - type: string - provider: - type: string - remark: - type: string - skip_dns: - type: boolean - updated_at: - type: string - type: object - model.TaskExecution: - properties: - created_at: - type: string - duration: - type: integer - error_message: - type: string - finished_at: - type: string - id: - example: "0" - type: string - log: - type: string - max_retry: - type: integer - payload: - type: string - result: - type: string - retry_count: - type: integer - retryable: - type: boolean - started_at: - type: string - status: - $ref: '#/definitions/model.TaskExecutionStatus' - task_id: - type: string - task_name: - type: string - task_type: - type: string - triggered_by: - type: string - updated_at: - type: string - type: object - model.TaskExecutionStatus: - enum: - - pending - - running - - succeeded - - failed - type: string - x-enum-varnames: - - TaskExecutionStatusPending - - TaskExecutionStatusRunning - - TaskExecutionStatusSucceeded - - TaskExecutionStatusFailed - model.Template: - properties: - content: - type: string - created_at: - type: string - description: - type: string - id: - type: integer - is_system: - type: boolean - key: - type: string - name: - type: string - subject: - type: string - type: - type: string - updated_at: - type: string - type: object - model.Upload: - properties: - access_mode: - type: integer - created_at: - type: string - extension: - description: 文件后缀名 (不含点,如 png, pdf) - type: string - file_name: - description: '原始文件名 (例如: image.png)' - type: string - file_path: - description: 文件相对路径 / S3 Key - type: string - file_size: - description: 文件大小(字节) - type: integer - hash: - description: 文件哈希 (SHA-256/MD5,可用于排重) - type: string - id: - example: "0" - type: string - metadata: - allOf: - - $ref: '#/definitions/model.UploadMetadata' - description: 业务扩展元数据 - mime_type: - description: 媒体类型 (MIME, 如 image/png) - type: string - status: - allOf: - - $ref: '#/definitions/model.UploadStatus' - description: 状态 - type: - description: 业务标识类型 (如 avatar, doc, attachment) - type: string - updated_at: - type: string - user_id: - example: "0" - type: string - type: object - model.UploadMetadata: - properties: - bucket: - description: 存储桶名称 (适用于 S3 等) - type: string - client_ip: - description: 上传者 IP - type: string - duration: - description: 音视频时长 (s) - type: number - extra: - additionalProperties: {} - description: 其它任意业务自定义元数据 - type: object - height: - description: 图像/视频高度 (px) - type: integer - original_mime: - description: 原始 MIME 类型 - type: string - user_agent: - description: 上传者的 UA - type: string - width: - description: 图像/视频宽度 (px) - type: integer - type: object - model.UploadStatus: - enum: - - pending - - used - - deleted - type: string - x-enum-comments: - UploadStatusDeleted: 已删除 - UploadStatusPending: 待使用 - UploadStatusUsed: 已使用 - x-enum-descriptions: - - 待使用 - - 已使用 - - 已删除 - x-enum-varnames: - - UploadStatusPending - - UploadStatusUsed - - UploadStatusDeleted - node.AgentReleaseInfo: - properties: - body: - type: string - channel: - type: string - current_version: - type: string - has_update: - type: boolean - html_url: - type: string - prerelease: - type: boolean - published_at: - type: string - requested_channel: - type: string - requested_tag: - type: string - tag_name: - type: string - update_requested: - type: boolean - type: object - node.AgentUpdateInput: - properties: - channel: - type: string - tag_name: - type: string - type: object - node.BootstrapView: - properties: - discovery_token: - type: string - type: object - node.HealthEventCleanupResult: - properties: - deleted_count: - type: integer - node_id: - type: string - type: object - node.Input: - properties: - auto_update_enabled: - type: boolean - geo_latitude: - type: number - geo_longitude: - type: number - geo_manual_override: - type: boolean - geo_name: - type: string - ip: - type: string - ip_manual_override: - type: boolean - name: - type: string - node_type: - type: string - relay_agent_access_addr: - type: string - relay_bind_port: - type: integer - relay_client_access_addr: - type: string - relay_client_proxy_url: - type: string - relay_vhost_http_port: - type: integer - relay_web_server_enabled: - type: boolean - type: object - node.ObservabilityView: - properties: - analytics: - $ref: '#/definitions/observability.NodeAnalytics' - health_events: - items: - $ref: '#/definitions/model.OpenFlareHealthEvent' - type: array - metric_snapshots: - items: - $ref: '#/definitions/model.OpenFlareMetricSnapshot' - type: array - node_id: - type: string - profile: - $ref: '#/definitions/model.OpenFlareNodeSystemProfile' - relay_dashboard: - $ref: '#/definitions/observability.RelayDashboardSnapshot' - traffic_reports: - items: - $ref: '#/definitions/model.OpenFlareRequestReport' - type: array - trends: - $ref: '#/definitions/observability.NodeTrends' - type: object - node.View: - properties: - access_token: - type: string - auto_update_enabled: - type: boolean - created_at: - type: string - current_version: - type: string - ext_version: - type: string - geo_latitude: - type: number - geo_longitude: - type: number - geo_manual_override: - type: boolean - geo_name: - type: string - id: - type: integer - ip: - type: string - ip_manual_override: - type: boolean - last_error: - type: string - last_seen_at: {} - latest_apply_at: - type: string - latest_apply_checksum: - type: string - latest_apply_message: - type: string - latest_apply_result: - type: string - latest_main_config_checksum: - type: string - latest_route_config_checksum: - type: string - latest_support_file_count: - type: integer - name: - type: string - node_id: - type: string - node_type: - type: string - openresty_message: - type: string - openresty_status: - type: string - relay_agent_access_addr: - type: string - relay_bind_port: - type: integer - relay_client_access_addr: - type: string - relay_client_proxy_url: - type: string - relay_status: - type: string - relay_vhost_http_port: - type: integer - relay_web_server_enabled: - type: boolean - restart_openresty_requested: - type: boolean - status: - type: string - update_channel: - type: string - update_requested: - type: boolean - update_tag: - type: string - updated_at: - type: string - version: - type: string - type: object - oauth.AuthSourceView: - properties: - client_secret_configured: - type: boolean - display_name: - type: string - icon_url: - type: string - id: - type: integer - is_active: - type: boolean - name: - type: string - type: - type: string - type: object - oauth.BasicUserInfo: - properties: - avatar_url: - type: string - bio: - type: string - email: - type: string - gender: - type: string - id: - type: integer - is_admin: - type: boolean - location: - type: string - need_change_password: - type: boolean - nickname: - type: string - phone: - type: string - username: - type: string - website: - type: string - type: object - oauth.CallbackRequest: - properties: - code: - type: string - state: - type: string - required: - - code - - state - type: object - oauth.OAuthAuthorizeResponse: - properties: - authorize_url: - type: string - type: object - oauth.OAuthCallbackResult: - properties: - status: - type: string - user: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - observability.AccessLogCleanupInput: - properties: - retention_days: - type: integer - type: object - observability.AccessLogCleanupResult: - properties: - cutoff: - type: string - deleted_count: - type: integer - retention_days: - type: integer - type: object - observability.AccessLogIPSummaryList: - properties: - has_more: - type: boolean - items: - items: - $ref: '#/definitions/observability.AccessLogIPSummaryView' - type: array - page: - type: integer - page_size: - type: integer - sort_by: - type: string - sort_order: - type: string - total_ip: - type: integer - type: object - observability.AccessLogIPSummaryView: - properties: - last_seen_at: - type: string - recent_requests: - type: integer - remote_addr: - type: string - total_requests: - type: integer - type: object - observability.AccessLogIPTrendPoint: - properties: - bucket_started_at: - type: string - request_count: - type: integer - type: object - observability.AccessLogIPTrendView: - properties: - bucket_minutes: - type: integer - hours: - type: integer - points: - items: - $ref: '#/definitions/observability.AccessLogIPTrendPoint' - type: array - remote_addr: - type: string - type: object - observability.AccessLogList: - properties: - has_more: - type: boolean - items: - items: - $ref: '#/definitions/observability.AccessLogView' - type: array - page: - type: integer - page_size: - type: integer - total_ip: - type: integer - total_record: - type: integer - type: object - observability.AccessLogView: - properties: - host: - type: string - id: - type: integer - logged_at: - type: string - node_id: - type: string - node_name: - type: string - path: - type: string - region: - type: string - remote_addr: - type: string - status_code: - type: integer - type: object - observability.CapacityTrendPoint: - properties: - average_cpu_usage_percent: - type: number - average_memory_usage_percent: - type: number - bucket_started_at: - type: string - reported_nodes: - type: integer - type: object - observability.DiskIOTrendPoint: - properties: - bucket_started_at: - type: string - disk_read_bytes: - type: integer - disk_write_bytes: - type: integer - reported_nodes: - type: integer - type: object - observability.DistributionItem: - properties: - key: - type: string - value: - type: integer - type: object - observability.FoldedAccessLogIPList: - properties: - bucket_started_at: - type: string - fold_minutes: - type: integer - has_more: - type: boolean - items: - items: - $ref: '#/definitions/observability.FoldedAccessLogIPView' - type: array - page: - type: integer - page_size: - type: integer - sort_by: - type: string - sort_order: - type: string - total_ip: - type: integer - type: object - observability.FoldedAccessLogIPView: - properties: - client_error_count: - type: integer - last_seen_at: - type: string - remote_addr: - type: string - request_count: - type: integer - server_error_count: - type: integer - success_count: - type: integer - type: object - observability.FoldedAccessLogList: - properties: - fold_minutes: - type: integer - has_more: - type: boolean - items: - items: - $ref: '#/definitions/observability.FoldedAccessLogView' - type: array - page: - type: integer - page_size: - type: integer - total_bucket: - type: integer - total_ip: - type: integer - total_record: - type: integer - type: object - observability.FoldedAccessLogView: - properties: - bucket_started_at: - type: string - client_error_count: - type: integer - request_count: - type: integer - server_error_count: - type: integer - success_count: - type: integer - unique_host_count: - type: integer - unique_ip_count: - type: integer - type: object - observability.HealthSummary: - properties: - active_alerts: - type: integer - critical_alerts: - type: integer - has_capacity_risk: - type: boolean - has_runtime_risk: - type: boolean - has_traffic_risk: - type: boolean - info_alerts: - type: integer - resolved_alerts: - type: integer - warning_alerts: - type: integer - type: object - observability.NetworkTrendPoint: - properties: - bucket_started_at: - type: string - network_rx_bytes: - type: integer - network_tx_bytes: - type: integer - openresty_rx_bytes: - type: integer - openresty_tx_bytes: - type: integer - reported_nodes: - type: integer - type: object - observability.NodeAnalytics: - properties: - distributions: - $ref: '#/definitions/observability.TrafficDistributions' - health: - $ref: '#/definitions/observability.HealthSummary' - traffic: - $ref: '#/definitions/observability.TrafficWindowSummary' - type: object - observability.NodeTrends: - properties: - capacity_24h: - items: - $ref: '#/definitions/observability.CapacityTrendPoint' - type: array - disk_io_24h: - items: - $ref: '#/definitions/observability.DiskIOTrendPoint' - type: array - network_24h: - items: - $ref: '#/definitions/observability.NetworkTrendPoint' - type: array - traffic_24h: - items: - $ref: '#/definitions/observability.TrafficTrendPoint' - type: array - type: object - observability.RelayDashboardSnapshot: - properties: - client_counts: - type: integer - offline_proxies: - type: integer - online_proxies: - type: integer - proxies: - items: - $ref: '#/definitions/observability.RelayProxyStat' - type: array - total_connections: - type: integer - total_proxies: - type: integer - type: object - observability.RelayProxyStat: - properties: - client_addr: - type: string - client_version: - type: string - last_close_time: - type: string - last_start_time: - type: string - name: - type: string - status: - type: string - type: - type: string - type: object - observability.TrafficDistributions: - properties: - source_countries: - items: - $ref: '#/definitions/observability.DistributionItem' - type: array - status_codes: - items: - $ref: '#/definitions/observability.DistributionItem' - type: array - top_domains: - items: - $ref: '#/definitions/observability.DistributionItem' - type: array - type: object - observability.TrafficTrendPoint: - properties: - bucket_started_at: - type: string - error_count: - type: integer - request_count: - type: integer - unique_visitor_count: - type: integer - type: object - observability.TrafficWindowSummary: - properties: - error_count: - type: integer - error_rate_percent: - type: number - estimated_qps: - type: number - request_count: - type: integer - unique_visitor_count: - type: integer - window_ended_at: - type: string - window_started_at: - type: string - type: object - option.databaseCleanupInput: - properties: - retention_days: - type: integer - target: - type: string - type: object - option.databaseCleanupResult: - properties: - delete_all: - type: boolean - deleted_count: - type: integer - retention_days: - type: integer - target: - type: string - target_label: - type: string - type: object - option.geoIPLookupRequest: - properties: - ip: - type: string - provider: - type: string - type: object - option.geoIPLookupView: - properties: - ip: - type: string - iso_code: - type: string - latitude: - type: number - longitude: - type: number - name: - type: string - provider: - type: string - type: object - option.optionBatchPayload: - properties: - options: - items: - $ref: '#/definitions/model.OpenFlareOption' - type: array - type: object - option.publicAuthSourceView: - properties: - authorize_url: - type: string - display_name: - type: string - icon_url: - type: string - id: - type: integer - name: - type: string - type: - type: string - type: object - option.statusView: - properties: - auth_sources: - items: - $ref: '#/definitions/option.publicAuthSourceView' - type: array - cap_login_enabled: - type: boolean - email_verification: - type: boolean - footer_html: - type: string - github_client_id: - type: string - github_oauth: - type: boolean - home_page_link: - type: string - password_register_enabled: - type: boolean - server_address: - type: string - start_time: - type: integer - system_name: - type: string - version: - type: string - wechat_login: - type: boolean - wechat_qrcode: - type: string - type: object - origin.DetailView: - properties: - address: - type: string - created_at: - type: string - id: - type: integer - name: - type: string - remark: - type: string - route_count: - type: integer - routes: - items: - $ref: '#/definitions/origin.RouteSummary' - type: array - updated_at: - type: string - type: object - origin.Input: - properties: - address: - type: string - name: - type: string - remark: - type: string - type: object - origin.RouteSummary: - properties: - domain: - type: string - enabled: - type: boolean - id: - type: integer - origin_url: - type: string - updated_at: - type: string - type: object - origin.View: - properties: - address: - type: string - created_at: - type: string - id: - type: integer - name: - type: string - remark: - type: string - route_count: - type: integer - updated_at: - type: string - type: object - pages.DeploymentFileView: - properties: - checksum: - type: string - created_at: - type: string - deployment_id: - type: integer - id: - type: integer - path: - type: string - size: - type: integer - type: object - pages.DeploymentView: - properties: - activated_at: - type: string - checksum: - type: string - created_at: - type: string - created_by: - type: string - deployment_number: - type: integer - file_count: - type: integer - id: - type: integer - project_id: - type: integer - status: - type: string - total_size: - type: integer - type: object - pages.Input: - properties: - api_proxy_enabled: - type: boolean - api_proxy_pass: - type: string - api_proxy_path: - type: string - api_proxy_rewrite: - type: string - description: - type: string - enabled: - type: boolean - entry_file: - type: string - name: - type: string - root_dir: - type: string - slug: - type: string - spa_fallback_enabled: - type: boolean - spa_fallback_path: - type: string - type: object - pages.View: - properties: - active_deployment: - $ref: '#/definitions/pages.DeploymentView' - active_deployment_id: - type: integer - api_proxy_enabled: - type: boolean - api_proxy_pass: - type: string - api_proxy_path: - type: string - api_proxy_rewrite: - type: string - created_at: - type: string - deployment_count: - type: integer - description: - type: string - enabled: - type: boolean - entry_file: - type: string - id: - type: integer - name: - type: string - root_dir: - type: string - slug: - type: string - spa_fallback_enabled: - type: boolean - spa_fallback_path: - type: string - updated_at: - type: string - type: object - proxy_route.CustomHeaderInput: - properties: - key: - type: string - value: - type: string - type: object - proxy_route.Input: - properties: - basic_auth_enabled: - type: boolean - basic_auth_password: - type: string - basic_auth_username: - type: string - cache_enabled: - type: boolean - cache_policy: - type: string - cache_rules: - items: - type: string - type: array - cert_id: - type: integer - cert_ids: - items: - type: integer - type: array - custom_headers: - items: - $ref: '#/definitions/proxy_route.CustomHeaderInput' - type: array - domain: - type: string - domain_cert_ids: - items: - type: integer - type: array - domains: - items: - type: string - type: array - enable_https: - type: boolean - enabled: - type: boolean - limit_conn_per_ip: - type: integer - limit_conn_per_server: - type: integer - limit_rate: - type: string - origin_address: - type: string - origin_host: - type: string - origin_id: - type: integer - origin_port: - type: string - origin_scheme: - type: string - origin_uri: - type: string - origin_url: - type: string - pages_project_id: - type: integer - redirect_http: - type: boolean - remark: - type: string - site_name: - type: string - tunnel_id: - type: integer - tunnel_node_id: - type: integer - tunnel_target_addr: - type: string - tunnel_target_protocol: - type: string - upstream_type: - type: string - upstreams: - items: - type: string - type: array - type: object - proxy_route.View: - properties: - basic_auth_enabled: - type: boolean - basic_auth_password: - type: string - basic_auth_username: - type: string - cache_enabled: - type: boolean - cache_policy: - type: string - cache_rule_list: - items: - type: string - type: array - cache_rules: - type: string - cert_id: - type: integer - cert_ids: - items: - type: integer - type: array - created_at: - type: string - custom_header_list: - items: - $ref: '#/definitions/proxy_route.CustomHeaderInput' - type: array - custom_headers: - type: string - domain: - type: string - domain_cert_ids: - items: - type: integer - type: array - domain_count: - type: integer - domains: - items: - type: string - type: array - enable_https: - type: boolean - enabled: - type: boolean - id: - type: integer - limit_conn_per_ip: - type: integer - limit_conn_per_server: - type: integer - limit_rate: - type: string - origin_host: - type: string - origin_id: - type: integer - origin_url: - type: string - pages_project_id: - type: integer - primary_domain: - type: string - redirect_http: - type: boolean - remark: - type: string - site_name: - type: string - tunnel_id: - type: integer - tunnel_node_id: - type: integer - tunnel_target_addr: - type: string - tunnel_target_protocol: - type: string - updated_at: - type: string - upstream_list: - items: - type: string - type: array - upstream_type: - type: string - upstreams: - type: string - type: object - push.Config: - properties: - channel: - description: 渠道名称,例如 "lark", "custom", "email" 等,唯一标识 - type: string - ext: - additionalProperties: {} - description: 预留拓展 JSON 配置 - type: object - key: - description: AppID 或 SMTP 用户名 - type: string - secret: - description: 签名密钥或 SMTP 密码/Token - type: string - url: - description: Webhook 地址或 SMTP 地址 - type: string - type: object - push.CreateChannelRequest: - properties: - description: - type: string - enabled: - type: boolean - name: - type: string - other: - type: string - token: - type: string - type: - type: string - url: - type: string - required: - - name - - type - type: object - push.CreateEventRequest: - properties: - channels: - items: - type: string - type: array - enabled: - type: boolean - event_key: - type: string - targets: - items: - type: string - type: array - task_type: - description: 关联的异步任务类型 - type: string - template: - type: string - type: object - push.Definition: - properties: - description: - description: short description - type: string - fields: - description: form fields - items: - $ref: '#/definitions/push.Field' - type: array - name: - description: display name - type: string - type: - description: channel type (e.g., custom, lark, email) - type: string - type: object - push.EventMetadata: - properties: - default_template: - $ref: '#/definitions/push.NotificationMessage' - description: - type: string - key: - type: string - name: - type: string - type: object - push.Field: - properties: - description: - description: field explanation/help text - type: string - key: - description: unique key for the field (e.g. url, token, other) - type: string - label: - description: human readable label (e.g. "Webhook 地址") - type: string - placeholder: - description: input placeholder - type: string - required: - description: whether this field is required - type: boolean - type: - description: 'input type: "text" | "password" | "textarea"' - type: string - type: object - push.NotificationMessage: - properties: - content: - type: string - ext: - additionalProperties: {} - type: object - level: - type: string - title: - type: string - type: object - push.TestChannelRequest: - properties: - name: - type: string - other: - type: string - target: - type: string - token: - type: string - type: - type: string - url: - type: string - type: object - push.TestPushRequest: - properties: - config: - $ref: '#/definitions/push.Config' - target: - type: string - required: - - config - type: object - push.UpdateChannelRequest: - properties: - description: - type: string - enabled: - type: boolean - other: - type: string - token: - type: string - type: - type: string - url: - type: string - required: - - type - type: object - push.UpdateEventRequest: - properties: - channels: - items: - type: string - type: array - enabled: - type: boolean - targets: - items: - type: string - type: array - template: - type: string - required: - - template - type: object - push.pushHistoriesResponse: - properties: - results: - items: - $ref: '#/definitions/model.PushHistory' - type: array - total: - type: integer - type: object - response.Any: - properties: - data: {} - error_msg: - example: "" - type: string - type: object - status.DatabaseInfoResponse: - properties: - name: - type: string - type: - type: string - version: - type: string - type: object - status.SystemStatusResponse: - properties: - alloc: - type: string - buck_hash_sys: - type: string - frees: - type: integer - gc_sys: - type: string - heap_alloc: - type: string - heap_idle: - type: string - heap_inuse: - type: string - heap_objects: - type: integer - heap_released: - type: string - heap_sys: - type: string - last_gc_time: - type: string - last_pause: - type: string - lookups: - type: integer - mallocs: - type: integer - mcache_inuse: - type: string - mcache_sys: - type: string - mspan_inuse: - type: string - mspan_sys: - type: string - next_gc: - type: string - num_gc: - type: integer - num_goroutine: - type: integer - other_sys: - type: string - pause_total_ns: - type: string - stack_inuse: - type: string - stack_sys: - type: string - sys: - type: string - total_alloc: - type: string - uptime: - type: string - type: object - system_config.CreateSystemConfigRequest: - properties: - description: - maxLength: 255 - type: string - key: - maxLength: 64 - type: string - type: - enum: - - system - - business - type: string - value: - type: string - visibility: - enum: - - 0 - - 1 - type: integer - required: - - key - - type - - value - type: object - system_config.TestSMTPRequest: - properties: - smtp_host: - maxLength: 255 - type: string - smtp_password: - maxLength: 255 - type: string - smtp_port: - type: integer - smtp_username: - maxLength: 255 - type: string - to: - type: string - required: - - smtp_host - - smtp_password - - smtp_port - - smtp_username - - to - type: object - system_config.TestSMTPResponse: - properties: - error: - type: string - log: - type: string - success: - type: boolean - type: object - system_config.UpdateSystemConfigRequest: - properties: - description: - maxLength: 255 - type: string - value: - type: string - visibility: - enum: - - 0 - - 1 - type: integer - required: - - value - type: object - task.CreateScheduleRequest: - properties: - cron: - type: string - is_active: - type: boolean - name: - type: string - payload: - type: string - task_type: - type: string - required: - - cron - - is_active - - name - - task_type - type: object - task.DispatchTaskRequest: - properties: - end_time: - type: string - payload: - type: string - start_time: - type: string - task_type: - type: string - user_id: - type: integer - required: - - task_type - type: object - task.TaskMeta: - properties: - asynq_task: - type: string - description: - type: string - max_retry: - type: integer - name: - type: string - params: - items: - $ref: '#/definitions/task.TaskParam' - type: array - queue: - type: string - retryable: - description: 是否支持手动重试 - type: boolean - supports_time: - type: boolean - type: - type: string - type: object - task.TaskParam: - properties: - description: - description: 描述 - type: string - label: - description: 显示名称 - type: string - name: - description: 参数键名 - type: string - placeholder: - description: 占位符 - type: string - required: - description: 是否必填 - type: boolean - type: - description: 类型:string, text, number, boolean - type: string - type: object - task.UpdateScheduleRequest: - properties: - cron: - type: string - is_active: - type: boolean - name: - type: string - payload: - type: string - task_type: - type: string - required: - - cron - - is_active - - name - - task_type - type: object - template.CreateTemplateRequest: - properties: - content: - type: string - description: - maxLength: 255 - type: string - key: - maxLength: 80 - type: string - name: - maxLength: 100 - type: string - subject: - maxLength: 255 - type: string - type: - maxLength: 20 - type: string - required: - - content - - key - - name - - type - type: object - template.UpdateTemplateRequest: - properties: - content: - type: string - description: - maxLength: 255 - type: string - name: - maxLength: 100 - type: string - subject: - maxLength: 255 - type: string - type: - maxLength: 20 - type: string - required: - - content - - name - - type - type: object - tls.ApplyInput: - properties: - acme_account_id: - type: integer - auto_renew: - type: boolean - disable_cname: - type: boolean - dns_account_id: - type: integer - dns1: - type: string - dns2: - type: string - key_algorithm: - type: string - name: - type: string - other_domains: - type: string - primary_domain: - type: string - remark: - type: string - skip_dns: - type: boolean - type: object - tls.CertificateContent: - properties: - acme_account_id: - type: integer - apply_message: - type: string - apply_status: - type: string - auto_renew: - type: boolean - cert_pem: - type: string - disable_cname: - type: boolean - dns_account_id: - type: integer - dns1: - type: string - dns2: - type: string - id: - type: integer - key_algorithm: - type: string - key_pem: - type: string - name: - type: string - other_domains: - type: string - primary_domain: - type: string - provider: - type: string - remark: - type: string - skip_dns: - type: boolean - type: object - tls.CertificateInput: - properties: - cert_pem: - type: string - key_pem: - type: string - name: - type: string - remark: - type: string - type: object - tls.DNSAccountInput: - properties: - authorization: - type: string - name: - type: string - type: - type: string - type: object - tls.ManagedDomainInput: - properties: - cert_id: - type: integer - domain: - type: string - enabled: - type: boolean - remark: - type: string - type: object - tls.ManagedDomainMatchCandidate: - properties: - certificate_id: - type: integer - certificate_name: - type: string - domain: - type: string - managed_domain_id: - type: integer - match_type: - type: string - type: object - tls.ManagedDomainMatchResult: - properties: - candidate: - $ref: '#/definitions/tls.ManagedDomainMatchCandidate' - candidates: - items: - $ref: '#/definitions/tls.ManagedDomainMatchCandidate' - type: array - domain: - type: string - matched: - type: boolean - type: object - updater.Status: - properties: - asset_name: - type: string - build_time: - type: string - can_upgrade: - type: boolean - current_version: - type: string - latest_version: - type: string - platform: - type: string - prerelease: - type: boolean - published_at: - type: string - release_name: - type: string - release_notes: - type: string - release_url: - type: string - update_available: - type: boolean - upstream_repository: - type: string - type: object - user.changePasswordRequest: - properties: - new_password: - type: string - old_password: - type: string - type: object - user.createTokenRequest: - properties: - is_admin: - type: boolean - name: - type: string - type: object - user.createUserRequest: - properties: - email: - maxLength: 255 - type: string - is_active: - type: boolean - is_admin: - type: boolean - nickname: - maxLength: 64 - type: string - password: - maxLength: 64 - minLength: 8 - type: string - username: - maxLength: 64 - minLength: 3 - type: string - required: - - email - - password - - username - type: object - user.listUsersResponse: - properties: - total: - type: integer - users: - items: - $ref: '#/definitions/user.user' - type: array - type: object - user.loginRequest: - properties: - code: - type: string - password: - type: string - username: - type: string - type: object - user.registerRequest: - properties: - code: - type: string - display_name: - type: string - email: - type: string - nickname: - type: string - password: - type: string - username: - type: string - type: object - user.sendEmailCodeRequest: - properties: - email: - type: string - scene: - type: string - required: - - email - - scene - type: object - user.tokenResponse: - properties: - record: - $ref: '#/definitions/model.AccessToken' - token: - type: string - type: object - user.updateProfileRequest: - properties: - avatar_url: - type: string - bio: - type: string - email: - type: string - gender: - type: string - location: - type: string - nickname: - type: string - phone: - type: string - website: - type: string - type: object - user.updateUserStatusRequest: - properties: - is_active: - type: boolean - type: object - user.user: - properties: - avatar_url: - type: string - bio: - type: string - created_at: - type: string - email: - type: string - gender: - type: string - id: - example: "0" - type: string - is_active: - type: boolean - is_admin: - type: boolean - last_login_at: - type: string - location: - type: string - nickname: - type: string - phone: - type: string - updated_at: - type: string - username: - type: string - website: - type: string - type: object - waf.IDsRequest: - properties: - ids: - items: - type: integer - type: array - type: object - waf.IPGroupAutoTestInput: - properties: - auto_config: - items: - type: integer - type: array - type: object - waf.IPGroupAutoTestResult: - properties: - lookback_minutes: - type: integer - matched_count: - type: integer - matched_ips: - items: - type: string - type: array - rule_count: - type: integer - tested_at: - type: string - type: object - waf.IPGroupExtIPView: - properties: - captured_at: - type: string - ip: - type: string - type: object - waf.IPGroupInput: - properties: - auto_config: - items: - type: integer - type: array - enabled: - type: boolean - ip_list: - items: - type: string - type: array - name: - type: string - remark: - type: string - subscription_format: - type: string - subscription_mapping_rule: - type: string - subscription_url: - type: string - sync_interval_minutes: - type: integer - type: - type: string - type: object - waf.IPGroupSyncResult: - properties: - group: - $ref: '#/definitions/waf.IPGroupView' - ip_count: - type: integer - message: - type: string - next_sync_at: - type: string - status: - type: string - synced_at: - type: string - type: object - waf.IPGroupView: - properties: - auto_config: - items: - type: integer - type: array - created_at: - type: string - enabled: - type: boolean - ext_ips: - items: - $ref: '#/definitions/waf.IPGroupExtIPView' - type: array - id: - type: integer - ip_list: - items: - type: string - type: array - last_sync_message: - type: string - last_sync_status: - type: string - last_synced_at: - type: string - name: - type: string - next_sync_at: - type: string - referenced_by_rule_count: - type: integer - remark: - type: string - subscription_format: - type: string - subscription_mapping_rule: - type: string - subscription_url: - type: string - sync_interval_minutes: - type: integer - type: - type: string - updated_at: - type: string - type: object - waf.PoWConfig: - properties: - algorithm: - type: string - blacklist: - $ref: '#/definitions/waf.PoWListConfig' - challenge_ttl: - type: integer - difficulty: - type: integer - session_ttl: - type: integer - whitelist: - $ref: '#/definitions/waf.PoWListConfig' - type: object - waf.PoWListConfig: - properties: - ip_cidrs: - items: - type: string - type: array - ips: - items: - type: string - type: array - path_regexes: - items: - type: string - type: array - paths: - items: - type: string - type: array - user_agents: - items: - type: string - type: array - type: object - waf.RuleGroupInput: - properties: - block_response_body: - type: string - block_status_code: - type: integer - country_blacklist: - items: - type: string - type: array - country_whitelist: - items: - type: string - type: array - enabled: - type: boolean - ip_blacklist: - items: - type: string - type: array - ip_blacklist_group_ids: - items: - type: integer - type: array - ip_whitelist: - items: - type: string - type: array - ip_whitelist_group_ids: - items: - type: integer - type: array - name: - type: string - pow_config: - items: - type: integer - type: array - pow_enabled: - type: boolean - region_blacklist: - items: - type: string - type: array - region_whitelist: - items: - type: string - type: array - remark: - type: string - type: object - waf.RuleGroupView: - properties: - applied_site_count: - type: integer - applied_site_ids: - items: - type: integer - type: array - block_response_body: - type: string - block_status_code: - type: integer - country_blacklist: - items: - type: string - type: array - country_whitelist: - items: - type: string - type: array - created_at: - type: string - enabled: - type: boolean - id: - type: integer - ip_blacklist: - items: - type: string - type: array - ip_blacklist_group_ids: - items: - type: integer - type: array - ip_whitelist: - items: - type: string - type: array - ip_whitelist_group_ids: - items: - type: integer - type: array - is_global: - type: boolean - name: - type: string - pow_config: - $ref: '#/definitions/waf.PoWConfig' - pow_enabled: - type: boolean - region_blacklist: - items: - type: string - type: array - region_whitelist: - items: - type: string - type: array - remark: - type: string - updated_at: - type: string - type: object - waf.SiteRuleGroupsView: - properties: - applied_ids: - items: - type: integer - type: array - applied_rule_groups: - items: - $ref: '#/definitions/waf.RuleGroupView' - type: array - global_rule_group: - $ref: '#/definitions/waf.RuleGroupView' - route_id: - type: integer - rule_groups: - items: - $ref: '#/definitions/waf.RuleGroupView' - type: array - type: object -info: - contact: - name: OpenFlare - url: https://github.com/Rain-kl/OpenFlare - description: OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。 - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - title: OpenFlare API - version: 1.0.0 -paths: - /api/cap/challenge: - post: - consumes: - - application/json - description: 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 - parameters: - - description: 可选范围限制参数 - in: body - name: request - schema: - $ref: '#/definitions/cap.challengeRequest' - produces: - - application/json - responses: - "200": - description: 成功返回 PoW 难题 - schema: - $ref: '#/definitions/cap.ChallengeResponse' - "500": - description: 内部服务错误 - schema: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' - summary: 生成人机验证难题 - tags: - - cap - /api/cap/redeem: - post: - consumes: - - application/json - description: 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 - parameters: - - description: 难题 Token 与解答 solutions 数组 - in: body - name: request - required: true - schema: - $ref: '#/definitions/cap.redeemRequest' - produces: - - application/json - responses: - "200": - description: 核销成功,返回 X-Cap-Token - schema: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' - "400": - description: 参数错误或核销失败 - schema: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' - "500": - description: 内部服务错误 - schema: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' - summary: 校验人机验证解答 - tags: - - cap - /api/health: - get: - description: 检查服务是否正常运行,可用于负载均衡存活探测 - produces: - - application/json - responses: - "200": - description: 服务正常 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - summary: 健康检查 - tags: - - health - /api/v1/admin/auth-sources: - get: - description: 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 认证源列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.AuthSource' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取认证源列表 - tags: - - admin - post: - consumes: - - application/json - description: 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 - parameters: - - description: 创建认证源参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth_source.AuthSourceRequest' - produces: - - application/json - responses: - "200": - description: 创建成功,返回认证源信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.AuthSource' - type: object - "400": - description: 参数错误或验证失败 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建认证源 - tags: - - admin - /api/v1/admin/auth-sources/{id}: - delete: - description: 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限 - parameters: - - description: 认证源 ID 或名称 - format: int64 - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: ID 无效或删除失败 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除认证源 - tags: - - admin - put: - consumes: - - application/json - description: 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限 - parameters: - - description: 认证源 ID 或名称 - format: int64 - in: path - name: id - required: true - type: integer - - description: 更新认证源参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth_source.AuthSourceRequest' - produces: - - application/json - responses: - "200": - description: 更新成功,返回更新后的认证源信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.AuthSource' - type: object - "400": - description: 参数错误或验证失败 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新认证源 - tags: - - admin - /api/v1/admin/auth-sources/{id}/toggle: - put: - consumes: - - application/json - description: 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限 - parameters: - - description: 认证源 ID 或名称 - format: int64 - in: path - name: id - required: true - type: integer - - description: 启用状态 - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth_source.ToggleAuthSourceRequest' - produces: - - application/json - responses: - "200": - description: 切换成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 验证失败或认证源不存在 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 切换认证源启用状态 - tags: - - admin - /api/v1/admin/cache/clear: - post: - description: 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据 - produces: - - application/json - responses: - "200": - description: 清理成功 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 清空缓存 - tags: - - admin - /api/v1/admin/cache/config: - post: - consumes: - - application/json - description: 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新 - parameters: - - description: 缓存配置请求体 - in: body - name: request - required: true - schema: - $ref: '#/definitions/cache.updateCacheConfigRequest' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新缓存配置 - tags: - - admin - /api/v1/admin/cache/status: - get: - description: 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置 - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/diskcache.Status' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取缓存状态 - tags: - - admin - /api/v1/admin/db-export: - get: - description: SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限 - produces: - - application/octet-stream - responses: - "200": - description: 数据库文件 - schema: - type: file - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 导出失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 导出数据库 - tags: - - admin - /api/v1/admin/db-info: - get: - description: 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/status.DatabaseInfoResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取数据库信息 - tags: - - admin - /api/v1/admin/db-manage/overview: - get: - description: 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/db_manage.DBOverviewResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取数据库运行概览 - tags: - - admin - /api/v1/admin/db-manage/query: - post: - consumes: - - application/json - description: 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限 - parameters: - - description: SQL 请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/db_manage.ExecuteSQLRequest' - produces: - - application/json - responses: - "200": - description: 执行完毕 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/db_manage.ExecuteSQLResponse' - type: object - "400": - description: SQL 语句错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 执行 SQL 查询 - tags: - - admin - /api/v1/admin/db-manage/tables: - get: - description: 返回当前数据库的所有用户自定义表名称列表,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - type: string - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取数据库所有表名 - tags: - - admin - /api/v1/admin/logs: - get: - description: 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志 - parameters: - - default: 0 - description: 日志游标,0=获取最新 - in: query - name: cursor - type: integer - - default: 200 - description: 每页条数 - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: 日志列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/logs.logsResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取系统日志 - tags: - - admin - /api/v1/admin/logs/access: - get: - description: 分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错) - parameters: - - default: 1 - description: 页码 - in: query - name: page - type: integer - - default: 20 - description: 每页条数 - in: query - name: page_size - type: integer - - description: 用户名模糊搜索 - in: query - name: username - type: string - - description: 接口路径模糊搜索 - in: query - name: path - type: string - - description: 起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS) - in: query - name: start_time - type: string - - description: 结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS) - in: query - name: end_time - type: string - produces: - - application/json - responses: - "200": - description: 访问日志列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/logs.accessLogsResponse' - type: object - "400": - description: ClickHouse 未启用或参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取用户访问日志 - tags: - - admin - /api/v1/admin/logs/analytics: - get: - description: 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错) - produces: - - application/json - responses: - "200": - description: 分析统计数据 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/logs.logsAnalyticsResponse' - type: object - "400": - description: ClickHouse 未启用 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取访问日志分析数据 - tags: - - admin - /api/v1/admin/logs/ws: - get: - description: 通过 WebSocket 实时推送系统日志,需要管理员权限 - responses: {} - summary: 系统日志实时推送 - tags: - - admin - /api/v1/admin/push/channels: - get: - description: 返回系统配置的所有消息通道列表,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 消息通道列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.PushChannel' - type: array - type: object - security: - - SessionCookie: [] - summary: 获取所有消息通道 - tags: - - admin-push - post: - consumes: - - application/json - description: 新建一个消息通道配置,需要管理员权限 - parameters: - - description: 创建参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/push.CreateChannelRequest' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.PushChannel' - type: object - security: - - SessionCookie: [] - summary: 创建消息通道 - tags: - - admin-push - /api/v1/admin/push/channels/{id}: - delete: - description: 根据ID删除消息通道,需要管理员权限 - parameters: - - description: 通道ID - format: int64 - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除消息通道 - tags: - - admin-push - put: - consumes: - - application/json - description: 修改消息通道配置,需要管理员权限 - parameters: - - description: 通道ID - format: int64 - in: path - name: id - required: true - type: integer - - description: 更新参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/push.UpdateChannelRequest' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.PushChannel' - type: object - security: - - SessionCookie: [] - summary: 更新消息通道 - tags: - - admin-push - /api/v1/admin/push/channels/definitions: - get: - description: 返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 通道配置定义列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/push.Definition' - type: array - type: object - security: - - SessionCookie: [] - summary: 获取所有消息通道配置字段定义 - tags: - - admin-push - /api/v1/admin/push/channels/test: - post: - consumes: - - application/json - description: 触发一次临时的或现有的通道连通性推送测试,需要管理员权限 - parameters: - - description: 测试参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/push.TestChannelRequest' - produces: - - application/json - responses: - "200": - description: 测试触发成功 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 测试通道连通性 - tags: - - admin-push - /api/v1/admin/push/events: - get: - description: 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 通知事件列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.PushEvent' - type: array - type: object - security: - - SessionCookie: [] - summary: 获取所有通知事件 - tags: - - admin-push - post: - consumes: - - application/json - description: 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限 - parameters: - - description: 创建参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/push.CreateEventRequest' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.PushEvent' - type: object - security: - - SessionCookie: [] - summary: 创建通知事件 - tags: - - admin-push - /api/v1/admin/push/events/{id}: - delete: - description: 删除数据库中的特定通知事件配置,需要管理员权限 - parameters: - - description: 事件 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - security: - - SessionCookie: [] - summary: 删除通知事件配置 - tags: - - admin-push - put: - consumes: - - application/json - description: 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限 - parameters: - - description: 事件 ID - in: path - name: id - required: true - type: integer - - description: 更新参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/push.UpdateEventRequest' - produces: - - application/json - responses: - "200": - description: 修改成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - security: - - SessionCookie: [] - summary: 更新通知事件 - tags: - - admin-push - /api/v1/admin/push/events/{id}/toggle: - post: - description: 启用或禁用指定的通知事件 - parameters: - - description: 事件 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 切换成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - security: - - SessionCookie: [] - summary: 快捷切换通知事件启用状态 - tags: - - admin-push - /api/v1/admin/push/events/builtin: - get: - description: 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 内置通知事件列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/push.EventMetadata' - type: array - type: object - security: - - SessionCookie: [] - summary: 获取所有内置通知事件 - tags: - - admin-push - /api/v1/admin/push/histories: - get: - description: 返回分页的通知历史日志数据,需要管理员权限 - parameters: - - description: 当前页码 - in: query - name: page - type: integer - - description: 分页大小 - in: query - name: page_size - type: integer - - description: 过滤事件名称 - in: query - name: event_key - type: string - - description: 过滤发送状态 - in: query - name: status - type: string - produces: - - application/json - responses: - "200": - description: 推送历史列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/push.pushHistoriesResponse' - type: object - security: - - SessionCookie: [] - summary: 分页获取通知推送历史 - tags: - - admin-push - /api/v1/admin/push/test: - post: - consumes: - - application/json - description: 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息 - parameters: - - description: 测试请求体 - in: body - name: request - required: true - schema: - $ref: '#/definitions/push.TestPushRequest' - produces: - - application/json - responses: - "200": - description: 测试成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - security: - - SessionCookie: [] - summary: 测试推送通道发送 - tags: - - admin-push - /api/v1/admin/status: - get: - description: 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/status.SystemStatusResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取系统状态信息 - tags: - - admin - /api/v1/admin/system-configs: - get: - description: 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 - parameters: - - description: 配置类型(system/business) - in: query - name: type - type: string - produces: - - application/json - responses: - "200": - description: 系统配置列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.SystemConfig' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取系统配置列表 - tags: - - admin - post: - consumes: - - application/json - description: 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限 - parameters: - - description: 创建请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/system_config.CreateSystemConfigRequest' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误或配置键已存在 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建系统配置 - tags: - - admin - /api/v1/admin/system-configs/{key}: - get: - description: 根据配置键获取对应的系统配置详情,需要管理员权限 - parameters: - - description: 配置键 - in: path - name: key - required: true - type: string - produces: - - application/json - responses: - "200": - description: 系统配置详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.SystemConfig' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 配置不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取单个系统配置 - tags: - - admin - put: - consumes: - - application/json - description: 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限 - parameters: - - description: 配置键 - in: path - name: key - required: true - type: string - - description: 更新请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/system_config.UpdateSystemConfigRequest' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 配置不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新系统配置 - tags: - - admin - /api/v1/admin/system-configs/smtp/test: - post: - consumes: - - application/json - description: 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码 - parameters: - - description: 测试请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/system_config.TestSMTPRequest' - produces: - - application/json - responses: - "200": - description: 测试执行完毕 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/system_config.TestSMTPResponse' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 测试 SMTP 邮件发送 - tags: - - admin - /api/v1/admin/tasks/dispatch: - post: - consumes: - - application/json - description: 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限 - parameters: - - description: 任务请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/task.DispatchTaskRequest' - produces: - - application/json - responses: - "200": - description: 任务已入队 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 任务类型不存在或参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 任务入队失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 下发异步任务 - tags: - - admin - /api/v1/admin/tasks/executions: - get: - description: 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限 - parameters: - - description: 状态筛选 (pending/running/succeeded/failed) - in: query - name: status - type: string - - description: 任务类型筛选 - in: query - name: task_type - type: string - - default: 1 - description: 页码 - in: query - name: page - type: integer - - default: 20 - description: 每页条数 - in: query - name: page_size - type: integer - produces: - - application/json - responses: - "200": - description: 任务执行记录列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: object - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 查询任务执行记录 - tags: - - admin - /api/v1/admin/tasks/executions/{id}: - get: - description: 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限 - parameters: - - description: 任务执行记录 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 任务执行详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TaskExecution' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 查询任务执行详情 - tags: - - admin - /api/v1/admin/tasks/executions/{id}/retry: - post: - description: 重新下发一条失败的任务,创建新的执行记录,需要管理员权限 - parameters: - - description: 任务执行记录 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 新任务的 TaskID - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 任务不支持重试或参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 重试失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 重试失败任务 - tags: - - admin - /api/v1/admin/tasks/schedules: - get: - description: 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 定时任务列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.Schedule' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取定时任务列表 - tags: - - admin - post: - consumes: - - application/json - description: 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限 - parameters: - - description: 创建定时任务请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/task.CreateScheduleRequest' - produces: - - application/json - responses: - "200": - description: 创建成功的定时任务信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.Schedule' - type: object - "400": - description: Cron 表达式无效、异步任务类型不存在或参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 保存定时任务失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建定时任务 - tags: - - admin - /api/v1/admin/tasks/schedules/{id}: - delete: - description: 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限 - parameters: - - description: 定时任务 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 删除定时任务失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除定时任务 - tags: - - admin - put: - consumes: - - application/json - description: 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限 - parameters: - - description: 定时任务 ID - in: path - name: id - required: true - type: integer - - description: 修改定时任务请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/task.UpdateScheduleRequest' - produces: - - application/json - responses: - "200": - description: 修改后的定时任务信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.Schedule' - type: object - "400": - description: Cron 表达式无效、参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 定时任务不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 修改定时任务失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 修改定时任务 - tags: - - admin - /api/v1/admin/tasks/types: - get: - description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 任务类型列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/task.TaskMeta' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取支持的任务类型 - tags: - - admin - /api/v1/admin/templates: - get: - description: 返回所有通知模板列表,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 模板列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.Template' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取模板列表 - tags: - - admin - post: - consumes: - - application/json - description: 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限 - parameters: - - description: 创建请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/template.CreateTemplateRequest' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误或模板标识符已存在 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建模板 - tags: - - admin - /api/v1/admin/templates/{key}: - delete: - description: 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限 - parameters: - - description: 模板标识符 - in: path - name: key - required: true - type: string - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 不可删除系统模板 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 模板不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除模板 - tags: - - admin - get: - description: 根据模板标识符获取对应的模板详情,需要管理员权限 - parameters: - - description: 模板标识符 - in: path - name: key - required: true - type: string - produces: - - application/json - responses: - "200": - description: 模板详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.Template' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 模板不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取单个模板 - tags: - - admin - put: - consumes: - - application/json - description: 根据模板标识符更新对应的模板内容,需要管理员权限 - parameters: - - description: 模板标识符 - in: path - name: key - required: true - type: string - - description: 更新请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/template.UpdateTemplateRequest' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.Template' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 模板不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新模板 - tags: - - admin - /api/v1/admin/update: - get: - description: 从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较 - produces: - - application/json - responses: - "200": - description: 更新状态 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/updater.Status' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 查询失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取应用更新状态 - tags: - - admin - /api/v1/admin/update/apply: - post: - description: 下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程 - produces: - - application/json - responses: - "200": - description: 升级已准备并即将重启 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 当前版本不可升级 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 升级准备失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 下载并应用应用更新 - tags: - - admin - /api/v1/admin/uploads: - get: - description: 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤 - parameters: - - description: 页码(默认 1) - in: query - name: page - type: integer - - description: 每页数量(默认 20,最大 100) - in: query - name: page_size - type: integer - - description: 文件名关键词(模糊匹配) - in: query - name: keyword - type: string - - description: 业务分类过滤 - in: query - name: type - type: string - - description: 扩展名过滤 - in: query - name: extension - type: string - - description: 上传用户 ID - format: int64 - in: query - name: user_id - type: integer - produces: - - application/json - responses: - "200": - description: 查询成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/handler.listFilesResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取文件列表 - tags: - - admin - /api/v1/admin/uploads/{id}: - delete: - description: 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无权操作 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 文件不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除文件 - tags: - - admin - /api/v1/admin/uploads/download/{id}: - get: - description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - - description: 图片质量 (low, medium, high, origin),默认为 origin - in: query - name: quality - type: string - produces: - - application/octet-stream - responses: - "200": - description: 成功下载文件 - schema: - type: file - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 文件不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 下载单文件 - tags: - - admin - /api/v1/admin/uploads/download/batch: - post: - consumes: - - application/json - description: 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 - parameters: - - description: 包含文件 ID 数组 of string 的请求体 - in: body - name: request - required: true - schema: - $ref: '#/definitions/handler.batchDownloadRequest' - produces: - - application/octet-stream - responses: - "200": - description: 成功下载打包后的 ZIP - schema: - type: file - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 打包失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 批量打包下载 - tags: - - admin - /api/v1/admin/uploads/stats: - get: - description: 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/handler.fileStatsResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取文件统计数据 - tags: - - admin - /api/v1/admin/uploads/types: - get: - description: 返回数据库中所有已上传文件实际拥有的业务类型列表 - produces: - - application/json - responses: - "200": - description: 业务类型列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - type: string - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取文件业务类型列表 - tags: - - admin - /api/v1/admin/users: - get: - description: 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 - parameters: - - in: query - minimum: 1 - name: page - type: integer - - in: query - maximum: 100 - minimum: 1 - name: page_size - type: integer - - in: query - name: user_id - type: integer - - in: query - name: username - type: string - produces: - - application/json - responses: - "200": - description: 用户列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/user.listUsersResponse' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取用户列表 - tags: - - admin - post: - consumes: - - application/json - description: 创建一个本地密码登录的新用户,需要管理员权限 - parameters: - - description: 创建用户参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.createUserRequest' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/user.user' - type: object - "400": - description: 参数错误或用户名已存在 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建用户 - tags: - - admin - /api/v1/admin/users/{id}: - delete: - description: 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户 - parameters: - - description: 用户 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限、尝试删除管理员或当前用户 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 用户不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除用户 - tags: - - admin - get: - description: 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段 - parameters: - - description: 用户 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 用户详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/user.user' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 用户不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取用户详情 - tags: - - admin - /api/v1/admin/users/{id}/status: - put: - consumes: - - application/json - description: 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限 - parameters: - - description: 用户 ID - in: path - name: id - required: true - type: integer - - description: 状态参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.updateUserStatusRequest' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限或尝试禁用管理员 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 用户不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新用户状态 - tags: - - admin - /api/v1/config/public: - get: - consumes: - - application/json - description: 返回系统配置表中 visibility 为 1 的配置键值集合 - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Any' - summary: 获取公共配置 - tags: - - config - /api/v1/custom/hello: - get: - description: A sample business API for customization - produces: - - application/json - responses: - "200": - description: 成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - summary: Sample Hello API - tags: - - custom - /api/v1/d/access-logs: - get: - description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限 - parameters: - - description: 节点 ID - in: query - name: node_id - type: string - - description: 客户端 IP - in: query - name: remote_addr - type: string - - description: 请求 Host - in: query - name: host - type: string - - description: 请求路径 - in: query - name: path - type: string - - description: 页码 - in: query - name: p - type: integer - - description: 每页条数 - in: query - name: page_size - type: integer - - description: 排序字段 - in: query - name: sort_by - type: string - - description: 排序方向 - in: query - name: sort_order - type: string - produces: - - application/json - responses: - "200": - description: 访问日志列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/observability.AccessLogList' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出访问日志 - tags: - - openflare-observability - /api/v1/d/access-logs/cleanup: - post: - consumes: - - application/json - description: 按保留天数清理过期访问日志记录,需要管理员权限 - parameters: - - description: 清理参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/observability.AccessLogCleanupInput' - produces: - - application/json - responses: - "200": - description: 清理结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/observability.AccessLogCleanupResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 清理访问日志 - tags: - - openflare-observability - /api/v1/d/access-logs/folds: - get: - description: 按时间桶聚合访问日志并分页返回,需要管理员权限 - parameters: - - description: 节点 ID - in: query - name: node_id - type: string - - description: 客户端 IP - in: query - name: remote_addr - type: string - - description: 请求 Host - in: query - name: host - type: string - - description: 请求路径 - in: query - name: path - type: string - - description: 折叠时间窗口(分钟) - in: query - name: fold_minutes - type: integer - - description: 页码 - in: query - name: p - type: integer - - description: 每页条数 - in: query - name: page_size - type: integer - - description: 排序字段 - in: query - name: sort_by - type: string - - description: 排序方向 - in: query - name: sort_order - type: string - produces: - - application/json - responses: - "200": - description: 折叠访问日志列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/observability.FoldedAccessLogList' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出折叠访问日志 - tags: - - openflare-observability - /api/v1/d/access-logs/folds/ip-summary: - get: - description: 在指定时间桶内按 IP 聚合访问统计,需要管理员权限 - parameters: - - description: 节点 ID - in: query - name: node_id - type: string - - description: 客户端 IP - in: query - name: remote_addr - type: string - - description: 请求 Host - in: query - name: host - type: string - - description: 请求路径 - in: query - name: path - type: string - - description: 时间桶起始时间 - in: query - name: bucket_started_at - type: string - - description: 折叠时间窗口(分钟) - in: query - name: fold_minutes - type: integer - - description: 页码 - in: query - name: p - type: integer - - description: 每页条数 - in: query - name: page_size - type: integer - - description: 排序字段 - in: query - name: sort_by - type: string - - description: 排序方向 - in: query - name: sort_order - type: string - produces: - - application/json - responses: - "200": - description: 折叠 IP 汇总列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/observability.FoldedAccessLogIPList' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出折叠访问日志 IP 汇总 - tags: - - openflare-observability - /api/v1/d/access-logs/ip-summary: - get: - description: 按 IP 聚合访问日志统计并分页返回,需要管理员权限 - parameters: - - description: 节点 ID - in: query - name: node_id - type: string - - description: 客户端 IP - in: query - name: remote_addr - type: string - - description: 请求 Host - in: query - name: host - type: string - - description: 页码 - in: query - name: p - type: integer - - description: 每页条数 - in: query - name: page_size - type: integer - - description: 排序字段 - in: query - name: sort_by - type: string - - description: 排序方向 - in: query - name: sort_order - type: string - produces: - - application/json - responses: - "200": - description: IP 汇总列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/observability.AccessLogIPSummaryList' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出访问日志 IP 汇总 - tags: - - openflare-observability - /api/v1/d/access-logs/ip-summary/trend: - get: - description: 返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限 - parameters: - - description: 节点 ID - in: query - name: node_id - type: string - - description: 客户端 IP - in: query - name: remote_addr - type: string - - description: 请求 Host - in: query - name: host - type: string - - description: 统计时间范围(小时) - in: query - name: hours - type: integer - - description: 时间桶粒度(分钟) - in: query - name: bucket_minutes - type: integer - produces: - - application/json - responses: - "200": - description: IP 访问趋势 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/observability.AccessLogIPTrendView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取访问日志 IP 趋势 - tags: - - openflare-observability - /api/v1/d/acme-accounts/default: - get: - description: 返回系统默认 ACME 账号配置,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 默认 ACME 账号 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.AcmeAccount' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取默认 ACME 账号 - tags: - - openflare-tls - /api/v1/d/apply-logs: - get: - description: 分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限 - parameters: - - description: 节点 ID 筛选 - in: query - name: node_id - type: string - - description: 页码 - in: query - name: pageNo - type: integer - - description: 页码(别名) - in: query - name: page_no - type: integer - - description: 每页数量 - in: query - name: pageSize - type: integer - - description: 每页数量(别名) - in: query - name: page_size - type: integer - produces: - - application/json - responses: - "200": - description: 下发日志列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/apply_log.ListResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取配置下发日志 - tags: - - openflare-apply-log - /api/v1/d/apply-logs/cleanup: - post: - consumes: - - application/json - description: 按保留天数清理历史下发记录,或删除全部记录,需要管理员权限 - parameters: - - description: 清理参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/apply_log.CleanupInput' - produces: - - application/json - responses: - "200": - description: 清理结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/apply_log.CleanupResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 清理配置下发日志 - tags: - - openflare-apply-log - /api/v1/d/config-versions: - get: - description: 返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 配置版本列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.ConfigVersionSummary' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取配置版本列表 - tags: - - openflare-config-version - /api/v1/d/config-versions/{id}: - get: - description: 返回指定配置版本的完整快照与渲染内容,需要管理员权限 - parameters: - - description: 配置版本 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 配置版本详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.ConfigVersion' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或版本不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取配置版本详情 - tags: - - openflare-config-version - /api/v1/d/config-versions/{id}/activate: - post: - description: 将指定历史版本设为当前活跃配置,需要管理员权限 - parameters: - - description: 配置版本 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 激活成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.ConfigVersion' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或版本不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 激活配置版本 - tags: - - openflare-config-version - /api/v1/d/config-versions/active: - get: - description: 返回当前正在使用的配置版本,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 活跃配置版本 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.ConfigVersion' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限、不存在或无活跃版本 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前活跃配置版本 - tags: - - openflare-config-version - /api/v1/d/config-versions/cleanup: - post: - consumes: - - application/json - description: 删除超出保留数量的非活跃配置版本,需要管理员权限 - parameters: - - description: 清理参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/config_version.CleanupInput' - produces: - - application/json - responses: - "200": - description: 清理结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/config_version.CleanupResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 清理历史配置版本 - tags: - - openflare-config-version - /api/v1/d/config-versions/diff: - get: - description: 对比当前草稿配置与活跃版本之间的差异,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 配置差异 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/config_version.ConfigDiffResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 对比草稿与活跃配置 - tags: - - openflare-config-version - /api/v1/d/config-versions/preview: - get: - description: 渲染并返回当前草稿配置的预览结果,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 配置预览 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/config_version.ConfigPreviewResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 预览当前草稿配置 - tags: - - openflare-config-version - /api/v1/d/config-versions/publish: - post: - description: 将当前草稿配置发布为新版本,需要管理员权限 - parameters: - - description: 是否强制发布 - in: query - name: force - type: boolean - produces: - - application/json - responses: - "200": - description: 发布成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.ConfigVersion' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 发布配置版本 - tags: - - openflare-config-version - /api/v1/d/dashboard/overview: - get: - description: 聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 仪表盘概览 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/dashboard.OverviewPayload' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取仪表盘概览 - tags: - - openflare-dashboard - /api/v1/d/dns-accounts: - get: - description: 返回全部 DNS 提供商账号,需要管理员权限 - produces: - - application/json - responses: - "200": - description: DNS 账号列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.DNSAccount' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 DNS 账号 - tags: - - openflare-tls - post: - consumes: - - application/json - description: 创建新的 DNS 提供商账号,需要管理员权限 - parameters: - - description: DNS 账号参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.DNSAccountInput' - produces: - - application/json - responses: - "200": - description: 创建成功的 DNS 账号 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.DNSAccount' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建 DNS 账号 - tags: - - openflare-tls - /api/v1/d/dns-accounts/{id}/delete: - post: - description: 按 ID 删除 DNS 提供商账号,需要管理员权限 - parameters: - - description: DNS 账号 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除 DNS 账号 - tags: - - openflare-tls - /api/v1/d/dns-accounts/{id}/update: - post: - consumes: - - application/json - description: 按 ID 更新 DNS 提供商账号,需要管理员权限 - parameters: - - description: DNS 账号 ID - in: path - name: id - required: true - type: integer - - description: DNS 账号参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.DNSAccountInput' - produces: - - application/json - responses: - "200": - description: 更新后的 DNS 账号 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.DNSAccount' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 DNS 账号 - tags: - - openflare-tls - /api/v1/d/managed-domains: - get: - description: 返回全部托管域名及关联证书,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 托管域名列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.ManagedDomain' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出托管域名 - tags: - - openflare-tls - post: - consumes: - - application/json - description: 创建新的托管域名记录,需要管理员权限 - parameters: - - description: 托管域名参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.ManagedDomainInput' - produces: - - application/json - responses: - "200": - description: 创建成功的托管域名 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.ManagedDomain' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建托管域名 - tags: - - openflare-tls - /api/v1/d/managed-domains/{id}/delete: - post: - description: 按 ID 删除托管域名,需要管理员权限 - parameters: - - description: 托管域名 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除托管域名 - tags: - - openflare-tls - /api/v1/d/managed-domains/{id}/update: - post: - consumes: - - application/json - description: 按 ID 更新托管域名,需要管理员权限 - parameters: - - description: 托管域名 ID - in: path - name: id - required: true - type: integer - - description: 托管域名参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.ManagedDomainInput' - produces: - - application/json - responses: - "200": - description: 更新后的托管域名 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.ManagedDomain' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新托管域名 - tags: - - openflare-tls - /api/v1/d/managed-domains/match: - get: - description: 按域名查询可用的证书匹配候选,需要管理员权限 - parameters: - - description: 域名 - in: query - name: domain - required: true - type: string - produces: - - application/json - responses: - "200": - description: 证书匹配结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/tls.ManagedDomainMatchResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 匹配托管域名证书 - tags: - - openflare-tls - /api/v1/d/nodes: - get: - description: 返回所有节点及最新配置下发记录,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 节点列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/node.View' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取节点列表 - tags: - - openflare-node - post: - consumes: - - application/json - description: 创建新的边缘节点记录,需要管理员权限 - parameters: - - description: 节点参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/node.Input' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建节点 - tags: - - openflare-node - /api/v1/d/nodes/{id}/agent-release: - get: - description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - - description: 发布渠道 - in: query - name: channel - type: string - produces: - - application/json - responses: - "200": - description: Agent 发布信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.AgentReleaseInfo' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取 Agent 发布信息 - tags: - - openflare-node - /api/v1/d/nodes/{id}/agent-update: - post: - consumes: - - application/json - description: 向指定节点下发 Agent 自更新指令,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - - description: 更新参数(可选) - in: body - name: body - schema: - $ref: '#/definitions/node.AgentUpdateInput' - produces: - - application/json - responses: - "200": - description: 更新请求已下发 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 请求 Agent 更新 - tags: - - openflare-node - /api/v1/d/nodes/{id}/delete: - post: - description: 删除指定节点记录,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除节点 - tags: - - openflare-node - /api/v1/d/nodes/{id}/force-sync: - post: - description: 向指定节点下发强制同步当前活跃配置的指令,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 同步请求已下发 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 请求强制同步配置 - tags: - - openflare-node - /api/v1/d/nodes/{id}/observability: - get: - description: 返回指定节点的指标、健康事件与流量分析数据,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - - description: 统计时间范围(小时) - in: query - name: hours - type: integer - - description: 返回记录数量上限 - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: 可观测性数据 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.ObservabilityView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取节点可观测性数据 - tags: - - openflare-node - /api/v1/d/nodes/{id}/observability/cleanup: - post: - description: 清理指定节点的历史健康事件记录,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 清理结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.HealthEventCleanupResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 清理节点健康事件 - tags: - - openflare-node - /api/v1/d/nodes/{id}/openresty-restart: - post: - description: 向指定节点下发 OpenResty 重启指令,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 重启请求已下发 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 请求重启 OpenResty - tags: - - openflare-node - /api/v1/d/nodes/{id}/update: - post: - consumes: - - application/json - description: 更新指定节点的配置信息,需要管理员权限 - parameters: - - description: 节点 ID - in: path - name: id - required: true - type: integer - - description: 节点参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/node.Input' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或节点不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新节点 - tags: - - openflare-node - /api/v1/d/nodes/bootstrap-token: - get: - description: 返回全局节点发现引导令牌,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 引导令牌 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.BootstrapView' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取引导令牌 - tags: - - openflare-node - /api/v1/d/nodes/bootstrap-token/rotate: - post: - description: 重新生成全局节点发现引导令牌,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 新引导令牌 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/node.BootstrapView' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 轮换引导令牌 - tags: - - openflare-node - /api/v1/d/notice: - get: - description: 返回 OpenFlare 控制台公告文本,无需登录 - produces: - - application/json - responses: - "200": - description: 系统公告 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - summary: 获取系统公告 - tags: - - openflare-option - /api/v1/d/option: - get: - description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 配置项列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.OpenFlareOption' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 OpenFlare 配置项 - tags: - - openflare-option - /api/v1/d/option/database/cleanup: - post: - consumes: - - application/json - description: 按目标与保留天数清理可观测性相关数据表,需要管理员权限 - parameters: - - description: 清理参数 - in: body - name: request - schema: - $ref: '#/definitions/option.databaseCleanupInput' - produces: - - application/json - responses: - "200": - description: 清理结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/option.databaseCleanupResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 清理可观测性数据库 - tags: - - openflare-option - /api/v1/d/option/geoip/lookup: - post: - consumes: - - application/json - description: 按提供商与 IP 查询地理位置信息,需要管理员权限 - parameters: - - description: 查询参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/option.geoIPLookupRequest' - produces: - - application/json - responses: - "200": - description: GeoIP 查询结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/option.geoIPLookupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: GeoIP 地址查询 - tags: - - openflare-option - /api/v1/d/option/update: - post: - consumes: - - application/json - description: 更新单个 OpenFlare 配置项,需要管理员权限 - parameters: - - description: 配置项 - in: body - name: request - required: true - schema: - $ref: '#/definitions/model.OpenFlareOption' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 OpenFlare 配置项 - tags: - - openflare-option - /api/v1/d/option/update-batch: - post: - consumes: - - application/json - description: 批量更新多个 OpenFlare 配置项,需要管理员权限 - parameters: - - description: 批量配置项 - in: body - name: request - required: true - schema: - $ref: '#/definitions/option.optionBatchPayload' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 批量更新 OpenFlare 配置项 - tags: - - openflare-option - /api/v1/d/origins: - get: - description: 返回所有源站及关联代理规则数量,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 源站列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/origin.View' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取源站列表 - tags: - - openflare-origin - post: - consumes: - - application/json - description: 创建新的上游源站记录,需要管理员权限 - parameters: - - description: 源站参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/origin.Input' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/origin.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建源站 - tags: - - openflare-origin - /api/v1/d/origins/{id}: - get: - description: 返回指定源站信息及关联代理规则摘要,需要管理员权限 - parameters: - - description: 源站 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 源站详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/origin.DetailView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或源站不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取源站详情 - tags: - - openflare-origin - /api/v1/d/origins/{id}/delete: - post: - description: 删除指定源站记录,需要管理员权限 - parameters: - - description: 源站 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或源站不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除源站 - tags: - - openflare-origin - /api/v1/d/origins/{id}/update: - post: - consumes: - - application/json - description: 更新指定源站的配置信息,需要管理员权限 - parameters: - - description: 源站 ID - in: path - name: id - required: true - type: integer - - description: 源站参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/origin.Input' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/origin.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或源站不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新源站 - tags: - - openflare-origin - /api/v1/d/pages: - get: - description: 返回全部 OpenFlare Pages 项目,需要管理员权限 - produces: - - application/json - responses: - "200": - description: Pages 项目列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/pages.View' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 Pages 项目 - tags: - - openflare-pages - post: - consumes: - - application/json - description: 创建新的 OpenFlare Pages 项目,需要管理员权限 - parameters: - - description: 项目参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/pages.Input' - produces: - - application/json - responses: - "200": - description: 创建成功的项目 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/pages.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建 Pages 项目 - tags: - - openflare-pages - /api/v1/d/pages/{id}: - get: - description: 按 ID 返回 Pages 项目详情,需要管理员权限 - parameters: - - description: 项目 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: Pages 项目详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/pages.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 项目不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取 Pages 项目详情 - tags: - - openflare-pages - /api/v1/d/pages/{id}/delete: - post: - description: 按 ID 删除 OpenFlare Pages 项目,需要管理员权限 - parameters: - - description: 项目 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 项目不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除 Pages 项目 - tags: - - openflare-pages - /api/v1/d/pages/{id}/deployments: - get: - description: 返回指定项目的全部部署记录,需要管理员权限 - parameters: - - description: 项目 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 部署列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/pages.DeploymentView' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 项目不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 Pages 部署 - tags: - - openflare-pages - /api/v1/d/pages/{id}/deployments/{deployment_id}/activate: - post: - description: 将指定部署设为项目当前生效版本,需要管理员权限 - parameters: - - description: 项目 ID - in: path - name: id - required: true - type: integer - - description: 部署 ID - in: path - name: deployment_id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 激活后的项目 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/pages.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 项目或部署不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 激活 Pages 部署 - tags: - - openflare-pages - /api/v1/d/pages/{id}/deployments/{deployment_id}/delete: - post: - description: 删除指定项目的部署记录,需要管理员权限 - parameters: - - description: 项目 ID - in: path - name: id - required: true - type: integer - - description: 部署 ID - in: path - name: deployment_id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 项目或部署不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除 Pages 部署 - tags: - - openflare-pages - /api/v1/d/pages/{id}/deployments/upload: - post: - consumes: - - multipart/form-data - description: 为指定项目上传 ZIP 部署包,需要管理员权限 - parameters: - - description: 项目 ID - in: path - name: id - required: true - type: integer - - description: 部署包 ZIP 文件 - in: formData - name: package - required: true - type: file - produces: - - application/json - responses: - "200": - description: 部署记录 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/pages.DeploymentView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 项目不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 上传 Pages 部署包 - tags: - - openflare-pages - /api/v1/d/pages/{id}/update: - post: - consumes: - - application/json - description: 按 ID 更新 OpenFlare Pages 项目,需要管理员权限 - parameters: - - description: 项目 ID - in: path - name: id - required: true - type: integer - - description: 项目参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/pages.Input' - produces: - - application/json - responses: - "200": - description: 更新后的项目 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/pages.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 项目不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 Pages 项目 - tags: - - openflare-pages - /api/v1/d/pages/deployments/{deployment_id}/files: - get: - description: 返回指定部署包含的文件清单,需要管理员权限 - parameters: - - description: 部署 ID - in: path - name: deployment_id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 部署文件列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/pages.DeploymentFileView' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 部署不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 Pages 部署文件 - tags: - - openflare-pages - /api/v1/d/proxy-routes: - get: - description: 返回所有代理规则配置,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 代理规则列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/proxy_route.View' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取代理规则列表 - tags: - - openflare-proxy-route - post: - consumes: - - application/json - description: 创建新的反向代理规则,需要管理员权限 - parameters: - - description: 代理规则参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/proxy_route.Input' - produces: - - application/json - responses: - "200": - description: 创建成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/proxy_route.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建代理规则 - tags: - - openflare-proxy-route - /api/v1/d/proxy-routes/{id}: - get: - description: 返回指定代理规则的完整配置,需要管理员权限 - parameters: - - description: 代理规则 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 代理规则详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/proxy_route.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或规则不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取代理规则详情 - tags: - - openflare-proxy-route - /api/v1/d/proxy-routes/{id}/delete: - post: - description: 删除指定代理规则,需要管理员权限 - parameters: - - description: 代理规则 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或规则不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除代理规则 - tags: - - openflare-proxy-route - /api/v1/d/proxy-routes/{id}/update: - post: - consumes: - - application/json - description: 更新指定代理规则的配置,需要管理员权限 - parameters: - - description: 代理规则 ID - in: path - name: id - required: true - type: integer - - description: 代理规则参数 - in: body - name: body - required: true - schema: - $ref: '#/definitions/proxy_route.Input' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/proxy_route.View' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 无权限或规则不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新代理规则 - tags: - - openflare-proxy-route - /api/v1/d/status: - get: - description: 返回版本、认证源与系统公开配置,无需登录 - produces: - - application/json - responses: - "200": - description: 公开状态 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/option.statusView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - summary: 获取 OpenFlare 公开状态 - tags: - - openflare-option - /api/v1/d/tls-certificates: - get: - description: 返回全部 TLS 证书(不含 PEM),需要管理员权限 - produces: - - application/json - responses: - "200": - description: 证书列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.TLSCertificate' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 TLS 证书 - tags: - - openflare-tls - post: - consumes: - - application/json - description: 从 PEM 文本创建 TLS 证书,需要管理员权限 - parameters: - - description: 证书参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.CertificateInput' - produces: - - application/json - responses: - "200": - description: 创建成功的证书 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建 TLS 证书 - tags: - - openflare-tls - /api/v1/d/tls-certificates/{id}: - get: - description: 按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限 - parameters: - - description: 证书 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 证书详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取 TLS 证书详情 - tags: - - openflare-tls - /api/v1/d/tls-certificates/{id}/content: - get: - description: 按 ID 返回证书与私钥 PEM 内容,需要管理员权限 - parameters: - - description: 证书 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 证书 PEM 内容 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/tls.CertificateContent' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取 TLS 证书 PEM 内容 - tags: - - openflare-tls - /api/v1/d/tls-certificates/{id}/convert-acme: - post: - consumes: - - application/json - description: 将已上传证书转换为 ACME 自动续期模式,需要管理员权限 - parameters: - - description: 证书 ID - in: path - name: id - required: true - type: integer - - description: ACME 申请参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.ApplyInput' - produces: - - application/json - responses: - "200": - description: 转换后的证书 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 将证书转为 ACME 管理 - tags: - - openflare-tls - /api/v1/d/tls-certificates/{id}/delete: - post: - description: 按 ID 删除 TLS 证书,需要管理员权限 - parameters: - - description: 证书 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除 TLS 证书 - tags: - - openflare-tls - /api/v1/d/tls-certificates/{id}/renew: - post: - description: 手动触发 ACME 证书续期,需要管理员权限 - parameters: - - description: 证书 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 续期后的证书 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 续期 ACME 证书 - tags: - - openflare-tls - /api/v1/d/tls-certificates/{id}/update: - post: - consumes: - - application/json - description: 按 ID 更新 TLS 证书 PEM 信息,需要管理员权限 - parameters: - - description: 证书 ID - in: path - name: id - required: true - type: integer - - description: 证书参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.CertificateInput' - produces: - - application/json - responses: - "200": - description: 更新后的证书 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 TLS 证书 - tags: - - openflare-tls - /api/v1/d/tls-certificates/{id}/update-acme: - post: - consumes: - - application/json - description: 按 ID 更新 ACME 证书申请配置,需要管理员权限 - parameters: - - description: 证书 ID - in: path - name: id - required: true - type: integer - - description: ACME 申请参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.ApplyInput' - produces: - - application/json - responses: - "200": - description: 更新后的证书 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 ACME 证书配置 - tags: - - openflare-tls - /api/v1/d/tls-certificates/apply: - post: - consumes: - - application/json - description: 通过 ACME 申请新的 TLS 证书,需要管理员权限 - parameters: - - description: ACME 申请参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/tls.ApplyInput' - produces: - - application/json - responses: - "200": - description: 申请中的证书 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 申请 ACME 证书 - tags: - - openflare-tls - /api/v1/d/tls-certificates/import-file: - post: - consumes: - - multipart/form-data - description: 上传证书与私钥文件创建 TLS 证书,需要管理员权限 - parameters: - - description: 证书名称 - in: formData - name: name - type: string - - description: 备注 - in: formData - name: remark - type: string - - description: 证书文件 - in: formData - name: cert_file - required: true - type: file - - description: 私钥文件 - in: formData - name: key_file - required: true - type: file - produces: - - application/json - responses: - "200": - description: 导入成功的证书 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.TLSCertificate' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 从文件导入 TLS 证书 - tags: - - openflare-tls - /api/v1/d/uptimekuma/sync: - post: - consumes: - - application/json - description: 将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 同步成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 同步 Uptime Kuma - tags: - - openflare-option - /api/v1/d/waf/ip-groups: - get: - description: 返回全部 WAF IP 组,需要管理员权限 - produces: - - application/json - responses: - "200": - description: IP 组列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/waf.IPGroupView' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 WAF IP 组 - tags: - - openflare-waf - post: - consumes: - - application/json - description: 创建新的 WAF IP 组,需要管理员权限 - parameters: - - description: IP 组参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.IPGroupInput' - produces: - - application/json - responses: - "200": - description: 创建成功的 IP 组 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.IPGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建 WAF IP 组 - tags: - - openflare-waf - /api/v1/d/waf/ip-groups/{id}: - get: - description: 按 ID 返回 WAF IP 组详情,需要管理员权限 - parameters: - - description: IP 组 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: IP 组详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.IPGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取 WAF IP 组详情 - tags: - - openflare-waf - /api/v1/d/waf/ip-groups/{id}/delete: - post: - description: 按 ID 删除 WAF IP 组,需要管理员权限 - parameters: - - description: IP 组 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除 WAF IP 组 - tags: - - openflare-waf - /api/v1/d/waf/ip-groups/{id}/sync: - post: - description: 手动触发 WAF IP 组外部 IP 同步,需要管理员权限 - parameters: - - description: IP 组 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 同步结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.IPGroupSyncResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 同步 WAF IP 组 - tags: - - openflare-waf - /api/v1/d/waf/ip-groups/{id}/update: - post: - consumes: - - application/json - description: 按 ID 更新 WAF IP 组,需要管理员权限 - parameters: - - description: IP 组 ID - in: path - name: id - required: true - type: integer - - description: IP 组参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.IPGroupInput' - produces: - - application/json - responses: - "200": - description: 更新后的 IP 组 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.IPGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 WAF IP 组 - tags: - - openflare-waf - /api/v1/d/waf/ip-groups/test: - post: - consumes: - - application/json - description: 根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限 - parameters: - - description: 自动配置参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.IPGroupAutoTestInput' - produces: - - application/json - responses: - "200": - description: 测试结果 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.IPGroupAutoTestResult' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 测试 WAF IP 组自动配置 - tags: - - openflare-waf - /api/v1/d/waf/rule-groups: - get: - description: 返回全部 WAF 规则组,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 规则组列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/waf.RuleGroupView' - type: array - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 列出 WAF 规则组 - tags: - - openflare-waf - post: - consumes: - - application/json - description: 创建新的 WAF 规则组,需要管理员权限 - parameters: - - description: 规则组参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.RuleGroupInput' - produces: - - application/json - responses: - "200": - description: 创建成功的规则组 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.RuleGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建 WAF 规则组 - tags: - - openflare-waf - /api/v1/d/waf/rule-groups/{id}: - get: - description: 按 ID 返回 WAF 规则组详情,需要管理员权限 - parameters: - - description: 规则组 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 规则组详情 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.RuleGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取 WAF 规则组详情 - tags: - - openflare-waf - /api/v1/d/waf/rule-groups/{id}/delete: - post: - description: 按 ID 删除 WAF 规则组,需要管理员权限 - parameters: - - description: 规则组 ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除 WAF 规则组 - tags: - - openflare-waf - /api/v1/d/waf/rule-groups/{id}/sites: - post: - consumes: - - application/json - description: 替换 WAF 规则组关联的代理站点列表,需要管理员权限 - parameters: - - description: 规则组 ID - in: path - name: id - required: true - type: integer - - description: 站点 ID 列表 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.IDsRequest' - produces: - - application/json - responses: - "200": - description: 更新后的规则组 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.RuleGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 替换规则组站点绑定 - tags: - - openflare-waf - /api/v1/d/waf/rule-groups/{id}/update: - post: - consumes: - - application/json - description: 按 ID 更新 WAF 规则组,需要管理员权限 - parameters: - - description: 规则组 ID - in: path - name: id - required: true - type: integer - - description: 规则组参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.RuleGroupInput' - produces: - - application/json - responses: - "200": - description: 更新后的规则组 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.RuleGroupView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新 WAF 规则组 - tags: - - openflare-waf - /api/v1/d/waf/sites/{route_id}/rule-groups: - get: - description: 返回代理站点关联的 WAF 规则组绑定,需要管理员权限 - parameters: - - description: 代理路由 ID - in: path - name: route_id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 站点规则组绑定 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.SiteRuleGroupsView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取站点 WAF 规则组 - tags: - - openflare-waf - post: - consumes: - - application/json - description: 替换代理站点关联的 WAF 规则组列表,需要管理员权限 - parameters: - - description: 代理路由 ID - in: path - name: route_id - required: true - type: integer - - description: 规则组 ID 列表 - in: body - name: request - required: true - schema: - $ref: '#/definitions/waf.IDsRequest' - produces: - - application/json - responses: - "200": - description: 更新后的站点规则组绑定 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/waf.SiteRuleGroupsView' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 记录不存在 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 替换站点 WAF 规则组 - tags: - - openflare-waf - /api/v1/oauth/{source}/authorize: - get: - description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 - parameters: - - description: 认证源名称 - in: path - name: source - required: true - type: string - - description: 授权目的:login(登录)或 bind(绑定账号),默认 login - in: query - name: purpose - type: string - produces: - - application/json - responses: - "200": - description: 授权 URL - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.OAuthAuthorizeResponse' - type: object - "400": - description: 认证源不存在或未启用 - schema: - $ref: '#/definitions/response.Any' - "500": - description: Redis 异常或构造 URL 失败 - schema: - $ref: '#/definitions/response.Any' - summary: 发起指定认证源授权 - tags: - - oauth - /api/v1/oauth/callback: - post: - consumes: - - application/json - description: 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。 - parameters: - - description: 回调请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/oauth.CallbackRequest' - produces: - - application/json - responses: - "200": - description: 登录或绑定成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.OAuthCallbackResult' - type: object - "400": - description: state 无效、参数错误或认证源错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 绑定场景未登录 - schema: - $ref: '#/definitions/response.Any' - "500": - description: OAuth 认证失败或内部错误 - schema: - $ref: '#/definitions/response.Any' - summary: OAuth 回调处理 - tags: - - oauth - /api/v1/oauth/external-accounts: - get: - description: 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录 - produces: - - application/json - responses: - "200": - description: 外部帐号列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.ExternalAccountView' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取外部帐号列表 - tags: - - oauth - /api/v1/oauth/external-accounts/{id}/delete: - post: - description: 解除当前登录用户与指定外部帐号的绑定关系,需要登录 - parameters: - - description: 外部帐号绑定记录 ID - format: int64 - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: 解除绑定成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: ID 无效或解除失败 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 解除外部帐号绑定 - tags: - - oauth - /api/v1/oauth/login: - get: - description: 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。 - parameters: - - description: 认证源名称,为空使用第一个启用的认证源 - in: query - name: source - type: string - produces: - - application/json - responses: - "200": - description: 授权 URL - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.OAuthAuthorizeResponse' - type: object - "400": - description: 认证源不存在或未配置 - schema: - $ref: '#/definitions/response.Any' - "500": - description: Redis 异常 or 构造 URL 失败 - schema: - $ref: '#/definitions/response.Any' - summary: 获取登录授权地址 - tags: - - oauth - /api/v1/oauth/logout: - get: - description: 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。 - produces: - - application/json - responses: - "200": - description: 退出成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "500": - description: Session 清除失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 退出登录 - tags: - - oauth - /api/v1/oauth/sources: - get: - description: 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用 - produces: - - application/json - responses: - "200": - description: 登录源列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/oauth.AuthSourceView' - type: array - type: object - summary: 获取可用登录源 - tags: - - oauth - /api/v1/oauth/user-info: - get: - description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 - produces: - - application/json - responses: - "200": - description: 用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前登录用户信息 - tags: - - oauth - /api/v1/upload: - post: - consumes: - - multipart/form-data - description: 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重 - parameters: - - description: 要上传的文件 - in: formData - name: file - required: true - type: file - - description: '业务分类 (例如: avatar, attachment, doc,默认为 generic)' - in: formData - name: type - type: string - - description: 额外的 JSON 格式元数据 - in: formData - name: metadata - type: string - produces: - - application/json - responses: - "200": - description: 上传成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.Upload' - type: object - "400": - description: 请求参数错误或文件受限 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 上传文件 - tags: - - upload - /api/v1/upload/{id}: - delete: - description: 将当前用户本人的文件状态置为 deleted(软删除) - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无权操作 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 文件不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除我的文件 - tags: - - upload - put: - consumes: - - application/json - description: 更新当前用户本人的文件名或访问权限模式 (AccessMode) - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - - description: 更新字段 - in: body - name: request - required: true - schema: - $ref: '#/definitions/handler.updateMyFileRequest' - produces: - - application/json - responses: - "200": - description: 更新成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.Upload' - type: object - "403": - description: 无权操作 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 文件不存在 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 更新我的文件信息 - tags: - - upload - /api/v1/upload/my: - get: - description: 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤 - parameters: - - description: 页码(默认 1) - in: query - name: page - type: integer - - description: 每页数量(默认 20,最大 100) - in: query - name: page_size - type: integer - - description: 文件名关键词(模糊匹配) - in: query - name: keyword - type: string - - description: 业务分类过滤 - in: query - name: type - type: string - - description: 扩展名过滤 - in: query - name: extension - type: string - produces: - - application/json - responses: - "200": - description: 查询成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/handler.listMyFilesResponse' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取我的文件列表 - tags: - - upload - /api/v1/user-info: - get: - description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 - produces: - - application/json - responses: - "200": - description: 用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前登录用户信息 - tags: - - oauth - /api/v1/user/access-tokens: - get: - description: 返回当前登录用户的所有 active access tokens(脱敏后) - produces: - - application/json - responses: - "200": - description: 令牌列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.AccessToken' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前用户的 AccessToken 列表 - tags: - - user - post: - consumes: - - application/json - description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。 - parameters: - - description: 令牌名称 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.createTokenRequest' - produces: - - application/json - responses: - "200": - description: 新建令牌成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/user.tokenResponse' - type: object - "400": - description: 参数错误或超限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 创建一个新的 AccessToken - tags: - - user - /api/v1/user/access-tokens/{id}: - delete: - description: 撤销并删除一个属于当前用户的 API 访问令牌 - parameters: - - description: 令牌ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 删除一个 AccessToken - tags: - - user - /api/v1/user/access-tokens/{id}/rotate: - post: - description: 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效 - parameters: - - description: 令牌ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: 令牌轮换成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/user.tokenResponse' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 轮换一个 AccessToken - tags: - - user - /api/v1/user/change-password: - post: - consumes: - - application/json - description: 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。 - parameters: - - description: 修改密码请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.changePasswordRequest' - produces: - - application/json - responses: - "200": - description: 修改密码成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "400": - description: 原密码错误或新密码不符合要求 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 请先登录 - schema: - $ref: '#/definitions/response.Any' - summary: 修改用户密码 - tags: - - user - /api/v1/user/login: - post: - consumes: - - application/json - description: 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 - parameters: - - description: 登录请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.loginRequest' - produces: - - application/json - responses: - "200": - description: 登录成功,返回用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "400": - description: 用户名或密码错误、帐号已禁用等 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/response.Any' - summary: 用户密码登录 - tags: - - user - /api/v1/user/logout: - get: - description: 清除用户登录 Session,完成退出 - produces: - - application/json - responses: - "200": - description: 退出成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - "500": - description: Session 清除失败 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 用户退出登录 - tags: - - user - /api/v1/user/profile: - put: - consumes: - - application/json - description: 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。 - parameters: - - description: 更新请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.updateProfileRequest' - produces: - - application/json - responses: - "200": - description: 修改成功,返回更新后的用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "400": - description: 邮箱已被占用或参数错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - summary: 修改当前登录用户的个人资料 - tags: - - user - /api/v1/user/register: - post: - consumes: - - application/json - description: 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。 - parameters: - - description: 注册请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.registerRequest' - produces: - - application/json - responses: - "200": - description: 注册并登录成功,返回用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "400": - description: 参数错误、用户名已存在或注册已关闭 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/response.Any' - summary: 用户注册 - tags: - - user - /api/v1/user/self: - get: - description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 - produces: - - application/json - responses: - "200": - description: 用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前登录用户信息 - tags: - - oauth - /api/v1/user/send-email-code: - post: - consumes: - - application/json - description: 向指定邮箱发送验证码(用于注册场景) - parameters: - - description: 发送验证码请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.sendEmailCodeRequest' - produces: - - application/json - responses: - "200": - description: 发送成功 - schema: - $ref: '#/definitions/response.Any' - "400": - description: 参数错误 - schema: - $ref: '#/definitions/response.Any' - summary: 发送邮箱验证码 - tags: - - user - /f/{id}: - get: - description: 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回 - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - - description: 图片质量 (low, medium, high, origin),默认为 origin - in: query - name: quality - type: string - produces: - - application/octet-stream - responses: - "200": - description: 成功获取文件内容 - schema: - type: file - "400": - description: 文件 ID 格式错误 - schema: - $ref: '#/definitions/response.Any' - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "404": - description: 文件未找到 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/response.Any' - summary: 获取已上传文件 - tags: - - upload - /robots.txt: - get: - description: 根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容 - produces: - - text/plain - responses: - "200": - description: robots.txt 内容 - schema: - type: string - summary: 获取 robots.txt - tags: - - config -securityDefinitions: - SessionCookie: - in: cookie - name: session - type: apiKey -swagger: "2.0" diff --git a/Wavelet/internal/common/constants.go b/Wavelet/internal/common/constants.go deleted file mode 100644 index f317898a..00000000 --- a/Wavelet/internal/common/constants.go +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package common 提供跨模块共享的常量、错误定义和通用工具函数。 -package common diff --git a/Wavelet/internal/common/response/response.go b/Wavelet/internal/common/response/response.go deleted file mode 100644 index 14e32b0f..00000000 --- a/Wavelet/internal/common/response/response.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package response provides shared HTTP API response structures. -package response - -import "github.com/gin-gonic/gin" - -// Response 通用响应体 -type Response[T any] struct { - ErrorMsg string `json:"error_msg"` - Data T `json:"data"` -} - -// Any 用于 Swagger 文档的响应类型(非泛型) -// swag 不支持泛型,使用此类型替代 Response[T] -type Any struct { - ErrorMsg string `json:"error_msg" example:""` - Data interface{} `json:"data"` -} - -// APIError 统一的 API 业务错误类型,可被全局错误处理中间件捕获 -type APIError struct { - Code int - Msg string -} - -func (e *APIError) Error() string { - return e.Msg -} - -// NewError 实例化一个 APIError -func NewError(code int, msg string) *APIError { - return &APIError{Code: code, Msg: msg} -} - -// AbortWithError 将 API 错误挂载到 Gin Context 并中断执行流 -func AbortWithError(c *gin.Context, code int, msg string) { - _ = c.Error(NewError(code, msg)) - c.Abort() -} - -// OK 构造成功响应 -func OK[T any](data T) Response[T] { - return Response[T]{Data: data} -} - -// OKNil 构造成功响应(data 为 null) -func OKNil() Response[any] { - return Response[any]{Data: nil} -} - -// Err 构造错误响应 -func Err(msg string) Response[any] { - return Response[any]{ErrorMsg: msg, Data: nil} -} diff --git a/Wavelet/internal/model/auth_source.go b/Wavelet/internal/model/auth_source.go deleted file mode 100644 index d1d65d74..00000000 --- a/Wavelet/internal/model/auth_source.go +++ /dev/null @@ -1,318 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "context" - "errors" - "regexp" - "strings" - "time" - - "github.com/Rain-kl/Wavelet/internal/db" - "gorm.io/gorm" -) - -// 认证源类型 -const ( - AuthSourceTypeOIDC = "oidc" -) - -var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`) - -// AuthSource 认证源实体 -type AuthSource struct { - ID uint64 `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` - Type string `json:"type" gorm:"size:20;not null"` - DisplayName string `json:"display_name" gorm:"size:100"` - IsActive bool `json:"is_active" gorm:"index;not null;default:false"` - ClientID string `json:"client_id" gorm:"size:255"` - ClientSecret string `json:"-" gorm:"size:1024"` - OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"` - Scopes string `json:"scopes" gorm:"size:255"` - IconURL string `json:"icon_url" gorm:"size:1024"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"` -} - -// TableName 表名 -func (AuthSource) TableName() string { - return "w_auth_sources" -} - -// ExternalAccount 外部账号绑定实体 -type ExternalAccount struct { - ID uint64 `json:"id" gorm:"primaryKey"` - AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"` - UserID uint64 `json:"user_id" gorm:"index;not null"` - ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"` - ExternalUsername string `json:"external_username" gorm:"size:255"` - Email string `json:"email" gorm:"size:255"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TableName 表名 -func (ExternalAccount) TableName() string { - return "w_external_accounts" -} - -// ExternalAccountView 外部帐号绑定视图(脱敏展示用) -type ExternalAccountView struct { - ID uint64 `json:"id"` - AuthSourceID uint64 `json:"auth_source_id"` - AuthSourceName string `json:"auth_source_name"` - AuthSourceType string `json:"auth_source_type"` - AuthSourceLabel string `json:"auth_source_label"` - ExternalUsername string `json:"external_username"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` -} - -// Normalize 对认证源字段进行标准化处理 -func (source *AuthSource) Normalize() { - source.Type = strings.ToLower(strings.TrimSpace(source.Type)) - source.Name = strings.TrimSpace(source.Name) - source.DisplayName = strings.TrimSpace(source.DisplayName) - source.ClientID = strings.TrimSpace(source.ClientID) - source.ClientSecret = strings.TrimSpace(source.ClientSecret) - source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL) - source.Scopes = strings.TrimSpace(source.Scopes) - source.IconURL = strings.TrimSpace(source.IconURL) - if source.DisplayName == "" { - source.DisplayName = source.Name - } - if source.Type == AuthSourceTypeOIDC && source.Scopes == "" { - source.Scopes = "openid profile email" - } -} - -// Validate 校验认证源字段合法性 -func (source *AuthSource) Validate() error { - source.Normalize() - if source.Name == "" { - return errors.New(errAuthSourceNameRequired) - } - if !authSourceNamePattern.MatchString(source.Name) { - return errors.New(errAuthSourceNameInvalid) - } - if source.Type != AuthSourceTypeOIDC { - return errors.New(errAuthSourceTypeUnsupported) - } - if source.OpenIDDiscoveryURL == "" { - return errors.New(errAuthSourceDiscoveryURLRequired) - } - if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { - return errors.New(errAuthSourceClientCredentialsRequired) - } - return nil -} - -// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志 -func (source *AuthSource) Sanitize() { - source.ClientSecretConfigured = source.ClientSecret != "" - source.ClientSecret = "" -} - -// GetAuthSources 获取所有认证源(已脱敏) -func GetAuthSources(ctx context.Context) ([]AuthSource, error) { - var sources []AuthSource - if err := db.DB(ctx).Order("id asc").Find(&sources).Error; err != nil { - return nil, err - } - for i := range sources { - sources[i].Sanitize() - } - return sources, nil -} - -// GetActiveAuthSources 获取所有已启用的认证源(已脱敏) -func GetActiveAuthSources(ctx context.Context) ([]AuthSource, error) { - var sources []AuthSource - if err := db.DB(ctx).Where("is_active = ?", true).Order("id asc").Find(&sources).Error; err != nil { - return nil, err - } - for i := range sources { - sources[i].Sanitize() - } - return sources, nil -} - -// GetAuthSourceByID 根据 ID 获取认证源 -func GetAuthSourceByID(ctx context.Context, id uint64) (*AuthSource, error) { - if id == 0 { - return nil, errors.New(errAuthSourceIDRequired) - } - var source AuthSource - if err := db.DB(ctx).First(&source, "id = ?", id).Error; err != nil { - return nil, err - } - source.ClientSecretConfigured = source.ClientSecret != "" - return &source, nil -} - -// GetAuthSourceByName 根据名称获取认证源(名称比较不区分大小写) -func GetAuthSourceByName(ctx context.Context, name string) (*AuthSource, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, errors.New(errAuthSourceNameRequired) - } - var source AuthSource - if err := db.DB(ctx).First(&source, "LOWER(name) = LOWER(?)", name).Error; err != nil { - return nil, err - } - source.ClientSecretConfigured = source.ClientSecret != "" - return &source, nil -} - -// CreateAuthSource 创建认证源 -func CreateAuthSource(ctx context.Context, source *AuthSource) error { - if err := source.Validate(); err != nil { - return err - } - return db.DB(ctx).Create(source).Error -} - -// UpdateAuthSource 更新认证源,keepSecret 为 true 时保留原密钥 -func UpdateAuthSource(ctx context.Context, source *AuthSource, keepSecret bool) error { - if source.ID == 0 { - return errors.New(errAuthSourceIDRequired) - } - var current AuthSource - if err := db.DB(ctx).First(¤t, "id = ?", source.ID).Error; err != nil { - return err - } - if keepSecret { - source.ClientSecret = current.ClientSecret - } - if err := source.Validate(); err != nil { - return err - } - return db.DB(ctx).Model(¤t).Updates(map[string]any{ - "name": source.Name, - "type": source.Type, - "display_name": source.DisplayName, - "is_active": source.IsActive, - "client_id": source.ClientID, - "client_secret": source.ClientSecret, - "openid_discovery_url": source.OpenIDDiscoveryURL, - "scopes": source.Scopes, - "icon_url": source.IconURL, - }).Error -} - -// ToggleAuthSource 切换认证源启用状态 -func ToggleAuthSource(ctx context.Context, id uint64, isActive bool) error { - source, err := GetAuthSourceByID(ctx, id) - if err != nil { - return err - } - source.IsActive = isActive - if err := source.Validate(); err != nil { - return err - } - return db.DB(ctx).Model(&AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error -} - -// DeleteAuthSource 删除认证源及其关联的外部帐号绑定 -func DeleteAuthSource(ctx context.Context, id uint64) error { - if id == 0 { - return errors.New(errAuthSourceIDRequired) - } - return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil { - return err - } - return tx.Delete(&AuthSource{}, "id = ?", id).Error - }) -} - -// FindExternalAccount 查找外部帐号绑定记录 -func FindExternalAccount(ctx context.Context, sourceID uint64, externalID string) (*ExternalAccount, error) { - var account ExternalAccount - if err := db.DB(ctx).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil { - return nil, err - } - return &account, nil -} - -// BindExternalAccount 绑定外部帐号(已存在时更新用户名和邮箱) -func BindExternalAccount(ctx context.Context, account *ExternalAccount) error { - if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { - return errors.New(errExternalAccountBindingIncomplete) - } - account.ExternalID = strings.TrimSpace(account.ExternalID) - account.ExternalUsername = strings.TrimSpace(account.ExternalUsername) - account.Email = strings.TrimSpace(account.Email) - - return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - var current ExternalAccount - err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(¤t).Error - if err == nil { - if current.UserID != account.UserID { - return errors.New(errExternalAccountAlreadyBoundToAnother) - } - return tx.Model(¤t).Updates(map[string]any{ - "external_username": account.ExternalUsername, - "email": account.Email, - }).Error - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - return tx.Create(account).Error - }) -} - -// ListExternalAccountsByUserID 获取指定用户的所有外部帐号绑定视图 -func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]ExternalAccountView, error) { - if userID == 0 { - return nil, errors.New(errUserIDRequired) - } - var accounts []ExternalAccount - if err := db.DB(ctx).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil { - return nil, err - } - views := make([]ExternalAccountView, 0, len(accounts)) - for _, account := range accounts { - var name, sourceType, label string - if account.AuthSourceID == 0 { - name = "default" - sourceType = "oidc" - label = "历史认证源" - } else { - source, err := GetAuthSourceByID(ctx, account.AuthSourceID) - if err != nil { - continue - } - name = source.Name - sourceType = source.Type - label = source.DisplayName - if label == "" { - label = source.Name - } - } - views = append(views, ExternalAccountView{ - ID: account.ID, - AuthSourceID: account.AuthSourceID, - AuthSourceName: name, - AuthSourceType: sourceType, - AuthSourceLabel: label, - ExternalUsername: account.ExternalUsername, - Email: account.Email, - CreatedAt: account.CreatedAt, - }) - } - return views, nil -} - -// DeleteExternalAccountForUser 删除指定用户的外部帐号绑定 -func DeleteExternalAccountForUser(ctx context.Context, id uint64, userID uint64) error { - if id == 0 || userID == 0 { - return errors.New(errExternalAccountBindingIDRequired) - } - return db.DB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error -} diff --git a/Wavelet/main.go b/Wavelet/main.go deleted file mode 100644 index deddacd5..00000000 --- a/Wavelet/main.go +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package main 是 OpenFlare 平台的程序入口 -package main - -import "github.com/Rain-kl/Wavelet/internal/cmd" - -// @title OpenFlare API -// @version 1.0.0 -// @description OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。 -// @contact.name OpenFlare -// @contact.url https://github.com/Rain-kl/OpenFlare -// @license.name Apache 2.0 -// @license.url http://www.apache.org/licenses/LICENSE-2.0.html -// @BasePath / -// @securityDefinitions.apikey SessionCookie -// @in cookie -// @name session -func main() { - cmd.Execute() -} diff --git a/docs/changelog/index.md b/docs/changelog/index.md index a2fbaafc..724f3b19 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -18,6 +18,7 @@ sidebar: false ### 移除 +- 删除旧版 `openflare-server/` 单体实现(含 `web/` 前端);仓库内 `openflare-server/` 现指迁移后的 Wavelet 统一控制面。 - 移除 Wavelet About 界面相关代码:删除 `AboutService` 与 `GET /api/v1/d/about` 接口(页面从未实现,属冗余遗留)。 - 移除 Wavelet 中从旧系统迁移但未实装的全局 API / Web / 敏感接口限流选项(`GlobalApiRateLimit*`、`GlobalWebRateLimit*`、`CriticalRateLimit*`)及相关校验与数据库种子。 - 移除 `internal/apps/openflare/legacy/` 控制台兼容层、`compat/auth.go`(`OpenFlare-Token` JWT 桥接)及前端 `legacy-base.service.ts`;`openflare-server/web` 不再能对接当前 Wavelet 后端管理 API。 @@ -72,6 +73,7 @@ sidebar: false ### 变更 +- 将 `Wavelet/` 目录重命名为 `openflare-server/`,完成 OpenFlare 后端与前端迁移后的仓库结构收敛;同步更新 Server Docker/Release 工作流与部署文档中的路径(`frontend/`、`docker/Dockerfile`)。 - Wavelet API 路径统一:管理端由 `/api/v1/openflare/*` 调整为 `/api/v1/d/*`;Agent/Relay/Tunnel 协议路由分别迁移至 `/api/v1/agent/*`、`/api/v1/relay/*`、`/api/v1/tunnel/*`(原 `/api/flared/*`)。同步更新 Wavelet 前端服务层与 `openflare-agent`、`openflare-relay`、`openflared` 客户端连接端点。 - Agent/Relay/Tunnel 协议 API 响应格式对齐 Wavelet `{error_msg, data}`:服务端移除 `compat` 包,业务错误改为 HTTP 4xx + `error_msg`;同步更新 `openflare-agent`、`openflare-relay`、`openflared` HTTP 客户端解析逻辑。 - OpenFlare 管理端权限模型对齐 Wavelet:取消旧系统 Admin/Root 三级角色区分,统一以 `user.IsAdmin` 为管理门槛;Access Token 访问敏感接口(Option、Update 等)须 `token_admin=true`;权限不足返回 HTTP 404 + `error_msg`,参数错误返回 HTTP 400 + `error_msg`(`apiutil.AdminMiddlewares` = `oauth.LoginRequired` + `admin.LoginAdminRequired`)。 diff --git a/docs/deployment/server.md b/docs/deployment/server.md index 7bfa4b67..fb288b32 100644 --- a/docs/deployment/server.md +++ b/docs/deployment/server.md @@ -4,8 +4,6 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询。 -> **迁移说明**:后端业务域正在迁入 [Wavelet](../plan/20260618-openflare-wavelet-backend-migration.md)。阶段一通过 Wavelet 的 `/api/*` legacy 兼容层联调旧前端;下文「Wavelet 后端」一节描述新后端的启动方式。 - ## 前置条件 | 项目 | 要求 | @@ -15,17 +13,17 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 AP | pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm | | 数据库 | SQLite 文件目录可写,或可访问的 PostgreSQL 实例 | -生产环境必须显式配置 `JWT_SECRET`,并优先使用 PostgreSQL。 +生产环境必须配置 `session_secret`(或 `SESSION_SECRET`),并优先使用 PostgreSQL 与 Redis。 ## 构建管理端前端 -Go Server 会托管 `openflare-server/web/build` 中的静态产物。源码启动前先构建前端: +Go Server 会嵌入 `openflare-server/frontend/out` 静态产物。源码启动前先构建前端: ```bash -cd openflare-server/web +cd openflare-server/frontend corepack enable pnpm install -pnpm build +pnpm build:embed ``` 常用前端检查: @@ -40,10 +38,9 @@ pnpm test ```bash cd openflare-server -export JWT_SECRET='replace-with-a-long-random-string' -export SQLITE_PATH='./openflare.db' -export LOG_LEVEL='info' -go run . +cp config.example.yaml config.yaml +# 编辑 config.yaml:设置 session_secret,并将 database.enabled 设为 false +go run . all ``` 默认监听 `3000` 端口,访问: @@ -56,15 +53,12 @@ http://localhost:3000 ```bash cd openflare-server -export JWT_SECRET='replace-with-a-long-random-string' -export DSN='postgres://openflare:secret@127.0.0.1:5432/openflare?sslmode=disable' -export LOG_LEVEL='info' -go run . +cp config.example.yaml config.yaml +# 编辑 config.yaml:设置 session_secret、database.* 与 redis.* +go run . all ``` -`DSN` 设置后优先于 SQLite。`DSN` 与兼容旧命名的 `SQL_DSN` 同时存在时,优先使用 `DSN`。 - -如果目标 PostgreSQL 数据库为空且本地 `SQLITE_PATH` 文件存在,Server 启动阶段会尝试把 SQLite 数据迁移到 PostgreSQL,并在日志中输出迁移进度。 +生产环境推荐分进程部署:`go run . api`、`go run . worker`、`go run . scheduler`。 ## 使用 Docker 启动 @@ -173,13 +167,9 @@ go run . --port 3000 --log-dir ./logs 首次登录后请立即修改默认密码。 -## Wavelet 后端(迁移中) +## 配置要点 -阶段一将 OpenFlare 业务 API 运行在 Wavelet 框架内(`Wavelet/internal/apps/openflare/`),默认监听 `:3000`,与旧 Server 端口一致。旧前端仍使用 `openflare-server/web/build` 静态产物;API 请求指向 Wavelet 的 `/api/*` 兼容路由。 - -### 配置要点 - -复制 `Wavelet/config.example.yaml` 为 `config.yaml`,或使用 `Wavelet/.env.example` 中的环境变量。关键默认值: +复制 `openflare-server/config.example.yaml` 为 `config.yaml`,或使用 `openflare-server/.env.example` 中的环境变量。关键默认值: | 项 | 值 | | --- | --- | @@ -189,46 +179,14 @@ go run . --port 3000 --log-dir ./logs | `application_name` | `openflare-server` | | Redis 键前缀 | `openflare:` | -生产环境需启用 PostgreSQL(`database.enabled: true` 或 `DB_ENABLED=true`),并配置 Redis。 - -### 启动命令 - -```bash -cd Wavelet - -# 开发:API + Worker + Scheduler 合一 -go run . all - -# 生产:分进程部署 -go run . api # HTTP API + OpenFlare 定时任务 -go run . worker # Wavelet Asynq 异步任务 -go run . scheduler # Wavelet Asynq 定时触发 -``` - -也可使用 `docker compose up`(见 `Wavelet/docker-compose.yml`)拉起 PostgreSQL、Redis 与 Wavelet 服务。 - -### OpenFlare 定时任务 - -OpenFlare 业务定时任务集中在 `internal/apps/openflare/tasks/`,通过 `robfig/cron` 在 **API 进程**内运行(非 Asynq): - -| 任务 | 调度 | 说明 | -| --- | --- | --- | -| 可观测数据自动清理 | 每日 03:00 | 受 Option `DatabaseAutoCleanupEnabled` 控制 | -| WAF IP 组同步 | 每 5 分钟 | 向在线 Agent 下发 IP 组变更 | -| UptimeKuma 同步 | 每分钟检查间隔 | 按 Option 配置的间隔触发 | -| SSL 自动续期 | 每日 00:00 | ACME 证书续期 | - -API 启动后日志中应出现 `[OpenFlareTasks] registered cron job` 行。`worker` 与 `scheduler` 进程不运行上述 cron。 +也可使用 `docker compose up`(见 `openflare-server/docker-compose.yml`)拉起 PostgreSQL、Redis 与 Server。 ### 验证 ```bash -cd Wavelet +cd openflare-server go build ./... go test ./internal/apps/openflare/... -count=1 -# 健康检查 curl http://127.0.0.1:3000/api/status ``` - -更多迁移进度与接手说明见 [AI 接手文档](../plan/handover-openflare-backend-migration.md)。 diff --git a/docs/design/index.md b/docs/design/index.md index a7bdd121..82477d5c 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -67,7 +67,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具 | 路径 | 职责 | | ---------------------- | ---------------------------------------------------- | | `openflare-server` | Gin + GORM + SQLite/PostgreSQL 单体控制面 | -| `openflare-server/web` | Next.js 15 App Router 管理端前端,由 Go Server 托管 | +| `openflare-server/frontend` | Next.js App Router 管理端前端,由 Go Server 嵌入托管 | | `pkg` | 跨组件复用的协议类型与通用工具包 | | `openflare-agent` | Go 单体 Agent,运行在节点侧 | | `openflare-relay` | Tunnel 中继代理,运行在公网边缘管理 frps 进程 | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 94cf316d..d249d12c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8,10 +8,8 @@ ```bash cd openflare-server -export JWT_SECRET='replace-with-random-string' -export SQLITE_PATH='./openflare.db' -export LOG_LEVEL='info' -go run . +cp config.example.yaml config.yaml +go run . all ``` 指定监听端口与日志目录: @@ -32,7 +30,7 @@ GOCACHE=/tmp/openflare-go-cache go test ./... 开发: ```bash -cd openflare-server/web +cd openflare-server/frontend pnpm install pnpm dev ``` @@ -40,14 +38,14 @@ pnpm dev 构建静态产物: ```bash -cd openflare-server/web -pnpm build +cd openflare-server/frontend +pnpm build:embed ``` 检查: ```bash -cd openflare-server/web +cd openflare-server/frontend pnpm lint pnpm typecheck pnpm test @@ -146,5 +144,5 @@ pnpm dev ```bash cd docs -pnpm build +pnpm build:embed ``` diff --git a/openflare-agent/Dockerfile b/openflare-agent/Dockerfile index 4077c88e..b4f68fa8 100644 --- a/openflare-agent/Dockerfile +++ b/openflare-agent/Dockerfile @@ -16,7 +16,6 @@ COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download -COPY openflare-server ./openflare-server COPY openflare-agent ./openflare-agent COPY pkg ./pkg RUN --mount=type=cache,target=/go/pkg/mod \ diff --git a/openflare-relay/Dockerfile b/openflare-relay/Dockerfile index c34e40dc..1903e313 100644 --- a/openflare-relay/Dockerfile +++ b/openflare-relay/Dockerfile @@ -10,7 +10,6 @@ COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download -COPY openflare-server ./openflare-server COPY openflare-relay ./openflare-relay COPY pkg ./pkg RUN --mount=type=cache,target=/go/pkg/mod \ diff --git a/Wavelet/.dmux-hooks/AGENTS.md b/openflare-server/.dmux-hooks/AGENTS.md similarity index 100% rename from Wavelet/.dmux-hooks/AGENTS.md rename to openflare-server/.dmux-hooks/AGENTS.md diff --git a/Wavelet/.dmux-hooks/CLAUDE.md b/openflare-server/.dmux-hooks/CLAUDE.md similarity index 100% rename from Wavelet/.dmux-hooks/CLAUDE.md rename to openflare-server/.dmux-hooks/CLAUDE.md diff --git a/Wavelet/.dmux-hooks/README.md b/openflare-server/.dmux-hooks/README.md similarity index 100% rename from Wavelet/.dmux-hooks/README.md rename to openflare-server/.dmux-hooks/README.md diff --git a/Wavelet/.dmux-hooks/examples/post_merge.example b/openflare-server/.dmux-hooks/examples/post_merge.example similarity index 100% rename from Wavelet/.dmux-hooks/examples/post_merge.example rename to openflare-server/.dmux-hooks/examples/post_merge.example diff --git a/Wavelet/.dmux-hooks/examples/run_dev.example b/openflare-server/.dmux-hooks/examples/run_dev.example similarity index 100% rename from Wavelet/.dmux-hooks/examples/run_dev.example rename to openflare-server/.dmux-hooks/examples/run_dev.example diff --git a/Wavelet/.dmux-hooks/examples/run_test.example b/openflare-server/.dmux-hooks/examples/run_test.example similarity index 100% rename from Wavelet/.dmux-hooks/examples/run_test.example rename to openflare-server/.dmux-hooks/examples/run_test.example diff --git a/Wavelet/.dmux-hooks/examples/worktree_created.example b/openflare-server/.dmux-hooks/examples/worktree_created.example similarity index 100% rename from Wavelet/.dmux-hooks/examples/worktree_created.example rename to openflare-server/.dmux-hooks/examples/worktree_created.example diff --git a/openflare-server/.dockerignore b/openflare-server/.dockerignore index 8c561426..65420584 100644 --- a/openflare-server/.dockerignore +++ b/openflare-server/.dockerignore @@ -1,16 +1,30 @@ .git -.github -node_modules -web/node_modules -web/.next -web/build -web/out -dist -tmp -logs -upload -*.log +.idea +.vscode .DS_Store +Thumbs.db + +config.yaml .env .env.* -docker-compose*.yml + +bin/ +build/ +dist/ +data/ +logs/ +uploads/ +s3_cache/ + +frontend/node_modules/ +frontend/.next/ +frontend/out/ +frontend/build/ +frontend/disk/ +frontend/.env +frontend/next-env.d.ts +frontend/*.tsbuildinfo +frontend/package-lock.json + +internal/router/dist/ +internal/router/root/dist/ diff --git a/Wavelet/.editorconfig b/openflare-server/.editorconfig similarity index 100% rename from Wavelet/.editorconfig rename to openflare-server/.editorconfig diff --git a/Wavelet/.env.example b/openflare-server/.env.example similarity index 100% rename from Wavelet/.env.example rename to openflare-server/.env.example diff --git a/Wavelet/.github/ISSUE_TEMPLATE/bug_report.yml b/openflare-server/.github/ISSUE_TEMPLATE/bug_report.yml similarity index 100% rename from Wavelet/.github/ISSUE_TEMPLATE/bug_report.yml rename to openflare-server/.github/ISSUE_TEMPLATE/bug_report.yml diff --git a/Wavelet/.github/ISSUE_TEMPLATE/config.yml b/openflare-server/.github/ISSUE_TEMPLATE/config.yml similarity index 100% rename from Wavelet/.github/ISSUE_TEMPLATE/config.yml rename to openflare-server/.github/ISSUE_TEMPLATE/config.yml diff --git a/Wavelet/.github/ISSUE_TEMPLATE/feature_request.yml b/openflare-server/.github/ISSUE_TEMPLATE/feature_request.yml similarity index 100% rename from Wavelet/.github/ISSUE_TEMPLATE/feature_request.yml rename to openflare-server/.github/ISSUE_TEMPLATE/feature_request.yml diff --git a/Wavelet/.github/copilot-instructions.md b/openflare-server/.github/copilot-instructions.md similarity index 100% rename from Wavelet/.github/copilot-instructions.md rename to openflare-server/.github/copilot-instructions.md diff --git a/Wavelet/.github/git-commit-instructions.md b/openflare-server/.github/git-commit-instructions.md similarity index 100% rename from Wavelet/.github/git-commit-instructions.md rename to openflare-server/.github/git-commit-instructions.md diff --git a/Wavelet/.github/pull_request_template.md b/openflare-server/.github/pull_request_template.md similarity index 100% rename from Wavelet/.github/pull_request_template.md rename to openflare-server/.github/pull_request_template.md diff --git a/Wavelet/.github/workflows/build-image.yml b/openflare-server/.github/workflows/build-image.yml similarity index 100% rename from Wavelet/.github/workflows/build-image.yml rename to openflare-server/.github/workflows/build-image.yml diff --git a/Wavelet/.github/workflows/build-release.yml b/openflare-server/.github/workflows/build-release.yml similarity index 100% rename from Wavelet/.github/workflows/build-release.yml rename to openflare-server/.github/workflows/build-release.yml diff --git a/Wavelet/.github/workflows/cleanup-prerelease-tags.yml b/openflare-server/.github/workflows/cleanup-prerelease-tags.yml similarity index 100% rename from Wavelet/.github/workflows/cleanup-prerelease-tags.yml rename to openflare-server/.github/workflows/cleanup-prerelease-tags.yml diff --git a/Wavelet/.github/workflows/close_ticket.yml b/openflare-server/.github/workflows/close_ticket.yml similarity index 100% rename from Wavelet/.github/workflows/close_ticket.yml rename to openflare-server/.github/workflows/close_ticket.yml diff --git a/Wavelet/.github/workflows/codeql.yml b/openflare-server/.github/workflows/codeql.yml similarity index 100% rename from Wavelet/.github/workflows/codeql.yml rename to openflare-server/.github/workflows/codeql.yml diff --git a/Wavelet/.github/workflows/copilot-setup-steps.yml b/openflare-server/.github/workflows/copilot-setup-steps.yml similarity index 100% rename from Wavelet/.github/workflows/copilot-setup-steps.yml rename to openflare-server/.github/workflows/copilot-setup-steps.yml diff --git a/Wavelet/.github/workflows/eslint.yml b/openflare-server/.github/workflows/eslint.yml similarity index 100% rename from Wavelet/.github/workflows/eslint.yml rename to openflare-server/.github/workflows/eslint.yml diff --git a/Wavelet/.github/workflows/pr-template-check.yml b/openflare-server/.github/workflows/pr-template-check.yml similarity index 100% rename from Wavelet/.github/workflows/pr-template-check.yml rename to openflare-server/.github/workflows/pr-template-check.yml diff --git a/openflare-server/.gitignore b/openflare-server/.gitignore index 0d28eed5..47f0d767 100644 --- a/openflare-server/.gitignore +++ b/openflare-server/.gitignore @@ -1,2 +1,61 @@ +# IDE +.idea/* +.idea +!.idea/icon.png +.vscode +.pnpm-store/ + +# logs +/logs/ +*.log + +# config +config.yaml +.env + +# sqlite +*.db +*.db-journal +*.db-shm +*.db-wal + +# frontend +frontend/build +frontend/disk +frontend/node_modules/* +frontend/.next/* +frontend/next-env.d.ts +frontend/package-lock.json +frontend/.env +.env.* +!.env.example +*.tsbuildinfo + +# os +.DS_Store +Thumbs.db + +# build +/build/ +/bin/ +/dist/ + +# go workspace +go.work +go.work.sum +main + +# upload +uploads/* + +s3_cache +/frontend/.next/ /data/ -/postgres-data/ +/internal/router/dist/ +/frontend/out/ +/.idea/ +/uploads/ +/*-source/ +/.cache/ +/internal/router/root/dist/ +.dmux/ diff --git a/Wavelet/.golangci.yml b/openflare-server/.golangci.yml similarity index 100% rename from Wavelet/.golangci.yml rename to openflare-server/.golangci.yml diff --git a/Wavelet/AGENTS.md b/openflare-server/AGENTS.md similarity index 98% rename from Wavelet/AGENTS.md rename to openflare-server/AGENTS.md index ba5f662a..9bc09e76 100644 --- a/Wavelet/AGENTS.md +++ b/openflare-server/AGENTS.md @@ -250,7 +250,7 @@ func doSomething(c *gin.Context) { response.AbortBadRequest(c, "...") } 路由与模块: - 仅在 `internal/router/router.go` 中作为统一高层入口进行路由分发委派,不允许在 `router.go` 中直接挂载业务 Handler。 -- 关于所有的路由归属划分、接口开发隔离防线以及详细的注册和开发步骤,请直接阅读并严格遵循 [new-api](file:///Users/ryan/DEV/Go/Wavelet/.claude/skills/new-api/SKILL.md) 技能。 +- 关于所有的路由归属划分、接口开发隔离防线以及详细的注册和开发步骤,请直接阅读并严格遵循 [new-api](file:///Users/ryan/DEV/Go/OpenFlare/openflare-server/.claude/skills/new-api/SKILL.md) 技能。 应用装配与跨模块集成: @@ -282,7 +282,7 @@ func doSomething(c *gin.Context) { response.AbortBadRequest(c, "...") } 在进行任何 Next.js 工作之前,请在 `node_modules/next/dist/docs/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。 -请直接查看并参考项目提供的示例和 Demo 代码:[frontend/app/(main)/admin/demo](file:///Users/ryan/DEV/Go/Wavelet/frontend/app/(main)/admin/demo)。 +请直接查看并参考项目提供的示例和 Demo 代码:[frontend/app/(main)/admin/demo](file:///Users/ryan/DEV/Go/OpenFlare/openflare-server/frontend/app/(main)/admin/demo)。 样式规范: diff --git a/Wavelet/CONTRIBUTING.md b/openflare-server/CONTRIBUTING.md similarity index 100% rename from Wavelet/CONTRIBUTING.md rename to openflare-server/CONTRIBUTING.md diff --git a/Wavelet/Claude.md b/openflare-server/Claude.md similarity index 100% rename from Wavelet/Claude.md rename to openflare-server/Claude.md diff --git a/openflare-server/Dockerfile b/openflare-server/Dockerfile deleted file mode 100644 index bda16b4d..00000000 --- a/openflare-server/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# syntax=docker/dockerfile:1.7 -ARG VERSION=dev -FROM node:20 AS web-builder -ARG VERSION -WORKDIR /build -RUN corepack enable -COPY openflare-server/web/package.json openflare-server/web/pnpm-lock.yaml ./ -RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ - pnpm install --frozen-lockfile -COPY openflare-server/web ./ -RUN --mount=type=cache,id=next-cache,target=/build/.next/cache \ - NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build - -FROM golang:1.25 AS go-builder -ARG VERSION -ENV GO111MODULE=on \ - CGO_ENABLED=0 \ - GOOS=linux -WORKDIR /build -COPY go.mod go.sum ./ -RUN --mount=type=cache,target=/go/pkg/mod \ - go mod download -COPY openflare-server ./openflare-server -COPY pkg ./pkg -COPY --from=web-builder /build/build ./openflare-server/web/build -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - go build -trimpath \ - -ldflags "-s -w -X 'github.com/rain-kl/openflare/openflare-server/internal/common.Version=$VERSION'" \ - -o openflare ./openflare-server/cmd/server - -FROM alpine:latest -RUN apk add --no-cache ca-certificates tzdata \ - && update-ca-certificates 2>/dev/null || true -ENV PORT=3000 -COPY --from=go-builder /build/openflare /openflare -EXPOSE 3000 -WORKDIR /data -ENTRYPOINT ["/openflare"] diff --git a/Wavelet/LICENSE b/openflare-server/LICENSE similarity index 100% rename from Wavelet/LICENSE rename to openflare-server/LICENSE diff --git a/Wavelet/Makefile b/openflare-server/Makefile similarity index 100% rename from Wavelet/Makefile rename to openflare-server/Makefile diff --git a/Wavelet/NOTICE b/openflare-server/NOTICE similarity index 100% rename from Wavelet/NOTICE rename to openflare-server/NOTICE diff --git a/Wavelet/README.md b/openflare-server/README.md similarity index 100% rename from Wavelet/README.md rename to openflare-server/README.md diff --git a/Wavelet/README_zh.md b/openflare-server/README_zh.md similarity index 100% rename from Wavelet/README_zh.md rename to openflare-server/README_zh.md diff --git a/openflare-server/cmd/accesslogbench/main.go b/openflare-server/cmd/accesslogbench/main.go deleted file mode 100644 index 8aa7b758..00000000 --- a/openflare-server/cmd/accesslogbench/main.go +++ /dev/null @@ -1,562 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -const retentionDays = 90 - -func main() { - dsn := flag.String("dsn", envOr("DSN", "postgres://openflare:replace-with-strong-password@192.168.107.2:5432/openflare?sslmode=disable"), "PostgreSQL DSN") - records := flag.Int("records", 1_000_000, "number of access log rows to seed (0 = skip seeding)") - seedNode := flag.String("node-id", "bench-node", "node_id used for seeded rows") - iterations := flag.Int("iterations", 10, "benchmark iterations per scenario") - warmup := flag.Int("warmup", 2, "warmup iterations per scenario") - page := flag.Int("page", 0, "page index for list benchmark") - pageSize := flag.Int("page-size", 20, "page size for list benchmark") - reset := flag.Bool("reset", false, "truncate node_access_logs shard tables before seeding") - legacyIterations := flag.Int("legacy-iterations", 1, "iterations for legacy full-scan scenario (can be very slow)") - skipLegacy := flag.Bool("skip-legacy", false, "skip legacy full-scan scenario") - verifyOnly := flag.Bool("verify-only", false, "verify query correctness and exit") - flag.Parse() - - common.SQLDSN = *dsn - common.SQLitePath = filepath.Join(os.TempDir(), "openflare-accesslogbench-missing.sqlite") - if err := initBenchDB(*dsn); err != nil { - log.Fatalf("init db: %v", err) - } - - fmt.Println("=== OpenFlare node_access_logs benchmark (PostgreSQL) ===") - fmt.Printf("DSN: %s\n", redactDSN(*dsn)) - fmt.Printf("GOMAXPROCS=%d\n", runtime.GOMAXPROCS(0)) - - if *reset { - if err := truncateAccessLogs(); err != nil { - log.Fatalf("truncate access logs: %v", err) - } - fmt.Println("truncated node_access_logs shard tables") - } - - if *records > 0 { - existing, err := countAllAccessLogs() - if err != nil { - log.Fatalf("count existing rows: %v", err) - } - if existing >= int64(*records) { - fmt.Printf("existing rows=%d >= target=%d, skip seeding\n", existing, *records) - } else { - missing := *records - int(existing) - fmt.Printf("seeding %d rows (existing=%d)...\n", missing, existing) - if err := seedAccessLogs(*seedNode, missing); err != nil { - log.Fatalf("seed access logs: %v", err) - } - } - } - - total, err := countAllAccessLogs() - if err != nil { - log.Fatalf("count rows after seed: %v", err) - } - fmt.Printf("total rows across shards: %d\n\n", total) - - since := time.Now().UTC().Add(-retentionDays * 24 * time.Hour) - if err := verifyQueryCorrectness(since, total); err != nil { - log.Fatalf("correctness verification failed: %v", err) - } - fmt.Println("correctness verification: PASS") - if *verifyOnly { - return - } - - query := model.NodeAccessLogQuery{ - Since: since, - Page: *page, - PageSize: *pageSize, - SortBy: "logged_at", - SortOrder: "desc", - } - - scenarios := []scenario{ - { - name: "paginated_list", - run: func() error { - _, err := model.ListNodeAccessLogs(query) - return err - }, - }, - { - name: "sql_count", - run: func() error { - _, _, err := model.CountNodeAccessLogs(query) - return err - }, - }, - { - name: "api_list+count", - run: func() error { - if _, err := model.ListNodeAccessLogs(query); err != nil { - return err - } - _, _, err := model.CountNodeAccessLogs(query) - return err - }, - }, - { - name: "fullscan_list_legacy", - run: func() error { - _, err := fullScanList(query) - return err - }, - }, - } - - if err := printExplainPlans(since); err != nil { - log.Printf("warn: explain analyze failed: %v", err) - } - - for _, item := range scenarios { - if item.name == "fullscan_list_legacy" && *skipLegacy { - fmt.Println("[fullscan_list_legacy] skipped") - continue - } - iterationCount := *iterations - if item.name == "fullscan_list_legacy" { - iterationCount = *legacyIterations - } - result, err := runScenario(item, *warmup, iterationCount) - if err != nil { - log.Fatalf("scenario %s failed: %v", item.name, err) - } - printResult(result) - } -} - -type scenario struct { - name string - run func() error -} - -type benchResult struct { - name string - iterations int - latencies []time.Duration - allocBytes []uint64 - heapInUse []uint64 - maxHeapInUse uint64 -} - -func runScenario(item scenario, warmup int, iterations int) (*benchResult, error) { - for range warmup { - if err := item.run(); err != nil { - return nil, err - } - } - - result := &benchResult{ - name: item.name, - iterations: iterations, - } - var peakHeap uint64 - - for range iterations { - runtime.GC() - var before, after runtime.MemStats - runtime.ReadMemStats(&before) - - start := time.Now() - if err := item.run(); err != nil { - return nil, err - } - elapsed := time.Since(start) - - runtime.ReadMemStats(&after) - result.latencies = append(result.latencies, elapsed) - result.allocBytes = append(result.allocBytes, after.TotalAlloc-before.TotalAlloc) - result.heapInUse = append(result.heapInUse, after.HeapInuse) - if after.HeapInuse > peakHeap { - peakHeap = after.HeapInuse - } - } - result.maxHeapInUse = peakHeap - return result, nil -} - -func printResult(result *benchResult) { - latencies := append([]time.Duration(nil), result.latencies...) - sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) - - var allocSum uint64 - var heapSum uint64 - for index := range result.allocBytes { - allocSum += result.allocBytes[index] - heapSum += result.heapInUse[index] - } - - fmt.Printf("[%s] iterations=%d\n", result.name, result.iterations) - fmt.Printf(" latency: min=%s avg=%s p50=%s p95=%s max=%s\n", - minDuration(latencies), - avgDuration(latencies), - percentile(latencies, 50), - percentile(latencies, 95), - maxDuration(latencies), - ) - fmt.Printf(" alloc/op: avg=%s peak_heap=%s\n", - humanBytes(allocSum/uint64(len(result.allocBytes))), - humanBytes(result.maxHeapInUse), - ) - fmt.Printf(" heap_inuse/op: avg=%s\n\n", humanBytes(heapSum/uint64(len(result.heapInUse)))) -} - -func seedAccessLogs(nodeID string, total int) error { - started := time.Now() - perShard := total / 10 - remainder := total % 10 - seeded := 0 - now := time.Now().UTC() - - for shard := range 10 { - rows := perShard - if shard < remainder { - rows++ - } - if rows == 0 { - continue - } - table := fmt.Sprintf("node_access_logs_%02d", shard) - sql := fmt.Sprintf(` -INSERT INTO %s (id, node_id, logged_at, remote_addr, region, host, path, status_code, created_at) -SELECT - (gs * 10 + %d)::bigint AS id, - $1 AS node_id, - $2::timestamptz - (gs || ' minutes')::interval AS logged_at, - ('203.0.' || ((gs / 256) %% 256)::text || '.' || (gs %% 256)::text) AS remote_addr, - 'Benchland' AS region, - ('host-' || (gs %% 200)::text || '.example.com') AS host, - ('/api/v1/resource/' || gs::text) AS path, - (200 + (gs %% 4))::bigint AS status_code, - $2::timestamptz AS created_at -FROM generate_series(0, $3 - 1) AS gs -`, table, shard) - if err := model.DB.Exec(sql, nodeID, now, rows).Error; err != nil { - return err - } - seeded += rows - elapsed := time.Since(started) - rate := float64(seeded) / elapsed.Seconds() - fmt.Printf(" seeded %d/%d rows (%.0f rows/s, elapsed %s)\n", seeded, total, rate, elapsed.Round(time.Millisecond)) - } - return nil -} - -func truncateAccessLogs() error { - for _, table := range observabilityShardTables() { - if err := model.DB.Exec("TRUNCATE TABLE " + table).Error; err != nil { - return err - } - } - return nil -} - -func countAllAccessLogs() (int64, error) { - var total int64 - for _, table := range observabilityShardTables() { - var count int64 - if err := model.DB.Table(table).Count(&count).Error; err != nil { - return 0, err - } - total += count - } - return total, nil -} - -func observabilityShardTables() []string { - tables := make([]string, 0, 10) - for index := range 10 { - tables = append(tables, fmt.Sprintf("node_access_logs_%02d", index)) - } - return tables -} - -func verifyQueryCorrectness(since time.Time, tableTotal int64) error { - fmt.Println("=== correctness verification ===") - baseQuery := model.NodeAccessLogQuery{ - Since: since, - SortBy: "logged_at", - SortOrder: "desc", - } - - reference, err := fullScanList(baseQuery) - if err != nil { - return fmt.Errorf("reference full scan failed: %w", err) - } - if int64(len(reference)) != tableTotal { - return fmt.Errorf("reference row count %d != table total %d", len(reference), tableTotal) - } - fmt.Printf(" reference rows in retention window: %d\n", len(reference)) - - totalRecords, totalIPs, err := model.CountNodeAccessLogs(baseQuery) - if err != nil { - return fmt.Errorf("CountNodeAccessLogs failed: %w", err) - } - if totalRecords != int64(len(reference)) { - return fmt.Errorf("total_records=%d want reference=%d", totalRecords, len(reference)) - } - referenceIPs := countUniqueIPs(reference) - if totalIPs != referenceIPs { - return fmt.Errorf("total_ip=%d want reference=%d", totalIPs, referenceIPs) - } - fmt.Printf(" count totals: total_record=%d total_ip=%d\n", totalRecords, totalIPs) - - pages := []struct { - page int - pageSize int - }{ - {0, 20}, - {1, 20}, - {49, 20}, - {100, 50}, - } - for _, item := range pages { - query := baseQuery - query.Page = item.page - query.PageSize = item.pageSize - got, err := model.ListNodeAccessLogs(query) - if err != nil { - return fmt.Errorf("ListNodeAccessLogs page=%d size=%d failed: %w", item.page, item.pageSize, err) - } - start := item.page * item.pageSize - end := start + item.pageSize - if start >= len(reference) { - if len(got) != 0 { - return fmt.Errorf("page=%d size=%d expected empty got %d", item.page, item.pageSize, len(got)) - } - continue - } - if end > len(reference) { - end = len(reference) - } - want := reference[start:end] - if !accessLogsEqual(got, want) { - return fmt.Errorf("page=%d size=%d content mismatch (got %d want %d rows)", item.page, item.pageSize, len(got), len(want)) - } - fmt.Printf(" page=%d size=%d: %d rows match reference\n", item.page, item.pageSize, len(got)) - } - - filtered := model.NodeAccessLogQuery{ - NodeID: "bench-node", - Since: since, - SortBy: "status_code", - SortOrder: "asc", - Page: 2, - PageSize: 15, - } - filteredReference, err := fullScanList(filtered) - if err != nil { - return fmt.Errorf("filtered reference failed: %w", err) - } - filteredRows, err := model.ListNodeAccessLogs(filtered) - if err != nil { - return fmt.Errorf("filtered ListNodeAccessLogs failed: %w", err) - } - start := filtered.Page * filtered.PageSize - end := start + filtered.PageSize - if end > len(filteredReference) { - end = len(filteredReference) - } - if start >= len(filteredReference) { - start = len(filteredReference) - } - if !accessLogsEqual(filteredRows, filteredReference[start:end]) { - return fmt.Errorf("filtered page content mismatch") - } - filteredTotal, filteredIPs, err := model.CountNodeAccessLogs(filtered) - if err != nil { - return fmt.Errorf("filtered CountNodeAccessLogs failed: %w", err) - } - if filteredTotal != int64(len(filteredReference)) { - return fmt.Errorf("filtered total_records=%d want %d", filteredTotal, len(filteredReference)) - } - if filteredIPs != countUniqueIPs(filteredReference) { - return fmt.Errorf("filtered total_ip=%d want %d", filteredIPs, countUniqueIPs(filteredReference)) - } - fmt.Printf(" filtered node_id=bench-node page=2 size=15: match (total_record=%d total_ip=%d)\n", filteredTotal, filteredIPs) - return nil -} - -func countUniqueIPs(logs []*model.NodeAccessLog) int64 { - ips := make(map[string]struct{}) - for _, item := range logs { - if item == nil { - continue - } - if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" { - ips[trimmed] = struct{}{} - } - } - return int64(len(ips)) -} - -func accessLogsEqual(left []*model.NodeAccessLog, right []*model.NodeAccessLog) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] == nil || right[index] == nil { - if left[index] != right[index] { - return false - } - continue - } - if left[index].ID != right[index].ID || - left[index].NodeID != right[index].NodeID || - !left[index].LoggedAt.Equal(right[index].LoggedAt) || - left[index].RemoteAddr != right[index].RemoteAddr || - left[index].Host != right[index].Host || - left[index].Path != right[index].Path || - left[index].StatusCode != right[index].StatusCode { - return false - } - } - return true -} - -func printExplainPlans(since time.Time) error { - fmt.Println("=== PostgreSQL EXPLAIN (one shard sample: node_access_logs_00) ===") - queries := []struct { - name string - sql string - }{ - { - name: "paginated_list", - sql: "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM node_access_logs_00 WHERE logged_at >= $1 ORDER BY logged_at DESC, id DESC LIMIT 20", - }, - { - name: "count_rows", - sql: "EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM node_access_logs_00 WHERE logged_at >= $1", - }, - { - name: "distinct_ip_union_all", - sql: `EXPLAIN (ANALYZE, BUFFERS) SELECT COUNT(*) FROM ( -SELECT remote_addr FROM ( -SELECT TRIM(remote_addr) AS remote_addr FROM node_access_logs_00 WHERE logged_at >= $1 AND remote_addr <> '' -UNION ALL -SELECT TRIM(remote_addr) AS remote_addr FROM node_access_logs_01 WHERE logged_at >= $1 AND remote_addr <> '' -) AS all_ips GROUP BY remote_addr -) AS ips`, - }, - } - for _, item := range queries { - rows, err := model.DB.Raw(item.sql, since).Rows() - if err != nil { - return err - } - fmt.Printf("-- %s\n", item.name) - for rows.Next() { - var line string - if err := rows.Scan(&line); err != nil { - rows.Close() - return err - } - fmt.Println(line) - } - rows.Close() - fmt.Println() - } - return nil -} - -func fullScanList(query model.NodeAccessLogQuery) ([]*model.NodeAccessLog, error) { - legacy := query - legacy.PageSize = 0 - return model.ListNodeAccessLogs(legacy) -} - -func initBenchDB(dsn string) error { - return model.InitBenchmarkDB(dsn) -} - -func envOr(key string, fallback string) string { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - return fallback -} - -func redactDSN(dsn string) string { - if at := strings.Index(dsn, "@"); at > 0 { - schemeEnd := strings.Index(dsn, "://") - if schemeEnd >= 0 { - return dsn[:schemeEnd+3] + "***@" + dsn[at+1:] - } - } - return dsn -} - -func minDuration(values []time.Duration) time.Duration { - if len(values) == 0 { - return 0 - } - return values[0] -} - -func maxDuration(values []time.Duration) time.Duration { - if len(values) == 0 { - return 0 - } - return values[len(values)-1] -} - -func avgDuration(values []time.Duration) time.Duration { - if len(values) == 0 { - return 0 - } - var sum time.Duration - for _, value := range values { - sum += value - } - return sum / time.Duration(len(values)) -} - -func percentile(values []time.Duration, p int) time.Duration { - if len(values) == 0 { - return 0 - } - if p <= 0 { - return values[0] - } - if p >= 100 { - return values[len(values)-1] - } - index := (len(values)*p + 99) / 100 - if index <= 0 { - index = 1 - } - if index > len(values) { - index = len(values) - } - return values[index-1] -} - -func humanBytes(value uint64) string { - const unit = 1024 - if value < unit { - return fmt.Sprintf("%d B", value) - } - div, exp := uint64(unit), 0 - for n := value / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp]) -} diff --git a/openflare-server/cmd/server/main.go b/openflare-server/cmd/server/main.go deleted file mode 100644 index 2235d147..00000000 --- a/openflare-server/cmd/server/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import server "github.com/rain-kl/openflare/openflare-server" - -func main() { - server.Run() -} diff --git a/Wavelet/config.example.yaml b/openflare-server/config.example.yaml similarity index 100% rename from Wavelet/config.example.yaml rename to openflare-server/config.example.yaml diff --git a/openflare-server/docker-compose.yaml b/openflare-server/docker-compose.yaml deleted file mode 100644 index 4d324d34..00000000 --- a/openflare-server/docker-compose.yaml +++ /dev/null @@ -1,35 +0,0 @@ -services: - postgres: - image: postgres:17-alpine - restart: unless-stopped - environment: - POSTGRES_DB: openflare - POSTGRES_USER: openflare - POSTGRES_PASSWORD: replace-with-strong-password - volumes: - - ./postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U openflare -d openflare"] - interval: 10s - timeout: 5s - retries: 5 - - openflare: - build: - dockerfile: Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - ports: - - "3000:3000" - environment: - SESSION_SECRET: replace-with-random-string - SQLITE_PATH: /data/openflare.db - DSN: postgres://openflare:replace-with-strong-password@postgres:5432/openflare?sslmode=disable - GIN_MODE: release - LOG_LEVEL: info - - volumes: - - ./openflare-data:/data - diff --git a/Wavelet/docker-compose.yml b/openflare-server/docker-compose.yml similarity index 100% rename from Wavelet/docker-compose.yml rename to openflare-server/docker-compose.yml diff --git a/Wavelet/docker/Dockerfile b/openflare-server/docker/Dockerfile similarity index 100% rename from Wavelet/docker/Dockerfile rename to openflare-server/docker/Dockerfile diff --git a/Wavelet/docker/Dockerfile.backend b/openflare-server/docker/Dockerfile.backend similarity index 100% rename from Wavelet/docker/Dockerfile.backend rename to openflare-server/docker/Dockerfile.backend diff --git a/Wavelet/docker/Dockerfile.cross b/openflare-server/docker/Dockerfile.cross similarity index 100% rename from Wavelet/docker/Dockerfile.cross rename to openflare-server/docker/Dockerfile.cross diff --git a/Wavelet/docker/Dockerfile.frontend b/openflare-server/docker/Dockerfile.frontend similarity index 100% rename from Wavelet/docker/Dockerfile.frontend rename to openflare-server/docker/Dockerfile.frontend diff --git a/Wavelet/docs/DEPLOYMENT.md b/openflare-server/docs/DEPLOYMENT.md similarity index 100% rename from Wavelet/docs/DEPLOYMENT.md rename to openflare-server/docs/DEPLOYMENT.md diff --git a/Wavelet/docs/PERFORMANCE.md b/openflare-server/docs/PERFORMANCE.md similarity index 100% rename from Wavelet/docs/PERFORMANCE.md rename to openflare-server/docs/PERFORMANCE.md diff --git a/openflare-server/docs/docs.go b/openflare-server/docs/docs.go index 8b3cb268..d553c93d 100644 --- a/openflare-server/docs/docs.go +++ b/openflare-server/docs/docs.go @@ -9,94 +9,22 @@ const docTemplate = `{ "info": { "description": "{{escape .Description}}", "title": "{{.Title}}", - "contact": {}, + "contact": { + "name": "OpenFlare", + "url": "https://github.com/Rain-kl/OpenFlare" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/api/access-logs/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "AccessLogs" - ], - "summary": "List access logs", - "parameters": [ - { - "type": "string", - "description": "Node ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "Remote address", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "Path", - "name": "path", - "in": "query" - }, - { - "type": "integer", - "description": "Page index", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "Sort by", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "Sort order", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/access-logs/cleanup": { + "/api/cap/challenge": { "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", "consumes": [ "application/json" ], @@ -104,605 +32,4869 @@ const docTemplate = `{ "application/json" ], "tags": [ - "AccessLogs" + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } ], - "summary": "Cleanup access logs by retention days", "responses": { "200": { - "description": "OK", + "description": "成功返回 PoW 难题", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/cap.ChallengeResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" } } } } }, - "/api/access-logs/folds": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } + "/api/cap/redeem": { + "post": { + "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "consumes": [ + "application/json" ], "produces": [ "application/json" ], "tags": [ - "AccessLogs" + "cap" ], - "summary": "List folded access logs", + "summary": "校验人机验证解答", "parameters": [ { - "type": "string", - "description": "Node ID", - "name": "node_id", - "in": "query" + "description": "难题 Token 与解答 solutions 数组", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cap.redeemRequest" + } + } + ], + "responses": { + "200": { + "description": "核销成功,返回 X-Cap-Token", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + } + }, + "400": { + "description": "参数错误或核销失败", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + } + } + } + } + }, + "/api/health": { + "get": { + "description": "检查服务是否正常运行,可用于负载均衡存活探测", + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "健康检查", + "responses": { + "200": { + "description": "服务正常", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/auth-sources": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取认证源列表", + "responses": { + "200": { + "description": "认证源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建认证源", + "parameters": [ + { + "description": "创建认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功,返回认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true }, { - "type": "string", - "description": "Remote address", - "name": "remote_addr", - "in": "query" + "description": "更新认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功,返回更新后的认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或删除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}/toggle": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "切换认证源启用状态", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true }, { - "type": "string", - "description": "Host", - "name": "host", - "in": "query" + "description": "启用状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } }, + "400": { + "description": "验证失败或认证源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/clear": { + "post": { + "security": [ { - "type": "string", - "description": "Path", - "name": "path", + "SessionCookie": [] + } + ], + "description": "清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "清空缓存", + "responses": { + "200": { + "description": "清理成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/config": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新缓存配置", + "parameters": [ + { + "description": "缓存配置请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cache.updateCacheConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取缓存状态", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/diskcache.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-export": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "导出数据库", + "responses": { + "200": { + "description": "数据库文件", + "schema": { + "type": "file" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "导出失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.DatabaseInfoResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库运行概览", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/db_manage.DBOverviewResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/query": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "执行 SQL 查询", + "parameters": [ + { + "description": "SQL 请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/db_manage.ExecuteSQLRequest" + } + } + ], + "responses": { + "200": { + "description": "执行完毕", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/db_manage.ExecuteSQLResponse" + } + } + } + ] + } + }, + "400": { + "description": "SQL 语句错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/tables": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前数据库的所有用户自定义表名称列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库所有表名", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统历史日志,cursor=0 获取最新日志,cursor\u003e0 获取更早日志", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统日志", + "parameters": [ + { + "type": "integer", + "default": 0, + "description": "日志游标,0=获取最新", + "name": "cursor", "in": "query" }, { "type": "integer", - "description": "Page index", - "name": "p", + "default": 200, + "description": "每页条数", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.logsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/access": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户访问日志", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", "in": "query" }, { "type": "integer", - "description": "Page size", + "default": 20, + "description": "每页条数", "name": "page_size", "in": "query" }, { "type": "string", - "description": "Sort by", - "name": "sort_by", + "description": "用户名模糊搜索", + "name": "username", "in": "query" }, { "type": "string", - "description": "Sort order", - "name": "sort_order", - "in": "query" - }, - { - "type": "integer", - "description": "Fold minutes", - "name": "fold_minutes", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/access-logs/folds/ip-summary": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "AccessLogs" - ], - "summary": "List folded access log IP summaries", - "parameters": [ - { - "type": "string", - "description": "Node ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "Remote address", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "Path", + "description": "接口路径模糊搜索", "name": "path", "in": "query" }, { "type": "string", - "description": "Bucket started at", + "description": "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", + "name": "start_time", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", + "name": "end_time", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.accessLogsResponse" + } + } + } + ] + } + }, + "400": { + "description": "ClickHouse 未启用或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/analytics": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取访问日志分析数据", + "responses": { + "200": { + "description": "分析统计数据", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.logsAnalyticsResponse" + } + } + } + ] + } + }, + "400": { + "description": "ClickHouse 未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/ws": { + "get": { + "description": "通过 WebSocket 实时推送系统日志,需要管理员权限", + "tags": [ + "admin" + ], + "summary": "系统日志实时推送", + "responses": {} + } + }, + "/api/v1/admin/push/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的所有消息通道列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道", + "responses": { + "200": { + "description": "消息通道列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新建一个消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建消息通道", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.CreateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/definitions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道配置字段定义", + "responses": { + "200": { + "description": "通道配置定义列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/push.Definition" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "触发一次临时的或现有的通道连通性推送测试,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试通道连通性", + "parameters": [ + { + "description": "测试参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.TestChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "测试触发成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/channels/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.UpdateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据ID删除消息通道,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/events": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有通知事件", + "responses": { + "200": { + "description": "通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建通知事件", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.CreateEventRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/builtin": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有内置通知事件", + "responses": { + "200": { + "description": "内置通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/push.EventMetadata" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新通知事件", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.UpdateEventRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除数据库中的特定通知事件配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除通知事件配置", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}/toggle": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定的通知事件", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "快捷切换通知事件启用状态", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/histories": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回分页的通知历史日志数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "分页获取通知推送历史", + "parameters": [ + { + "type": "integer", + "description": "当前页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "分页大小", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "过滤事件名称", + "name": "event_key", + "in": "query" + }, + { + "type": "string", + "description": "过滤发送状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "推送历史列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/push.pushHistoriesResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试推送通道发送", + "parameters": [ + { + "description": "测试请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.TestPushRequest" + } + } + ], + "responses": { + "200": { + "description": "测试成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统状态信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.SystemStatusResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统配置列表", + "parameters": [ + { + "type": "string", + "description": "配置类型(system/business)", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "系统配置列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建系统配置", + "parameters": [ + { + "description": "创建请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.CreateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或配置键已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs/smtp/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "测试 SMTP 邮件发送", + "parameters": [ + { + "description": "测试请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.TestSMTPRequest" + } + } + ], + "responses": { + "200": { + "description": "测试执行完毕", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/system_config.TestSMTPResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs/{key}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据配置键获取对应的系统配置详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取单个系统配置", + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "系统配置详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新系统配置", + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/dispatch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下发异步任务", + "parameters": [ + { + "description": "任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.DispatchTaskRequest" + } + } + ], + "responses": { + "200": { + "description": "任务已入队", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "任务入队失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "查询任务执行记录", + "parameters": [ + { + "type": "string", + "description": "状态筛选 (pending/running/succeeded/failed)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "任务类型筛选", + "name": "task_type", + "in": "query" + }, + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "每页条数", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "任务执行记录列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "查询任务执行详情", + "parameters": [ + { + "type": "integer", + "description": "任务执行记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "任务执行详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TaskExecution" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions/{id}/retry": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "重新下发一条失败的任务,创建新的执行记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "重试失败任务", + "parameters": [ + { + "type": "integer", + "description": "任务执行记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "新任务的 TaskID", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务不支持重试或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "重试失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/schedules": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取定时任务列表", + "responses": { + "200": { + "description": "定时任务列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Schedule" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建定时任务", + "parameters": [ + { + "description": "创建定时任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.CreateScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功的定时任务信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Schedule" + } + } + } + ] + } + }, + "400": { + "description": "Cron 表达式无效、异步任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "保存定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/schedules/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "修改定时任务", + "parameters": [ + { + "type": "integer", + "description": "定时任务 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "修改定时任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.UpdateScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "修改后的定时任务信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Schedule" + } + } + } + ] + } + }, + "400": { + "description": "Cron 表达式无效、参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "定时任务不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "修改定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定的定时任务配置,并触发调度器热加载,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除定时任务", + "parameters": [ + { + "type": "integer", + "description": "定时任务 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "删除定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取支持的任务类型", + "responses": { + "200": { + "description": "任务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskMeta" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/templates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有通知模板列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取模板列表", + "responses": { + "200": { + "description": "模板列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Template" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建模板", + "parameters": [ + { + "description": "创建请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/template.CreateTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或模板标识符已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/templates/{key}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符获取对应的模板详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取单个模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "模板详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Template" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符更新对应的模板内容,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/template.UpdateTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Template" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "不可删除系统模板", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/update": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取应用更新状态", + "responses": { + "200": { + "description": "更新状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/updater.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "查询失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/update/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下载并应用应用更新", + "responses": { + "200": { + "description": "升级已准备并即将重启", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "当前版本不可升级", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "升级准备失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "上传用户 ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/download/batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", + "consumes": [ + "application/json" + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "批量打包下载", + "parameters": [ + { + "description": "包含文件 ID 数组 of string 的请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.batchDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "成功下载打包后的 ZIP", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "打包失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/download/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "下载单文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功下载文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件统计数据", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.fileStatsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回数据库中所有已上传文件实际拥有的业务类型列表", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件业务类型列表", + "responses": { + "200": { + "description": "业务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户列表", + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "用户列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.listUsersResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个本地密码登录的新用户,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建用户", + "parameters": [ + { + "description": "创建用户参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createUserRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.user" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或用户名已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户详情", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "用户详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.user" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除用户", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限、尝试删除管理员或当前用户", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新用户状态", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "状态参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限或尝试禁用管理员", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/config/public": { + "get": { + "description": "返回系统配置表中 visibility 为 1 的配置键值集合", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "config" + ], + "summary": "获取公共配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/custom/hello": { + "get": { + "description": "A sample business API for customization", + "produces": [ + "application/json" + ], + "tags": [ + "custom" + ], + "summary": "Sample Hello API", + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/access-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理过期访问日志记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "清理访问日志", + "parameters": [ + { + "description": "清理参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/observability.AccessLogCleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按时间桶聚合访问日志并分页返回,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在指定时间桶内按 IP 聚合访问统计,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "string", + "description": "时间桶起始时间", "name": "bucket_started_at", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", - "description": "Fold minutes", + "description": "折叠时间窗口(分钟)", "name": "fold_minutes", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", - "description": "Page index", + "description": "页码", "name": "p", "in": "query" }, { "type": "integer", - "description": "Page size", + "description": "每页条数", "name": "page_size", "in": "query" }, { "type": "string", - "description": "Sort by", + "description": "排序字段", "name": "sort_by", "in": "query" }, { "type": "string", - "description": "Sort order", + "description": "排序方向", "name": "sort_order", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "折叠 IP 汇总列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogIPList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/access-logs/ip-summary": { + "/api/v1/d/access-logs/ip-summary": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "AccessLogs" + "openflare-observability" ], - "summary": "List access log IP summaries", + "summary": "列出访问日志 IP 汇总", "parameters": [ { "type": "string", - "description": "Node ID", + "description": "节点 ID", "name": "node_id", "in": "query" }, { "type": "string", - "description": "Remote address", + "description": "客户端 IP", "name": "remote_addr", "in": "query" }, { "type": "string", - "description": "Host", + "description": "请求 Host", "name": "host", "in": "query" }, { "type": "integer", - "description": "Page index", + "description": "页码", "name": "p", "in": "query" }, { "type": "integer", - "description": "Page size", + "description": "每页条数", "name": "page_size", "in": "query" }, { "type": "string", - "description": "Sort by", + "description": "排序字段", "name": "sort_by", "in": "query" }, { "type": "string", - "description": "Sort order", + "description": "排序方向", "name": "sort_order", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "IP 汇总列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPSummaryList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/access-logs/ip-summary/trend": { + "/api/v1/d/access-logs/ip-summary/trend": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "AccessLogs" + "openflare-observability" ], - "summary": "Get access log IP trend", + "summary": "获取访问日志 IP 趋势", "parameters": [ { "type": "string", - "description": "Node ID", + "description": "节点 ID", "name": "node_id", "in": "query" }, { "type": "string", - "description": "Remote address", + "description": "客户端 IP", "name": "remote_addr", - "in": "query", - "required": true + "in": "query" }, { "type": "string", - "description": "Host", + "description": "请求 Host", "name": "host", "in": "query" }, { "type": "integer", - "description": "Hours", + "description": "统计时间范围(小时)", "name": "hours", "in": "query" }, { "type": "integer", - "description": "Bucket minutes", + "description": "时间桶粒度(分钟)", "name": "bucket_minutes", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "IP 访问趋势", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/acme-accounts/default": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "AcmeAccounts" - ], - "summary": "Get default ACME account", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/apply-logs": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Report agent apply result", - "parameters": [ - { - "description": "Apply log payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.ApplyLogPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPTrendView" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/agent/config-versions/active": { + "/api/v1/d/acme-accounts/default": { "get": { "security": [ { - "AccessTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回系统默认 ACME 账号配置,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Agent" + "openflare-tls" ], - "summary": "Get active config for agent", + "summary": "获取默认 ACME 账号", "responses": { "200": { - "description": "OK", + "description": "默认 ACME 账号", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/nodes/heartbeat": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Report agent heartbeat", - "parameters": [ - { - "description": "Agent heartbeat payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.AgentNodePayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AcmeAccount" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/nodes/register": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Register or discover agent node", - "parameters": [ - { - "description": "Agent node payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.AgentNodePayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/waf/ip-groups/sync": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Sync WAF IP groups for agent", - "parameters": [ - { - "description": "WAF IP group sync payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.AgentWAFIPGroupSyncInput" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "403": { + "description": "无管理员权限", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/agent/ws": { + "/api/v1/d/apply-logs": { "get": { "security": [ { - "AccessTokenAuth": [] - } - ], - "tags": [ - "Agent" - ], - "summary": "Upgrade agent connection to websocket", - "responses": {} - } - }, - "/api/apply-logs/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ApplyLogs" + "openflare-apply-log" ], - "summary": "List apply logs", + "summary": "获取配置下发日志", "parameters": [ { "type": "string", - "description": "Node ID", + "description": "节点 ID 筛选", "name": "node_id", "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "pageNo", + "in": "query" + }, + { + "type": "integer", + "description": "页码(别名)", + "name": "page_no", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(别名)", + "name": "page_size", + "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "下发日志列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.ListResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/apply-logs/cleanup": { + "/api/v1/d/apply-logs/cleanup": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按保留天数清理历史下发记录,或删除全部记录,需要管理员权限", "consumes": [ "application/json" ], @@ -710,206 +4902,754 @@ const docTemplate = `{ "application/json" ], "tags": [ - "ApplyLogs" + "openflare-apply-log" + ], + "summary": "清理配置下发日志", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/apply_log.CleanupInput" + } + } ], - "summary": "Cleanup apply logs", "responses": { "200": { - "description": "OK", + "description": "清理结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/": { + "/api/v1/d/config-versions": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-config-version" ], - "summary": "List config versions", + "summary": "获取配置版本列表", "responses": { "200": { - "description": "OK", + "description": "配置版本列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ConfigVersionSummary" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/active": { + "/api/v1/d/config-versions/active": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回当前正在使用的配置版本,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-config-version" ], - "summary": "Get active config version", + "summary": "获取当前活跃配置版本", "responses": { "200": { - "description": "OK", + "description": "活跃配置版本", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限、不存在或无活跃版本", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/cleanup": { + "/api/v1/d/config-versions/cleanup": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "删除超出保留数量的非活跃配置版本,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-config-version" ], - "summary": "Cleanup old config versions", + "summary": "清理历史配置版本", "parameters": [ { - "description": "Cleanup request", + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config_version.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/diff": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "对比当前草稿配置与活跃版本之间的差异,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "对比草稿与活跃配置", + "responses": { + "200": { + "description": "配置差异", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigDiffResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/preview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "渲染并返回当前草稿配置的预览结果,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "预览当前草稿配置", + "responses": { + "200": { + "description": "配置预览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigPreviewResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/publish": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前草稿配置发布为新版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "发布配置版本", + "parameters": [ + { + "type": "boolean", + "description": "是否强制发布", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "发布成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定配置版本的完整快照与渲染内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本详情", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "配置版本详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定历史版本设为当前活跃配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "激活配置版本", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dashboard/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-dashboard" + ], + "summary": "获取仪表盘概览", + "responses": { + "200": { + "description": "仪表盘概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dashboard.OverviewPayload" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 DNS 账号", + "responses": { + "200": { + "description": "DNS 账号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 DNS 账号", + "parameters": [ + { + "description": "DNS 账号参数", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.CleanupConfigVersionRequest" + "$ref": "#/definitions/tls.DNSAccountInput" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功的 DNS 账号", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/diff": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ConfigVersions" - ], - "summary": "Diff current draft against active version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/config-versions/preview": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ConfigVersions" - ], - "summary": "Preview config rendering", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/config-versions/publish": { + "/api/v1/d/dns-accounts/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除 DNS 提供商账号,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-tls" ], - "summary": "Publish a new config version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/config-versions/{id}": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ConfigVersions" - ], - "summary": "Get config version detail", + "summary": "删除 DNS 账号", "parameters": [ { "type": "integer", - "description": "Version ID", + "description": "DNS 账号 ID", "name": "id", "in": "path", "required": true @@ -917,115 +5657,191 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/{id}/activate": { + "/api/v1/d/dns-accounts/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-tls" ], - "summary": "Activate an existing config version", + "summary": "更新 DNS 账号", "parameters": [ { "type": "integer", - "description": "Version ID", + "description": "DNS 账号 ID", "name": "id", "in": "path", "required": true + }, + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的 DNS 账号", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/dashboard/overview": { + "/api/v1/d/managed-domains": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回全部托管域名及关联证书,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Dashboard" + "openflare-tls" ], - "summary": "Get dashboard overview", + "summary": "列出托管域名", "responses": { "200": { - "description": "OK", + "description": "托管域名列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ManagedDomain" + } + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } - } - } - } - }, - "/api/dns-accounts/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "DnsAccounts" - ], - "summary": "List DNS accounts", - "responses": { - "200": { - "description": "OK", + }, + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -1033,9 +5849,10 @@ const docTemplate = `{ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的托管域名记录,需要管理员权限", "consumes": [ "application/json" ], @@ -1043,322 +5860,85 @@ const docTemplate = `{ "application/json" ], "tags": [ - "DnsAccounts" + "openflare-tls" ], - "summary": "Create DNS account", + "summary": "创建托管域名", "parameters": [ { - "description": "DNS account payload", - "name": "payload", + "description": "托管域名参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.DnsAccountInput" + "$ref": "#/definitions/tls.ManagedDomainInput" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功的托管域名", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/dns-accounts/{id}/delete": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "DnsAccounts" - ], - "summary": "Delete DNS account", - "parameters": [ - { - "type": "integer", - "description": "DNS Account ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/dns-accounts/{id}/update": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "DnsAccounts" - ], - "summary": "Update DNS account", - "parameters": [ - { - "type": "integer", - "description": "DNS Account ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "DNS account payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.DnsAccountInput" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/apply-log": { - "post": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Flared" - ], - "summary": "Report OpenFlared apply result", - "parameters": [ - { - "description": "Apply log payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.ApplyLogPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/config/active": { - "get": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Flared" - ], - "summary": "Get active tunnel config for OpenFlared", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/heartbeat": { - "post": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Flared" - ], - "summary": "Report OpenFlared heartbeat", - "parameters": [ - { - "description": "Flared heartbeat payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.FlaredHeartbeatPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ManagedDomain" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/ws": { - "get": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "tags": [ - "Flared" - ], - "summary": "Upgrade OpenFlared connection to websocket", - "responses": {} - } - }, - "/api/managed-domains/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ManagedDomains" - ], - "summary": "List managed domains", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ManagedDomains" - ], - "summary": "Create managed domain", - "parameters": [ - { - "description": "Managed domain payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.ManagedDomainInput" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/managed-domains/match": { + "/api/v1/d/managed-domains/match": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按域名查询可用的证书匹配候选,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ManagedDomains" + "openflare-tls" ], - "summary": "Match certificate for domain", + "summary": "匹配托管域名证书", "parameters": [ { "type": "string", - "description": "Domain", + "description": "域名", "name": "domain", "in": "query", "required": true @@ -1366,33 +5946,69 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "证书匹配结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/tls.ManagedDomainMatchResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/managed-domains/{id}/delete": { + "/api/v1/d/managed-domains/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除托管域名,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ManagedDomains" + "openflare-tls" ], - "summary": "Delete managed domain", + "summary": "删除托管域名", "parameters": [ { "type": "integer", - "description": "Managed domain ID", + "description": "托管域名 ID", "name": "id", "in": "path", "required": true @@ -1400,29 +6016,52 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/managed-domains/{id}/update": { + "/api/v1/d/managed-domains/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新托管域名,需要管理员权限", "consumes": [ "application/json" ], @@ -1430,65 +6069,132 @@ const docTemplate = `{ "application/json" ], "tags": [ - "ManagedDomains" + "openflare-tls" ], - "summary": "Update managed domain", + "summary": "更新托管域名", "parameters": [ { "type": "integer", - "description": "Managed domain ID", + "description": "托管域名 ID", "name": "id", "in": "path", "required": true }, { - "description": "Managed domain payload", - "name": "payload", + "description": "托管域名参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.ManagedDomainInput" + "$ref": "#/definitions/tls.ManagedDomainInput" } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的托管域名", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ManagedDomain" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/": { + "/api/v1/d/nodes": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回所有节点及最新配置下发记录,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "List nodes", + "summary": "获取节点列表", "responses": { "200": { - "description": "OK", + "description": "节点列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/node.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -1496,9 +6202,10 @@ const docTemplate = `{ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的边缘节点记录,需要管理员权限", "consumes": [ "application/json" ], @@ -1506,153 +6213,322 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Create node", + "summary": "创建节点", "parameters": [ { - "description": "Node payload", - "name": "payload", + "description": "节点参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.NodeInput" + "$ref": "#/definitions/node.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/bootstrap-token": { + "/api/v1/d/nodes/bootstrap-token": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回全局节点发现引导令牌,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Get global discovery token", + "summary": "获取引导令牌", "responses": { "200": { - "description": "OK", + "description": "引导令牌", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/bootstrap-token/rotate": { + "/api/v1/d/nodes/bootstrap-token/rotate": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "重新生成全局节点发现引导令牌,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Rotate global discovery token", + "summary": "轮换引导令牌", "responses": { "200": { - "description": "OK", + "description": "新引导令牌", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/agent-release": { + "/api/v1/d/nodes/{id}/agent-release": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定节点可用的最新 Agent 版本信息,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Check latest agent release for node", + "summary": "获取 Agent 发布信息", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true }, { "type": "string", - "description": "stable or preview", + "description": "发布渠道", "name": "channel", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "Agent 发布信息", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.AgentReleaseInfo" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/agent-update": { + "/api/v1/d/nodes/{id}/agent-update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "向指定节点下发 Agent 自更新指令,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Request agent self-update on node", + "summary": "请求 Agent 更新", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数(可选)", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/node.AgentUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定节点记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "删除节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1660,40 +6536,63 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/delete": { + "/api/v1/d/nodes/{id}/force-sync": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "向指定节点下发强制同步当前活跃配置的指令,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Delete node", + "summary": "请求强制同步配置", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1701,134 +6600,139 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "同步请求已下发", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/force-sync": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Nodes" - ], - "summary": "Request force sync config on node", - "parameters": [ - { - "type": "integer", - "description": "Node ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/nodes/{id}/observability": { + "/api/v1/d/nodes/{id}/observability": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定节点的指标、健康事件与流量分析数据,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Get node observability details", + "summary": "获取节点可观测性数据", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true }, { "type": "integer", - "description": "Lookback window in hours", + "description": "统计时间范围(小时)", "name": "hours", "in": "query" }, { "type": "integer", - "description": "Max records per section", + "description": "返回记录数量上限", "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "可观测性数据", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.ObservabilityView" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/observability/cleanup": { + "/api/v1/d/nodes/{id}/observability/cleanup": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "清理指定节点的历史健康事件记录,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Cleanup node health events", + "summary": "清理节点健康事件", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1836,40 +6740,63 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "清理结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.HealthEventCleanupResult" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/openresty-restart": { + "/api/v1/d/nodes/{id}/openresty-restart": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "向指定节点下发 OpenResty 重启指令,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Request openresty restart on node", + "summary": "请求重启 OpenResty", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1877,29 +6804,52 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "重启请求已下发", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/update": { + "/api/v1/d/nodes/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "更新指定节点的配置信息,需要管理员权限", "consumes": [ "application/json" ], @@ -1907,236 +6857,503 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Update node", + "summary": "更新节点", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true }, { - "description": "Node payload", - "name": "payload", + "description": "节点参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.NodeInput" + "$ref": "#/definitions/node.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "更新成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/option/": { + "/api/v1/d/notice": { "get": { + "description": "返回 OpenFlare 控制台公告文本,无需登录", "produces": [ "application/json" ], "tags": [ - "Options" + "openflare-option" ], - "summary": "List editable options", + "summary": "获取系统公告", "responses": { "200": { - "description": "OK", + "description": "系统公告", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/option/database/cleanup": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Cleanup observability tables", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/option/geoip/lookup": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Test GeoIP lookup", - "parameters": [ - { - "description": "GeoIP lookup payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.geoIPLookupRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/option/update": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Update option", - "parameters": [ - { - "description": "Option payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.Option" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "500": { + "description": "内部错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } } } } }, - "/api/option/update-batch": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Batch update options", - "parameters": [ - { - "description": "Batch option payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.optionBatchPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/proxy-routes/": { + "/api/v1/d/option": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回全部非敏感 OpenFlare 配置项,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "列出 OpenFlare 配置项", + "responses": { + "200": { + "description": "配置项列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/database/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按目标与保留天数清理可观测性相关数据表,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-option" + ], + "summary": "清理可观测性数据库", + "parameters": [ + { + "description": "清理参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/option.databaseCleanupInput" + } + } ], - "summary": "List proxy routes", "responses": { "200": { - "description": "OK", + "description": "清理结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.databaseCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/geoip/lookup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按提供商与 IP 查询地理位置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "GeoIP 地址查询", + "parameters": [ + { + "description": "查询参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.geoIPLookupRequest" + } + } + ], + "responses": { + "200": { + "description": "GeoIP 查询结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.geoIPLookupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新单个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "更新 OpenFlare 配置项", + "parameters": [ + { + "description": "配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update-batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "批量更新多个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "批量更新 OpenFlare 配置项", + "parameters": [ + { + "description": "批量配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.optionBatchPayload" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有源站及关联代理规则数量,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站列表", + "responses": { + "200": { + "description": "源站列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -2144,9 +7361,10 @@ const docTemplate = `{ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的上游源站记录,需要管理员权限", "consumes": [ "application/json" ], @@ -2154,56 +7372,79 @@ const docTemplate = `{ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Create proxy route", + "summary": "创建源站", "parameters": [ { - "description": "Proxy route payload", - "name": "payload", + "description": "源站参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.ProxyRouteInput" + "$ref": "#/definitions/origin.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/proxy-routes/{id}": { + "/api/v1/d/origins/{id}": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定源站信息及关联代理规则摘要,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Get proxy route detail", + "summary": "获取源站详情", "parameters": [ { "type": "integer", - "description": "Route ID", + "description": "源站 ID", "name": "id", "in": "path", "required": true @@ -2211,40 +7452,63 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "源站详情", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.DetailView" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/proxy-routes/{id}/delete": { + "/api/v1/d/origins/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "删除指定源站记录,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Delete proxy route", + "summary": "删除源站", "parameters": [ { "type": "integer", - "description": "Route ID", + "description": "源站 ID", "name": "id", "in": "path", "required": true @@ -2252,29 +7516,52 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/proxy-routes/{id}/update": { + "/api/v1/d/origins/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "更新指定源站的配置信息,需要管理员权限", "consumes": [ "application/json" ], @@ -2282,145 +7569,126 @@ const docTemplate = `{ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Update proxy route", + "summary": "更新源站", "parameters": [ { "type": "integer", - "description": "Route ID", + "description": "源站 ID", "name": "id", "in": "path", "required": true }, { - "description": "Proxy route payload", - "name": "payload", + "description": "源站参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.ProxyRouteInput" + "$ref": "#/definitions/origin.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "更新成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/relay/heartbeat": { - "post": { + "/api/v1/d/pages": { + "get": { "security": [ { - "AccessTokenAuth": [] + "SessionCookie": [] } ], - "consumes": [ - "application/json" - ], + "description": "返回全部 OpenFlare Pages 项目,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Relay" - ], - "summary": "Report relay heartbeat", - "parameters": [ - { - "description": "Relay heartbeat payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.RelayHeartbeatPayload" - } - } + "openflare-pages" ], + "summary": "列出 Pages 项目", "responses": { "200": { - "description": "OK", + "description": "Pages 项目列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.View" + } + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } - } - } - } - }, - "/api/relay/ws": { - "get": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "tags": [ - "Relay" - ], - "summary": "Upgrade relay connection to websocket", - "responses": {} - } - }, - "/api/status": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Public" - ], - "summary": "Get server status", - "responses": { - "200": { - "description": "OK", + }, + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } - } - } - } - }, - "/api/tls-certificates/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "TLSCertificates" - ], - "summary": "List TLS certificates", - "responses": { - "200": { - "description": "OK", + }, + "403": { + "description": "无管理员权限", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -2428,9 +7696,10 @@ const docTemplate = `{ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的 OpenFlare Pages 项目,需要管理员权限", "consumes": [ "application/json" ], @@ -2438,91 +7707,372 @@ const docTemplate = `{ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-pages" ], - "summary": "Create TLS certificate from PEM", + "summary": "创建 Pages 项目", "parameters": [ { - "description": "TLS certificate payload", - "name": "payload", + "description": "项目参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSCertificateInput" + "$ref": "#/definitions/pages.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功的项目", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/apply": { - "post": { + "/api/v1/d/pages/deployments/{deployment_id}/files": { + "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], - "consumes": [ - "application/json" - ], + "description": "返回指定部署包含的文件清单,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-pages" ], - "summary": "Apply TLS certificate via ACME", + "summary": "列出 Pages 部署文件", "parameters": [ { - "description": "TLS apply payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.TLSApplyInput" - } + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "OK", + "description": "部署文件列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentFileView" + } + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/import-file": { + "/api/v1/d/pages/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 Pages 项目详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 项目详情", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Pages 项目详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定项目的全部部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为指定项目上传 ZIP 部署包,需要管理员权限", "consumes": [ "multipart/form-data" ], @@ -2530,33 +8080,945 @@ const docTemplate = `{ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-pages" ], - "summary": "Import TLS certificate from files", + "summary": "上传 Pages 部署包", "parameters": [ { - "type": "string", - "description": "Certificate name", - "name": "name", - "in": "formData", + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", "required": true }, + { + "type": "file", + "description": "部署包 ZIP 文件", + "name": "package", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定部署设为项目当前生效版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "激活 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定项目的部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "更新后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有代理规则配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则列表", + "responses": { + "200": { + "description": "代理规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的反向代理规则,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "创建代理规则", + "parameters": [ + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定代理规则的完整配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则详情", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "代理规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定代理规则,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "删除代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定代理规则的配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "更新代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/status": { + "get": { + "description": "返回版本、认证源与系统公开配置,无需登录", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "获取 OpenFlare 公开状态", + "responses": { + "200": { + "description": "公开状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.statusView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 TLS 证书(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 TLS 证书", + "responses": { + "200": { + "description": "证书列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从 PEM 文本创建 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 TLS 证书", + "parameters": [ + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "通过 ACME 申请新的 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "申请 ACME 证书", + "parameters": [ + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "申请中的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/import-file": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "上传证书与私钥文件创建 TLS 证书,需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "从文件导入 TLS 证书", + "parameters": [ { "type": "string", - "description": "Remark", + "description": "证书名称", + "name": "name", + "in": "formData" + }, + { + "type": "string", + "description": "备注", "name": "remark", "in": "formData" }, { "type": "file", - "description": "Certificate file", + "description": "证书文件", "name": "cert_file", "in": "formData", "required": true }, { "type": "file", - "description": "Private key file", + "description": "私钥文件", "name": "key_file", "in": "formData", "required": true @@ -2564,40 +9026,69 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "导入成功的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}": { + "/api/v1/d/tls-certificates/{id}": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Get TLS certificate detail", + "summary": "获取 TLS 证书详情", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2605,40 +9096,75 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "证书详情", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/content": { + "/api/v1/d/tls-certificates/{id}/content": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 返回证书与私钥 PEM 内容,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Get TLS certificate PEM content", + "summary": "获取 TLS 证书 PEM 内容", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2646,29 +9172,64 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "证书 PEM 内容", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/tls.CertificateContent" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/convert-acme": { + "/api/v1/d/tls-certificates/{id}/convert-acme": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "将已上传证书转换为 ACME 自动续期模式,需要管理员权限", "consumes": [ "application/json" ], @@ -2676,63 +9237,98 @@ const docTemplate = `{ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Convert uploaded TLS certificate to ACME managed certificate", + "summary": "将证书转为 ACME 管理", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true }, { - "description": "TLS apply payload", - "name": "payload", + "description": "ACME 申请参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSApplyInput" + "$ref": "#/definitions/tls.ApplyInput" } } ], "responses": { "200": { - "description": "OK", + "description": "转换后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/delete": { + "/api/v1/d/tls-certificates/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除 TLS 证书,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Delete TLS certificate", + "summary": "删除 TLS 证书", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2740,40 +9336,63 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/renew": { + "/api/v1/d/tls-certificates/{id}/renew": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "手动触发 ACME 证书续期,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Renew TLS certificate", + "summary": "续期 ACME 证书", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2781,29 +9400,64 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "续期后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/update": { + "/api/v1/d/tls-certificates/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新 TLS 证书 PEM 信息,需要管理员权限", "consumes": [ "application/json" ], @@ -2811,52 +9465,87 @@ const docTemplate = `{ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Update TLS certificate from PEM", + "summary": "更新 TLS 证书", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true }, { - "description": "TLS certificate payload", - "name": "payload", + "description": "证书参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSCertificateInput" + "$ref": "#/definitions/tls.CertificateInput" } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/update-acme": { + "/api/v1/d/tls-certificates/{id}/update-acme": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新 ACME 证书申请配置,需要管理员权限", "consumes": [ "application/json" ], @@ -2864,81 +9553,87 @@ const docTemplate = `{ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Update ACME TLS certificate", + "summary": "更新 ACME 证书配置", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true }, { - "description": "TLS apply payload", - "name": "payload", + "description": "ACME 申请参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSApplyInput" + "$ref": "#/definitions/tls.ApplyInput" } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/update/latest-release": { - "get": { + "/api/v1/d/uptimekuma/sync": { + "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], - "produces": [ - "application/json" - ], - "tags": [ - "Update" - ], - "summary": "Get latest GitHub release", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/update/logs/ws": { - "get": { - "tags": [ - "Update" - ], - "summary": "Stream server upgrade logs over websocket", - "responses": {} - } - }, - "/api/update/manual-upgrade": { - "post": { + "description": "将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限", "consumes": [ "application/json" ], @@ -2946,22 +9641,1601 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Update" + "openflare-option" ], - "summary": "Confirm upgrade with previously uploaded server binary", + "summary": "同步 Uptime Kuma", "responses": { "200": { - "description": "OK", + "description": "同步成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/update/manual-upload": { + "/api/v1/d/waf/ip-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF IP 组", + "responses": { + "200": { + "description": "IP 组列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF IP 组", + "parameters": [ + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "测试 WAF IP 组自动配置", + "parameters": [ + { + "description": "自动配置参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupAutoTestInput" + } + } + ], + "responses": { + "200": { + "description": "测试结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupAutoTestResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 WAF IP 组详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF IP 组详情", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "IP 组详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 WAF IP 组外部 IP 同步,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 WAF 规则组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF 规则组", + "responses": { + "200": { + "description": "规则组列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 WAF 规则组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF 规则组", + "parameters": [ + { + "description": "规则组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.RuleGroupInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的规则组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 WAF 规则组详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF 规则组详情", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "规则组详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 WAF 规则组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/sites": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "替换 WAF 规则组关联的代理站点列表,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "替换规则组站点绑定", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "站点 ID 列表", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IDsRequest" + } + } + ], + "responses": { + "200": { + "description": "更新后的规则组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 WAF 规则组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.RuleGroupInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的规则组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/sites/{route_id}/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回代理站点关联的 WAF 规则组绑定,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "替换代理站点关联的 WAF 规则组列表,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "替换站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + }, + { + "description": "规则组 ID 列表", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IDsRequest" + } + } + ], + "responses": { + "200": { + "description": "更新后的站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/callback": { + "post": { + "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 回调处理", + "parameters": [ + { + "description": "回调请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oauth.CallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "登录或绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthCallbackResult" + } + } + } + ] + } + }, + "400": { + "description": "state 无效、参数错误或认证源错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "绑定场景未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "OAuth 认证失败或内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取外部帐号列表", + "responses": { + "200": { + "description": "外部帐号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ExternalAccountView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解除外部帐号绑定", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "外部帐号绑定记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "解除绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或解除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/login": { + "get": { + "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取登录授权地址", + "parameters": [ + { + "type": "string", + "description": "认证源名称,为空使用第一个启用的认证源", + "name": "source", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未配置", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "Redis 异常 or 构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/sources": { + "get": { + "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取可用登录源", + "responses": { + "200": { + "description": "登录源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/oauth.AuthSourceView" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/oauth/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/{source}/authorize": { + "get": { + "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "发起指定认证源授权", + "parameters": [ + { + "type": "string", + "description": "认证源名称", + "name": "source", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", + "name": "purpose", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "Redis 异常或构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重", "consumes": [ "multipart/form-data" ], @@ -2969,35 +11243,939 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Update" + "upload" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "要上传的文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)", + "name": "type", + "in": "formData" + }, + { + "type": "string", + "description": "额外的 JSON 格式元数据", + "name": "metadata", + "in": "formData" + } ], - "summary": "Upload server binary and inspect version before upgrade", "responses": { "200": { - "description": "OK", + "description": "上传成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "400": { + "description": "请求参数错误或文件受限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/update/upgrade": { - "post": { + "/api/v1/upload/my": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤", "produces": [ "application/json" ], "tags": [ - "Update" + "upload" + ], + "summary": "获取我的文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + } ], - "summary": "Upgrade server binary from latest GitHub release", "responses": { "200": { - "description": "OK", + "description": "查询成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listMyFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/upload/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新当前用户本人的文件名或访问权限模式 (AccessMode)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "更新我的文件信息", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新字段", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateMyFileRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前用户本人的文件状态置为 deleted(软删除)", + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "删除我的文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的所有 active access tokens(脱敏后)", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前用户的 AccessToken 列表", + "responses": { + "200": { + "description": "令牌列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AccessToken" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "创建一个新的 AccessToken", + "parameters": [ + { + "description": "令牌名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "新建令牌成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或超限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "撤销并删除一个属于当前用户的 API 访问令牌", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "删除一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "轮换一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "令牌轮换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/change-password": { + "post": { + "description": "修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改用户密码", + "parameters": [ + { + "description": "修改密码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.changePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "修改密码成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "原密码错误或新密码不符合要求", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "请先登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/login": { + "post": { + "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户密码登录", + "parameters": [ + { + "description": "登录请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.loginRequest" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "用户名或密码错误、帐号已禁用等", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除用户登录 Session,完成退出", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/profile": { + "put": { + "description": "修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改当前登录用户的个人资料", + "parameters": [ + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功,返回更新后的用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "邮箱已被占用或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/register": { + "post": { + "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.registerRequest" + } + } + ], + "responses": { + "200": { + "description": "注册并登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误、用户名已存在或注册已关闭", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/self": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/send-email-code": { + "post": { + "description": "向指定邮箱发送验证码(用于注册场景)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "发送邮箱验证码", + "parameters": [ + { + "description": "发送验证码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.sendEmailCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "发送成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/f/{id}": { + "get": { + "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "summary": "获取已上传文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功获取文件内容", + "schema": { + "type": "file" + } + }, + "400": { + "description": "文件 ID 格式错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件未找到", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/robots.txt": { + "get": { + "description": "根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容", + "produces": [ + "text/plain" + ], + "tags": [ + "config" + ], + "summary": "获取 robots.txt", + "responses": { + "200": { + "description": "robots.txt 内容", + "schema": { + "type": "string" } } } @@ -3005,350 +12183,1073 @@ const docTemplate = `{ } }, "definitions": { - "controller.CleanupConfigVersionRequest": { + "apply_log.CleanupInput": { "type": "object", - "required": [ - "keep_count" - ], "properties": { - "keep_count": { - "type": "integer", - "minimum": 3 + "delete_all": { + "type": "boolean" + }, + "retention_days": { + "type": "integer" } } }, - "controller.DnsAccountInput": { + "apply_log.CleanupResult": { "type": "object", "properties": { - "authorization": { + "cutoff": { "type": "string" }, + "delete_all": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.ListResult": { + "type": "object", + "properties": { + "current": { + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + }, + "total": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + } + }, + "auth_source.AuthSourceRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, "name": { "type": "string" }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, "type": { "type": "string" } } }, - "controller.geoIPLookupRequest": { + "auth_source.ToggleAuthSourceRequest": { "type": "object", "properties": { - "ip": { - "type": "string" + "is_active": { + "type": "boolean" + } + } + }, + "cache.updateCacheConfigRequest": { + "type": "object", + "required": [ + "max_size_mb", + "ttl_minutes" + ], + "properties": { + "lru_enabled": { + "type": "boolean" }, - "provider": { - "type": "string" - } - } - }, - "controller.optionBatchPayload": { - "type": "object", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Option" - } - } - } - }, - "model.Option": { - "type": "object", - "properties": { - "key": { - "type": "string" + "max_size_mb": { + "type": "integer", + "minimum": 1 }, - "value": { - "type": "string" + "ttl_minutes": { + "type": "integer", + "minimum": 0 } } }, - "service.AgentBufferedObservabilityRecord": { + "cap.ChallengeResponse": { "type": "object", "properties": { - "access_logs": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeAccessLog" + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } } }, - "openresty_observation": { - "$ref": "#/definitions/service.AgentNodeOpenrestyObservation" + "expires": { + "description": "ms timestamp", + "type": "integer" }, - "snapshot": { - "$ref": "#/definitions/service.AgentNodeMetricSnapshot" + "token": { + "type": "string" + } + } + }, + "cap.challengeRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + } + }, + "cap.redeemRequest": { + "type": "object", + "required": [ + "solutions", + "token" + ], + "properties": { + "scope": { + "type": "string" }, - "traffic_report": { - "$ref": "#/definitions/service.AgentNodeTrafficReport" + "solutions": { + "type": "array", + "items": { + "type": "integer" + } }, - "window_started_at_unix": { + "token": { + "type": "string" + } + } + }, + "config_version.CleanupInput": { + "type": "object", + "properties": { + "keep_count": { "type": "integer" } } }, - "service.AgentNodeAccessLog": { + "config_version.CleanupResult": { "type": "object", "properties": { - "host": { - "type": "string" - }, - "logged_at_unix": { + "deleted_count": { "type": "integer" }, - "path": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "status_code": { - "type": "integer" - } - } - }, - "service.AgentNodeHealthEvent": { - "type": "object", - "properties": { - "event_type": { - "type": "string" - }, "message": { "type": "string" + } + } + }, + "config_version.ConfigDiffResult": { + "type": "object", + "properties": { + "active_version": { + "type": "string" }, - "metadata": { - "type": "object", - "additionalProperties": { + "active_website_count": { + "type": "integer" + }, + "added_domains": { + "type": "array", + "items": { "type": "string" } }, - "severity": { + "added_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed_option_details": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.ConfigOptionDiffItem" + } + }, + "changed_option_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "current_website_count": { + "type": "integer" + }, + "main_config_changed": { + "type": "boolean" + }, + "modified_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "modified_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "waf_config_changed": { + "type": "boolean" + } + } + }, + "config_version.ConfigOptionDiffItem": { + "type": "object", + "properties": { + "current_value": { "type": "string" }, - "triggered_at_unix": { + "key": { + "type": "string" + }, + "previous_value": { + "type": "string" + } + } + }, + "config_version.ConfigPreviewResult": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "route_config": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "snapshot_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.SupportFile" + } + }, + "website_count": { "type": "integer" } } }, - "service.AgentNodeMetricSnapshot": { + "config_version.SupportFile": { "type": "object", "properties": { - "captured_at_unix": { - "type": "integer" + "content": { + "type": "string" }, - "cpu_usage_percent": { + "path": { + "type": "string" + } + } + }, + "dashboard.Capacity": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { "type": "number" }, - "disk_read_bytes": { + "average_memory_usage_percent": { + "type": "number" + }, + "high_cpu_nodes": { "type": "integer" }, - "disk_write_bytes": { + "high_memory_nodes": { "type": "integer" }, - "memory_total_bytes": { - "type": "integer" - }, - "memory_used_bytes": { - "type": "integer" - }, - "network_rx_bytes": { - "type": "integer" - }, - "network_tx_bytes": { - "type": "integer" - }, - "storage_total_bytes": { - "type": "integer" - }, - "storage_used_bytes": { + "high_storage_nodes": { "type": "integer" } } }, - "service.AgentNodeOpenrestyObservation": { + "dashboard.OverviewPayload": { "type": "object", "properties": { - "captured_at_unix": { - "type": "integer" + "capacity": { + "$ref": "#/definitions/dashboard.Capacity" }, - "openresty_connections": { - "type": "integer" + "distributions": { + "$ref": "#/definitions/dashboard.distributionsPayload" }, - "openresty_rx_bytes": { - "type": "integer" + "generated_at": {}, + "nodes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "openresty_tx_bytes": { - "type": "integer" + "summary": { + "$ref": "#/definitions/dashboard.Summary" + }, + "traffic": { + "$ref": "#/definitions/dashboard.Traffic" + }, + "trends": { + "$ref": "#/definitions/dashboard.trendsPayload" } } }, - "service.AgentNodePayload": { + "dashboard.Summary": { "type": "object", "properties": { - "access_logs": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeAccessLog" - } - }, - "buffered_observability": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentBufferedObservabilityRecord" - } - }, - "current_version": { - "type": "string" - }, - "ext_version": { - "type": "string" - }, - "health_events": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeHealthEvent" - } - }, - "ip": { - "type": "string" - }, - "last_error": { - "type": "string" - }, - "name": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "openresty_message": { - "type": "string" - }, - "openresty_observation": { - "$ref": "#/definitions/service.AgentNodeOpenrestyObservation" - }, - "openresty_status": { - "type": "string" - }, - "profile": { - "$ref": "#/definitions/service.AgentNodeSystemProfile" - }, - "snapshot": { - "$ref": "#/definitions/service.AgentNodeMetricSnapshot" - }, - "traffic_report": { - "$ref": "#/definitions/service.AgentNodeTrafficReport" - }, - "version": { - "type": "string" - }, - "waf_ip_group_checksums": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "service.AgentNodeSystemProfile": { - "type": "object", - "properties": { - "architecture": { - "type": "string" - }, - "cpu_cores": { + "offline_nodes": { "type": "integer" }, - "cpu_model": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "kernel_version": { - "type": "string" - }, - "os_name": { - "type": "string" - }, - "os_version": { - "type": "string" - }, - "reported_at_unix": { + "online_nodes": { "type": "integer" }, - "total_disk_bytes": { + "pending_nodes": { "type": "integer" }, - "total_memory_bytes": { + "total_nodes": { "type": "integer" }, - "uptime_seconds": { + "unhealthy_nodes": { "type": "integer" } } }, - "service.AgentNodeTrafficReport": { + "dashboard.Traffic": { "type": "object", "properties": { "error_count": { "type": "integer" }, + "estimated_qps": { + "type": "number" + }, + "reported_nodes": { + "type": "integer" + }, "request_count": { "type": "integer" }, + "unique_visitors": { + "type": "integer" + } + } + }, + "dashboard.distributionsPayload": { + "type": "object", + "properties": { "source_countries": { - "type": "object", - "additionalProperties": { - "type": "integer" + "type": "array", + "items": { + "type": "array", + "items": {} } }, "status_codes": { - "type": "object", - "additionalProperties": { - "type": "integer" + "type": "array", + "items": { + "type": "array", + "items": {} } }, "top_domains": { - "type": "object", - "additionalProperties": { - "type": "integer" + "type": "array", + "items": { + "type": "array", + "items": {} } - }, - "unique_visitor_count": { - "type": "integer" - }, - "window_ended_at_unix": { - "type": "integer" - }, - "window_started_at_unix": { - "type": "integer" } } }, - "service.AgentWAFIPGroupSyncInput": { + "dashboard.trendsPayload": { "type": "object", "properties": { - "checksums": { - "type": "object", - "additionalProperties": { + "capacity_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "network_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "traffic_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, + "db_manage.DBOverviewResponse": { + "type": "object", + "properties": { + "connections": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "string" + }, + "table_count": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "db_manage.ExecuteSQLRequest": { + "type": "object", + "required": [ + "sql" + ], + "properties": { + "sql": { + "type": "string" + } + } + }, + "db_manage.ExecuteSQLResponse": { + "type": "object", + "properties": { + "affected_rows": { + "type": "integer" + }, + "columns": { + "type": "array", + "items": { "type": "string" } }, - "ids": { + "execution_time_ms": { + "type": "integer" + }, + "results": { "type": "array", "items": { - "type": "integer" + "type": "object", + "additionalProperties": true + } + }, + "type": { + "description": "\"select\" 或 \"exec\"", + "type": "string" + } + } + }, + "diskcache.Status": { + "type": "object", + "properties": { + "base_path": { + "type": "string" + }, + "keys_count": { + "type": "integer" + }, + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "ttl_minutes": { + "type": "integer" + } + } + }, + "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "handler.batchDownloadRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" } } } }, - "service.ApplyLogPayload": { + "handler.distributionItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.fileStatsResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + }, + "total_count": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.trendItem" + } + }, + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + } + } + }, + "handler.listFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.listMyFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.updateMyFileRequest": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer", + "enum": [ + 0, + 1 + ] + }, + "file_name": { + "type": "string", + "maxLength": 255 + } + } + }, + "logger.LogEntry": { + "type": "object", + "properties": { + "data": { + "description": "一行日志原文(含换行符)", + "type": "string" + }, + "index": { + "description": "全局递增序号", + "type": "integer" + } + } + }, + "logs.accessLogItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "headers": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "ip": { + "type": "string" + }, + "latency": { + "type": "integer" + }, + "method": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "path": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_agent": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "logs.accessLogsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.accessLogItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "logs.browserItem": { + "type": "object", + "properties": { + "browser": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + }, + "logs.logsAnalyticsResponse": { + "type": "object", + "properties": { + "browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.browserItem" + } + }, + "top_users": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.topUserItem" + } + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.trendItem" + } + } + } + }, + "logs.logsResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/definitions/logger.LogEntry" + } + }, + "next_cursor": { + "description": "用于加载更早日志的 cursor", + "type": "integer" + } + } + }, + "logs.topUserItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "logs.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + } + } + }, + "model.AccessToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "masked_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "model.AcmeAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.AuthSource": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret_configured": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ConfigVersion": { "type": "object", "properties": { "checksum": { "type": "string" }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "snapshot_json": { + "type": "string" + }, + "support_files_json": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.ConfigVersionSummary": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "version": { + "type": "string" + } + } + }, + "model.DNSAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ExternalAccountView": { + "type": "object", + "properties": { + "auth_source_id": { + "type": "integer" + }, + "auth_source_label": { + "type": "string" + }, + "auth_source_name": { + "type": "string" + }, + "auth_source_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "external_username": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "model.ManagedDomain": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.OpenFlareApplyLog": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, "main_config_checksum": { "type": "string" }, @@ -3372,67 +13273,746 @@ const docTemplate = `{ } } }, - "service.FlaredConnectedRelay": { + "model.OpenFlareHealthEvent": { "type": "object", "properties": { - "proxy_count": { + "created_at": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "first_triggered_at": { + "type": "string" + }, + "id": { "type": "integer" }, - "relay_node_id": { + "last_triggered_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata_json": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "resolved_at": { + "type": "string" + }, + "severity": { "type": "string" }, "status": { "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "service.FlaredHeartbeatPayload": { + "model.OpenFlareMetricSnapshot": { "type": "object", "properties": { - "client_version": { + "captured_at": { "type": "string" }, - "connected_relays": { + "cpu_usage_percent": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "network_rx_bytes": { + "type": "integer" + }, + "network_tx_bytes": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "model.OpenFlareNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kernel_version": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "model.OpenFlareOption": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "model.OpenFlareRequestReport": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "error_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "request_count": { + "type": "integer" + }, + "source_countries_json": { + "type": "string" + }, + "status_codes_json": { + "type": "string" + }, + "top_domains_json": { + "type": "string" + }, + "unique_visitor_count": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "model.PushChannel": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "description": "备注", + "type": "string" + }, + "enabled": { + "description": "通道是否启用", + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "通道名称,仅英文字母和下划线,唯一", + "type": "string" + }, + "other": { + "description": "请求体/SMTP 密码等", + "type": "string" + }, + "token": { + "description": "鉴权令牌或发信用户名等", + "type": "string" + }, + "type": { + "description": "通道类型:custom, lark, email", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "url": { + "description": "请求地址,HTTPS 协议或 SMTP 地址", + "type": "string" + } + } + }, + "model.PushEvent": { + "type": "object", + "properties": { + "channels": { + "description": "推送渠道列表,如 [\"lark\"]", "type": "array", "items": { - "$ref": "#/definitions/service.FlaredConnectedRelay" + "type": "string" } }, - "current_checksum": { + "created_at": { + "type": "string" + }, + "enabled": { + "description": "是否启用", + "type": "boolean" + }, + "event_key": { + "description": "如 admin_login", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "如 管理员登录", + "type": "string" + }, + "targets": { + "description": "推送目标用户/邮箱列表", + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "description": "关联的异步任务类型", + "type": "string" + }, + "template": { + "description": "消息模板 JSON", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.PushHistory": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "error_msg": { + "type": "string" + }, + "event_key": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "string" + }, + "status": { + "description": "success / failed", + "type": "string" + }, + "target": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "model.Schedule": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.SystemConfig": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer" + } + } + }, + "model.TLSCertificate": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "not_after": { + "type": "string" + }, + "not_before": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.TaskExecution": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "error_message": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "log": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "result": { + "type": "string" + }, + "retry_count": { + "type": "integer" + }, + "retryable": { + "type": "boolean" + }, + "started_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/model.TaskExecutionStatus" + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.TaskExecutionStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "succeeded", + "failed" + ], + "x-enum-varnames": [ + "TaskExecutionStatusPending", + "TaskExecutionStatusRunning", + "TaskExecutionStatusSucceeded", + "TaskExecutionStatusFailed" + ] + }, + "model.Template": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.Upload": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "extension": { + "description": "文件后缀名 (不含点,如 png, pdf)", + "type": "string" + }, + "file_name": { + "description": "原始文件名 (例如: image.png)", + "type": "string" + }, + "file_path": { + "description": "文件相对路径 / S3 Key", + "type": "string" + }, + "file_size": { + "description": "文件大小(字节)", + "type": "integer" + }, + "hash": { + "description": "文件哈希 (SHA-256/MD5,可用于排重)", + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "metadata": { + "description": "业务扩展元数据", + "allOf": [ + { + "$ref": "#/definitions/model.UploadMetadata" + } + ] + }, + "mime_type": { + "description": "媒体类型 (MIME, 如 image/png)", + "type": "string" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/model.UploadStatus" + } + ] + }, + "type": { + "description": "业务标识类型 (如 avatar, doc, attachment)", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "model.UploadMetadata": { + "type": "object", + "properties": { + "bucket": { + "description": "存储桶名称 (适用于 S3 等)", + "type": "string" + }, + "client_ip": { + "description": "上传者 IP", + "type": "string" + }, + "duration": { + "description": "音视频时长 (s)", + "type": "number" + }, + "extra": { + "description": "其它任意业务自定义元数据", + "type": "object", + "additionalProperties": {} + }, + "height": { + "description": "图像/视频高度 (px)", + "type": "integer" + }, + "original_mime": { + "description": "原始 MIME 类型", + "type": "string" + }, + "user_agent": { + "description": "上传者的 UA", + "type": "string" + }, + "width": { + "description": "图像/视频宽度 (px)", + "type": "integer" + } + } + }, + "model.UploadStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "deleted" + ], + "x-enum-comments": { + "UploadStatusDeleted": "已删除", + "UploadStatusPending": "待使用", + "UploadStatusUsed": "已使用" + }, + "x-enum-descriptions": [ + "待使用", + "已使用", + "已删除" + ], + "x-enum-varnames": [ + "UploadStatusPending", + "UploadStatusUsed", + "UploadStatusDeleted" + ] + }, + "node.AgentReleaseInfo": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "channel": { "type": "string" }, "current_version": { "type": "string" }, - "frp_version": { - "type": "string" - }, - "ip": { - "type": "string" - }, - "tunnel_status": { - "type": "string" - } - } - }, - "service.ManagedDomainInput": { - "type": "object", - "properties": { - "cert_id": { - "type": "integer" - }, - "domain": { - "type": "string" - }, - "enabled": { + "has_update": { "type": "boolean" }, - "remark": { + "html_url": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "requested_channel": { + "type": "string" + }, + "requested_tag": { + "type": "string" + }, + "tag_name": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + } + } + }, + "node.AgentUpdateInput": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "tag_name": { "type": "string" } } }, - "service.NodeInput": { + "node.BootstrapView": { + "type": "object", + "properties": { + "discovery_token": { + "type": "string" + } + } + }, + "node.HealthEventCleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + } + }, + "node.Input": { "type": "object", "properties": { "auto_update_enabled": { @@ -3460,7 +14040,6 @@ const docTemplate = `{ "type": "string" }, "node_type": { - "description": "TunnelRelay fields", "type": "string" }, "relay_agent_access_addr": { @@ -3483,7 +14062,1199 @@ const docTemplate = `{ } } }, - "service.ProxyRouteCustomHeaderInput": { + "node.ObservabilityView": { + "type": "object", + "properties": { + "analytics": { + "$ref": "#/definitions/observability.NodeAnalytics" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareHealthEvent" + } + }, + "metric_snapshots": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareMetricSnapshot" + } + }, + "node_id": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/model.OpenFlareNodeSystemProfile" + }, + "relay_dashboard": { + "$ref": "#/definitions/observability.RelayDashboardSnapshot" + }, + "traffic_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareRequestReport" + } + }, + "trends": { + "$ref": "#/definitions/observability.NodeTrends" + } + } + }, + "node.View": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": {}, + "latest_apply_at": { + "type": "string" + }, + "latest_apply_checksum": { + "type": "string" + }, + "latest_apply_message": { + "type": "string" + }, + "latest_apply_result": { + "type": "string" + }, + "latest_main_config_checksum": { + "type": "string" + }, + "latest_route_config_checksum": { + "type": "string" + }, + "latest_support_file_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "oauth.AuthSourceView": { + "type": "object", + "properties": { + "client_secret_configured": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oauth.BasicUserInfo": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "need_change_password": { + "type": "boolean" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "oauth.CallbackRequest": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "oauth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "oauth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + }, + "observability.AccessLogCleanupInput": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogCleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogIPSummaryList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPSummaryView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + } + } + }, + "observability.AccessLogIPSummaryView": { + "type": "object", + "properties": { + "last_seen_at": { + "type": "string" + }, + "recent_requests": { + "type": "integer" + }, + "remote_addr": { + "type": "string" + }, + "total_requests": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "request_count": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendView": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "hours": { + "type": "integer" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPTrendPoint" + } + }, + "remote_addr": { + "type": "string" + } + } + }, + "observability.AccessLogList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.AccessLogView": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "logged_at": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "status_code": { + "type": "integer" + } + } + }, + "observability.CapacityTrendPoint": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "bucket_started_at": { + "type": "string" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DiskIOTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DistributionItem": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPList": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogIPView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPView": { + "type": "object", + "properties": { + "client_error_count": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogList": { + "type": "object", + "properties": { + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_bucket": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogView": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "client_error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + }, + "unique_host_count": { + "type": "integer" + }, + "unique_ip_count": { + "type": "integer" + } + } + }, + "observability.HealthSummary": { + "type": "object", + "properties": { + "active_alerts": { + "type": "integer" + }, + "critical_alerts": { + "type": "integer" + }, + "has_capacity_risk": { + "type": "boolean" + }, + "has_runtime_risk": { + "type": "boolean" + }, + "has_traffic_risk": { + "type": "boolean" + }, + "info_alerts": { + "type": "integer" + }, + "resolved_alerts": { + "type": "integer" + }, + "warning_alerts": { + "type": "integer" + } + } + }, + "observability.NetworkTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "network_rx_bytes": { + "type": "integer" + }, + "network_tx_bytes": { + "type": "integer" + }, + "openresty_rx_bytes": { + "type": "integer" + }, + "openresty_tx_bytes": { + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.NodeAnalytics": { + "type": "object", + "properties": { + "distributions": { + "$ref": "#/definitions/observability.TrafficDistributions" + }, + "health": { + "$ref": "#/definitions/observability.HealthSummary" + }, + "traffic": { + "$ref": "#/definitions/observability.TrafficWindowSummary" + } + } + }, + "observability.NodeTrends": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.CapacityTrendPoint" + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DiskIOTrendPoint" + } + }, + "network_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NetworkTrendPoint" + } + }, + "traffic_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.TrafficTrendPoint" + } + } + } + }, + "observability.RelayDashboardSnapshot": { + "type": "object", + "properties": { + "client_counts": { + "type": "integer" + }, + "offline_proxies": { + "type": "integer" + }, + "online_proxies": { + "type": "integer" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.RelayProxyStat" + } + }, + "total_connections": { + "type": "integer" + }, + "total_proxies": { + "type": "integer" + } + } + }, + "observability.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "observability.TrafficDistributions": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.TrafficTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + } + } + }, + "observability.TrafficWindowSummary": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "error_rate_percent": { + "type": "number" + }, + "estimated_qps": { + "type": "number" + }, + "request_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "option.databaseCleanupInput": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer" + }, + "target": { + "type": "string" + } + } + }, + "option.databaseCleanupResult": { + "type": "object", + "properties": { + "delete_all": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + }, + "target": { + "type": "string" + }, + "target_label": { + "type": "string" + } + } + }, + "option.geoIPLookupRequest": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.geoIPLookupView": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "iso_code": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.optionBatchPayload": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + }, + "option.publicAuthSourceView": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "option.statusView": { + "type": "object", + "properties": { + "auth_sources": { + "type": "array", + "items": { + "$ref": "#/definitions/option.publicAuthSourceView" + } + }, + "cap_login_enabled": { + "type": "boolean" + }, + "email_verification": { + "type": "boolean" + }, + "footer_html": { + "type": "string" + }, + "github_client_id": { + "type": "string" + }, + "github_oauth": { + "type": "boolean" + }, + "home_page_link": { + "type": "string" + }, + "password_register_enabled": { + "type": "boolean" + }, + "server_address": { + "type": "string" + }, + "start_time": { + "type": "integer" + }, + "system_name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "wechat_login": { + "type": "boolean" + }, + "wechat_qrcode": { + "type": "string" + } + } + }, + "origin.DetailView": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.RouteSummary" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.Input": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "origin.RouteSummary": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.View": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "pages.DeploymentFileView": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "pages.DeploymentView": { + "type": "object", + "properties": { + "activated_at": { + "type": "string" + }, + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "deployment_number": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "total_size": { + "type": "integer" + } + } + }, + "pages.Input": { + "type": "object", + "properties": { + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + } + } + }, + "pages.View": { + "type": "object", + "properties": { + "active_deployment": { + "$ref": "#/definitions/pages.DeploymentView" + }, + "active_deployment_id": { + "type": "integer" + }, + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_count": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "proxy_route.CustomHeaderInput": { "type": "object", "properties": { "key": { @@ -3494,7 +15265,7 @@ const docTemplate = `{ } } }, - "service.ProxyRouteInput": { + "proxy_route.Input": { "type": "object", "properties": { "basic_auth_enabled": { @@ -3530,7 +15301,7 @@ const docTemplate = `{ "custom_headers": { "type": "array", "items": { - "$ref": "#/definitions/service.ProxyRouteCustomHeaderInput" + "$ref": "#/definitions/proxy_route.CustomHeaderInput" } }, "domain": { @@ -3584,11 +15355,8 @@ const docTemplate = `{ "origin_url": { "type": "string" }, - "pow_config": { - "type": "string" - }, - "pow_enabled": { - "type": "boolean" + "pages_project_id": { + "type": "integer" }, "redirect_http": { "type": "boolean" @@ -3622,80 +15390,838 @@ const docTemplate = `{ } } }, - "service.RelayHeartbeatPayload": { + "proxy_route.View": { "type": "object", "properties": { - "frp_version": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { "type": "string" }, - "frps_client_count": { - "type": "integer" - }, - "frps_connections": { - "type": "integer" - }, - "frps_proxies": { - "type": "array", - "items": { - "$ref": "#/definitions/service.RelayProxyStat" - } - }, - "frps_proxy_count": { - "type": "integer" - }, - "health_events": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeHealthEvent" - } - }, - "ip": { + "basic_auth_username": { "type": "string" }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rule_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "cache_rules": { + "type": "string" + }, + "cert_id": { + "type": "integer" + }, + "cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "custom_header_list": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "custom_headers": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domain_cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "domain_count": { + "type": "integer" + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "primary_domain": { + "type": "string" + }, + "redirect_http": { + "type": "boolean" + }, + "remark": { + "type": "string" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "upstream_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "string" + } + } + }, + "push.Config": { + "type": "object", + "properties": { + "channel": { + "description": "渠道名称,例如 \"lark\", \"custom\", \"email\" 等,唯一标识", + "type": "string" + }, + "ext": { + "description": "预留拓展 JSON 配置", + "type": "object", + "additionalProperties": {} + }, + "key": { + "description": "AppID 或 SMTP 用户名", + "type": "string" + }, + "secret": { + "description": "签名密钥或 SMTP 密码/Token", + "type": "string" + }, + "url": { + "description": "Webhook 地址或 SMTP 地址", + "type": "string" + } + } + }, + "push.CreateChannelRequest": { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, "name": { "type": "string" }, - "profile": { - "$ref": "#/definitions/service.AgentNodeSystemProfile" - }, - "relay_status": { + "other": { "type": "string" }, - "snapshot": { - "$ref": "#/definitions/service.AgentNodeMetricSnapshot" + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.CreateEventRequest": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "event_key": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "description": "关联的异步任务类型", + "type": "string" + }, + "template": { + "type": "string" + } + } + }, + "push.Definition": { + "type": "object", + "properties": { + "description": { + "description": "short description", + "type": "string" + }, + "fields": { + "description": "form fields", + "type": "array", + "items": { + "$ref": "#/definitions/push.Field" + } + }, + "name": { + "description": "display name", + "type": "string" + }, + "type": { + "description": "channel type (e.g., custom, lark, email)", + "type": "string" + } + } + }, + "push.EventMetadata": { + "type": "object", + "properties": { + "default_template": { + "$ref": "#/definitions/push.NotificationMessage" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "push.Field": { + "type": "object", + "properties": { + "description": { + "description": "field explanation/help text", + "type": "string" + }, + "key": { + "description": "unique key for the field (e.g. url, token, other)", + "type": "string" + }, + "label": { + "description": "human readable label (e.g. \"Webhook 地址\")", + "type": "string" + }, + "placeholder": { + "description": "input placeholder", + "type": "string" + }, + "required": { + "description": "whether this field is required", + "type": "boolean" + }, + "type": { + "description": "input type: \"text\" | \"password\" | \"textarea\"", + "type": "string" + } + } + }, + "push.NotificationMessage": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "ext": { + "type": "object", + "additionalProperties": {} + }, + "level": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "push.TestChannelRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "target": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.TestPushRequest": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/push.Config" + }, + "target": { + "type": "string" + } + } + }, + "push.UpdateChannelRequest": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.UpdateEventRequest": { + "type": "object", + "required": [ + "template" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + } + } + }, + "push.pushHistoriesResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushHistory" + } + }, + "total": { + "type": "integer" + } + } + }, + "response.Any": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string", + "example": "" + } + } + }, + "status.DatabaseInfoResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" }, "version": { "type": "string" } } }, - "service.RelayProxyStat": { + "status.SystemStatusResponse": { "type": "object", "properties": { - "client_addr": { + "alloc": { "type": "string" }, - "client_version": { + "buck_hash_sys": { "type": "string" }, - "last_close_time": { + "frees": { + "type": "integer" + }, + "gc_sys": { "type": "string" }, - "last_start_time": { + "heap_alloc": { "type": "string" }, + "heap_idle": { + "type": "string" + }, + "heap_inuse": { + "type": "string" + }, + "heap_objects": { + "type": "integer" + }, + "heap_released": { + "type": "string" + }, + "heap_sys": { + "type": "string" + }, + "last_gc_time": { + "type": "string" + }, + "last_pause": { + "type": "string" + }, + "lookups": { + "type": "integer" + }, + "mallocs": { + "type": "integer" + }, + "mcache_inuse": { + "type": "string" + }, + "mcache_sys": { + "type": "string" + }, + "mspan_inuse": { + "type": "string" + }, + "mspan_sys": { + "type": "string" + }, + "next_gc": { + "type": "string" + }, + "num_gc": { + "type": "integer" + }, + "num_goroutine": { + "type": "integer" + }, + "other_sys": { + "type": "string" + }, + "pause_total_ns": { + "type": "string" + }, + "stack_inuse": { + "type": "string" + }, + "stack_sys": { + "type": "string" + }, + "sys": { + "type": "string" + }, + "total_alloc": { + "type": "string" + }, + "uptime": { + "type": "string" + } + } + }, + "system_config.CreateSystemConfigRequest": { + "type": "object", + "required": [ + "key", + "type", + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 64 + }, + "type": { + "type": "string", + "enum": [ + "system", + "business" + ] + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "system_config.TestSMTPRequest": { + "type": "object", + "required": [ + "smtp_host", + "smtp_password", + "smtp_port", + "smtp_username", + "to" + ], + "properties": { + "smtp_host": { + "type": "string", + "maxLength": 255 + }, + "smtp_password": { + "type": "string", + "maxLength": 255 + }, + "smtp_port": { + "type": "integer" + }, + "smtp_username": { + "type": "string", + "maxLength": 255 + }, + "to": { + "type": "string" + } + } + }, + "system_config.TestSMTPResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "log": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "system_config.UpdateSystemConfigRequest": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "task.CreateScheduleRequest": { + "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], + "properties": { + "cron": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, "name": { "type": "string" }, - "status": { + "payload": { "type": "string" }, + "task_type": { + "type": "string" + } + } + }, + "task.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "task.TaskMeta": { + "type": "object", + "properties": { + "asynq_task": { + "type": "string" + }, + "description": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskParam" + } + }, + "queue": { + "type": "string" + }, + "retryable": { + "description": "是否支持手动重试", + "type": "boolean" + }, + "supports_time": { + "type": "boolean" + }, "type": { "type": "string" } } }, - "service.TLSApplyInput": { + "task.TaskParam": { + "type": "object", + "properties": { + "description": { + "description": "描述", + "type": "string" + }, + "label": { + "description": "显示名称", + "type": "string" + }, + "name": { + "description": "参数键名", + "type": "string" + }, + "placeholder": { + "description": "占位符", + "type": "string" + }, + "required": { + "description": "是否必填", + "type": "boolean" + }, + "type": { + "description": "类型:string, text, number, boolean", + "type": "string" + } + } + }, + "task.UpdateScheduleRequest": { + "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], + "properties": { + "cron": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + } + } + }, + "template.CreateTemplateRequest": { + "type": "object", + "required": [ + "content", + "key", + "name", + "type" + ], + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 80 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "template.UpdateTemplateRequest": { + "type": "object", + "required": [ + "content", + "name", + "type" + ], + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "tls.ApplyInput": { "type": "object", "properties": { "acme_account_id": { @@ -3736,7 +16262,66 @@ const docTemplate = `{ } } }, - "service.TLSCertificateInput": { + "tls.CertificateContent": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "cert_pem": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateInput": { "type": "object", "properties": { "cert_pem": { @@ -3752,32 +16337,811 @@ const docTemplate = `{ "type": "string" } } + }, + "tls.DNSAccountInput": { + "type": "object", + "properties": { + "authorization": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "tls.ManagedDomainInput": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "remark": { + "type": "string" + } + } + }, + "tls.ManagedDomainMatchCandidate": { + "type": "object", + "properties": { + "certificate_id": { + "type": "integer" + }, + "certificate_name": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "managed_domain_id": { + "type": "integer" + }, + "match_type": { + "type": "string" + } + } + }, + "tls.ManagedDomainMatchResult": { + "type": "object", + "properties": { + "candidate": { + "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" + } + }, + "domain": { + "type": "string" + }, + "matched": { + "type": "boolean" + } + } + }, + "updater.Status": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, + "user.changePasswordRequest": { + "type": "object", + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "user.createTokenRequest": { + "type": "object", + "properties": { + "is_admin": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "user.createUserRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + } + } + }, + "user.listUsersResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user.user" + } + } + } + }, + "user.loginRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.registerRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.sendEmailCodeRequest": { + "type": "object", + "required": [ + "email", + "scene" + ], + "properties": { + "email": { + "type": "string" + }, + "scene": { + "type": "string" + } + } + }, + "user.tokenResponse": { + "type": "object", + "properties": { + "record": { + "$ref": "#/definitions/model.AccessToken" + }, + "token": { + "type": "string" + } + } + }, + "user.updateProfileRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "user.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "user.user": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "last_login_at": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "waf.IDsRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestResult": { + "type": "object", + "properties": { + "lookback_minutes": { + "type": "integer" + }, + "matched_count": { + "type": "integer" + }, + "matched_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "rule_count": { + "type": "integer" + }, + "tested_at": { + "type": "string" + } + } + }, + "waf.IPGroupExtIPView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, + "waf.IPGroupInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "waf.IPGroupSyncResult": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/waf.IPGroupView" + }, + "ip_count": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "synced_at": { + "type": "string" + } + } + }, + "waf.IPGroupView": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ext_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupExtIPView" + } + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "last_sync_message": { + "type": "string" + }, + "last_sync_status": { + "type": "string" + }, + "last_synced_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "referenced_by_rule_count": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.PoWConfig": { + "type": "object", + "properties": { + "algorithm": { + "type": "string" + }, + "blacklist": { + "$ref": "#/definitions/waf.PoWListConfig" + }, + "challenge_ttl": { + "type": "integer" + }, + "difficulty": { + "type": "integer" + }, + "session_ttl": { + "type": "integer" + }, + "whitelist": { + "$ref": "#/definitions/waf.PoWListConfig" + } + } + }, + "waf.PoWListConfig": { + "type": "object", + "properties": { + "ip_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "path_regexes": { + "type": "array", + "items": { + "type": "string" + } + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "user_agents": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "waf.RuleGroupInput": { + "type": "object", + "properties": { + "block_response_body": { + "type": "string" + }, + "block_status_code": { + "type": "integer" + }, + "country_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "country_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "ip_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_blacklist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ip_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_whitelist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" + }, + "pow_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "pow_enabled": { + "type": "boolean" + }, + "region_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "region_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "remark": { + "type": "string" + } + } + }, + "waf.RuleGroupView": { + "type": "object", + "properties": { + "applied_site_count": { + "type": "integer" + }, + "applied_site_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "block_response_body": { + "type": "string" + }, + "block_status_code": { + "type": "integer" + }, + "country_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "country_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_blacklist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ip_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_whitelist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "is_global": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "pow_config": { + "$ref": "#/definitions/waf.PoWConfig" + }, + "pow_enabled": { + "type": "boolean" + }, + "region_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "region_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "remark": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.SiteRuleGroupsView": { + "type": "object", + "properties": { + "applied_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "applied_rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleGroupView" + } + }, + "global_rule_group": { + "$ref": "#/definitions/waf.RuleGroupView" + }, + "route_id": { + "type": "integer" + }, + "rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } } }, "securityDefinitions": { - "AccessTokenAuth": { - "description": "Agent API 使用节点专属 Agent Token 或全局 Discovery Token", + "SessionCookie": { "type": "apiKey", - "name": "X-Agent-Token", - "in": "header" - }, - "OpenFlareTokenAuth": { - "description": "管理端 API 使用登录后返回的用户 Token", - "type": "apiKey", - "name": "OPENFLARE_TOKEN", - "in": "header" + "name": "session", + "in": "cookie" } } }` // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "3.0", + Version: "1.0.0", Host: "", BasePath: "/", - Schemes: []string{"http", "https"}, - Title: "OpenFlare Server API", - Description: "OpenFlare Server 管理端与 Agent API 文档。", + Schemes: []string{}, + Title: "OpenFlare API", + Description: "OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/openflare-server/docs/swagger.json b/openflare-server/docs/swagger.json index 1f914f3c..ade96ab2 100644 --- a/openflare-server/docs/swagger.json +++ b/openflare-server/docs/swagger.json @@ -1,99 +1,23 @@ { - "schemes": [ - "http", - "https" - ], "swagger": "2.0", "info": { - "description": "OpenFlare Server 管理端与 Agent API 文档。", - "title": "OpenFlare Server API", - "contact": {}, - "version": "3.0" + "description": "OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。", + "title": "OpenFlare API", + "contact": { + "name": "OpenFlare", + "url": "https://github.com/Rain-kl/OpenFlare" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.0" }, "basePath": "/", "paths": { - "/api/access-logs/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "AccessLogs" - ], - "summary": "List access logs", - "parameters": [ - { - "type": "string", - "description": "Node ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "Remote address", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "Path", - "name": "path", - "in": "query" - }, - { - "type": "integer", - "description": "Page index", - "name": "p", - "in": "query" - }, - { - "type": "integer", - "description": "Page size", - "name": "page_size", - "in": "query" - }, - { - "type": "string", - "description": "Sort by", - "name": "sort_by", - "in": "query" - }, - { - "type": "string", - "description": "Sort order", - "name": "sort_order", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/access-logs/cleanup": { + "/api/cap/challenge": { "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", "consumes": [ "application/json" ], @@ -101,605 +25,4869 @@ "application/json" ], "tags": [ - "AccessLogs" + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } ], - "summary": "Cleanup access logs by retention days", "responses": { "200": { - "description": "OK", + "description": "成功返回 PoW 难题", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/cap.ChallengeResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" } } } } }, - "/api/access-logs/folds": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } + "/api/cap/redeem": { + "post": { + "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "consumes": [ + "application/json" ], "produces": [ "application/json" ], "tags": [ - "AccessLogs" + "cap" ], - "summary": "List folded access logs", + "summary": "校验人机验证解答", "parameters": [ { - "type": "string", - "description": "Node ID", - "name": "node_id", - "in": "query" + "description": "难题 Token 与解答 solutions 数组", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cap.redeemRequest" + } + } + ], + "responses": { + "200": { + "description": "核销成功,返回 X-Cap-Token", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + } + }, + "400": { + "description": "参数错误或核销失败", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + } + } + } + } + }, + "/api/health": { + "get": { + "description": "检查服务是否正常运行,可用于负载均衡存活探测", + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "健康检查", + "responses": { + "200": { + "description": "服务正常", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/auth-sources": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取认证源列表", + "responses": { + "200": { + "description": "认证源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建认证源", + "parameters": [ + { + "description": "创建认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功,返回认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true }, { - "type": "string", - "description": "Remote address", - "name": "remote_addr", - "in": "query" + "description": "更新认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功,返回更新后的认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或删除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}/toggle": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "切换认证源启用状态", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true }, { - "type": "string", - "description": "Host", - "name": "host", - "in": "query" + "description": "启用状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } }, + "400": { + "description": "验证失败或认证源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/clear": { + "post": { + "security": [ { - "type": "string", - "description": "Path", - "name": "path", + "SessionCookie": [] + } + ], + "description": "清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "清空缓存", + "responses": { + "200": { + "description": "清理成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/config": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新缓存配置", + "parameters": [ + { + "description": "缓存配置请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cache.updateCacheConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取缓存状态", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/diskcache.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-export": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "导出数据库", + "responses": { + "200": { + "description": "数据库文件", + "schema": { + "type": "file" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "导出失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.DatabaseInfoResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库运行概览", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/db_manage.DBOverviewResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/query": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "执行 SQL 查询", + "parameters": [ + { + "description": "SQL 请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/db_manage.ExecuteSQLRequest" + } + } + ], + "responses": { + "200": { + "description": "执行完毕", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/db_manage.ExecuteSQLResponse" + } + } + } + ] + } + }, + "400": { + "description": "SQL 语句错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/tables": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前数据库的所有用户自定义表名称列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库所有表名", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统历史日志,cursor=0 获取最新日志,cursor\u003e0 获取更早日志", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统日志", + "parameters": [ + { + "type": "integer", + "default": 0, + "description": "日志游标,0=获取最新", + "name": "cursor", "in": "query" }, { "type": "integer", - "description": "Page index", - "name": "p", + "default": 200, + "description": "每页条数", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.logsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/access": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户访问日志", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", "in": "query" }, { "type": "integer", - "description": "Page size", + "default": 20, + "description": "每页条数", "name": "page_size", "in": "query" }, { "type": "string", - "description": "Sort by", - "name": "sort_by", + "description": "用户名模糊搜索", + "name": "username", "in": "query" }, { "type": "string", - "description": "Sort order", - "name": "sort_order", - "in": "query" - }, - { - "type": "integer", - "description": "Fold minutes", - "name": "fold_minutes", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/access-logs/folds/ip-summary": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "AccessLogs" - ], - "summary": "List folded access log IP summaries", - "parameters": [ - { - "type": "string", - "description": "Node ID", - "name": "node_id", - "in": "query" - }, - { - "type": "string", - "description": "Remote address", - "name": "remote_addr", - "in": "query" - }, - { - "type": "string", - "description": "Host", - "name": "host", - "in": "query" - }, - { - "type": "string", - "description": "Path", + "description": "接口路径模糊搜索", "name": "path", "in": "query" }, { "type": "string", - "description": "Bucket started at", + "description": "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", + "name": "start_time", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", + "name": "end_time", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.accessLogsResponse" + } + } + } + ] + } + }, + "400": { + "description": "ClickHouse 未启用或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/analytics": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取访问日志分析数据", + "responses": { + "200": { + "description": "分析统计数据", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.logsAnalyticsResponse" + } + } + } + ] + } + }, + "400": { + "description": "ClickHouse 未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/ws": { + "get": { + "description": "通过 WebSocket 实时推送系统日志,需要管理员权限", + "tags": [ + "admin" + ], + "summary": "系统日志实时推送", + "responses": {} + } + }, + "/api/v1/admin/push/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的所有消息通道列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道", + "responses": { + "200": { + "description": "消息通道列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新建一个消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建消息通道", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.CreateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/definitions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道配置字段定义", + "responses": { + "200": { + "description": "通道配置定义列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/push.Definition" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "触发一次临时的或现有的通道连通性推送测试,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试通道连通性", + "parameters": [ + { + "description": "测试参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.TestChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "测试触发成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/channels/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.UpdateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据ID删除消息通道,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/events": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有通知事件", + "responses": { + "200": { + "description": "通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建通知事件", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.CreateEventRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/builtin": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有内置通知事件", + "responses": { + "200": { + "description": "内置通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/push.EventMetadata" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新通知事件", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.UpdateEventRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除数据库中的特定通知事件配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除通知事件配置", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}/toggle": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定的通知事件", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "快捷切换通知事件启用状态", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/histories": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回分页的通知历史日志数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "分页获取通知推送历史", + "parameters": [ + { + "type": "integer", + "description": "当前页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "分页大小", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "过滤事件名称", + "name": "event_key", + "in": "query" + }, + { + "type": "string", + "description": "过滤发送状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "推送历史列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/push.pushHistoriesResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试推送通道发送", + "parameters": [ + { + "description": "测试请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.TestPushRequest" + } + } + ], + "responses": { + "200": { + "description": "测试成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统状态信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.SystemStatusResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统配置列表", + "parameters": [ + { + "type": "string", + "description": "配置类型(system/business)", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "系统配置列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建系统配置", + "parameters": [ + { + "description": "创建请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.CreateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或配置键已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs/smtp/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "测试 SMTP 邮件发送", + "parameters": [ + { + "description": "测试请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.TestSMTPRequest" + } + } + ], + "responses": { + "200": { + "description": "测试执行完毕", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/system_config.TestSMTPResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs/{key}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据配置键获取对应的系统配置详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取单个系统配置", + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "系统配置详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新系统配置", + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/dispatch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下发异步任务", + "parameters": [ + { + "description": "任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.DispatchTaskRequest" + } + } + ], + "responses": { + "200": { + "description": "任务已入队", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "任务入队失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "查询任务执行记录", + "parameters": [ + { + "type": "string", + "description": "状态筛选 (pending/running/succeeded/failed)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "任务类型筛选", + "name": "task_type", + "in": "query" + }, + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "每页条数", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "任务执行记录列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "查询任务执行详情", + "parameters": [ + { + "type": "integer", + "description": "任务执行记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "任务执行详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TaskExecution" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions/{id}/retry": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "重新下发一条失败的任务,创建新的执行记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "重试失败任务", + "parameters": [ + { + "type": "integer", + "description": "任务执行记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "新任务的 TaskID", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务不支持重试或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "重试失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/schedules": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取定时任务列表", + "responses": { + "200": { + "description": "定时任务列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Schedule" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建定时任务", + "parameters": [ + { + "description": "创建定时任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.CreateScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功的定时任务信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Schedule" + } + } + } + ] + } + }, + "400": { + "description": "Cron 表达式无效、异步任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "保存定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/schedules/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "修改定时任务", + "parameters": [ + { + "type": "integer", + "description": "定时任务 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "修改定时任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.UpdateScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "修改后的定时任务信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Schedule" + } + } + } + ] + } + }, + "400": { + "description": "Cron 表达式无效、参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "定时任务不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "修改定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定的定时任务配置,并触发调度器热加载,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除定时任务", + "parameters": [ + { + "type": "integer", + "description": "定时任务 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "删除定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取支持的任务类型", + "responses": { + "200": { + "description": "任务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskMeta" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/templates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有通知模板列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取模板列表", + "responses": { + "200": { + "description": "模板列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Template" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建模板", + "parameters": [ + { + "description": "创建请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/template.CreateTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或模板标识符已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/templates/{key}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符获取对应的模板详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取单个模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "模板详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Template" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符更新对应的模板内容,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/template.UpdateTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Template" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "不可删除系统模板", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/update": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取应用更新状态", + "responses": { + "200": { + "description": "更新状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/updater.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "查询失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/update/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下载并应用应用更新", + "responses": { + "200": { + "description": "升级已准备并即将重启", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "当前版本不可升级", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "升级准备失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "上传用户 ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/download/batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", + "consumes": [ + "application/json" + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "批量打包下载", + "parameters": [ + { + "description": "包含文件 ID 数组 of string 的请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.batchDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "成功下载打包后的 ZIP", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "打包失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/download/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "下载单文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功下载文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件统计数据", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.fileStatsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回数据库中所有已上传文件实际拥有的业务类型列表", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件业务类型列表", + "responses": { + "200": { + "description": "业务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户列表", + "parameters": [ + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "用户列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.listUsersResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个本地密码登录的新用户,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建用户", + "parameters": [ + { + "description": "创建用户参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createUserRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.user" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或用户名已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户详情", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "用户详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.user" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除用户", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限、尝试删除管理员或当前用户", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新用户状态", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "状态参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限或尝试禁用管理员", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/config/public": { + "get": { + "description": "返回系统配置表中 visibility 为 1 的配置键值集合", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "config" + ], + "summary": "获取公共配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/custom/hello": { + "get": { + "description": "A sample business API for customization", + "produces": [ + "application/json" + ], + "tags": [ + "custom" + ], + "summary": "Sample Hello API", + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/access-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理过期访问日志记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "清理访问日志", + "parameters": [ + { + "description": "清理参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/observability.AccessLogCleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按时间桶聚合访问日志并分页返回,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在指定时间桶内按 IP 聚合访问统计,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "string", + "description": "时间桶起始时间", "name": "bucket_started_at", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", - "description": "Fold minutes", + "description": "折叠时间窗口(分钟)", "name": "fold_minutes", - "in": "query", - "required": true + "in": "query" }, { "type": "integer", - "description": "Page index", + "description": "页码", "name": "p", "in": "query" }, { "type": "integer", - "description": "Page size", + "description": "每页条数", "name": "page_size", "in": "query" }, { "type": "string", - "description": "Sort by", + "description": "排序字段", "name": "sort_by", "in": "query" }, { "type": "string", - "description": "Sort order", + "description": "排序方向", "name": "sort_order", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "折叠 IP 汇总列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogIPList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/access-logs/ip-summary": { + "/api/v1/d/access-logs/ip-summary": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "AccessLogs" + "openflare-observability" ], - "summary": "List access log IP summaries", + "summary": "列出访问日志 IP 汇总", "parameters": [ { "type": "string", - "description": "Node ID", + "description": "节点 ID", "name": "node_id", "in": "query" }, { "type": "string", - "description": "Remote address", + "description": "客户端 IP", "name": "remote_addr", "in": "query" }, { "type": "string", - "description": "Host", + "description": "请求 Host", "name": "host", "in": "query" }, { "type": "integer", - "description": "Page index", + "description": "页码", "name": "p", "in": "query" }, { "type": "integer", - "description": "Page size", + "description": "每页条数", "name": "page_size", "in": "query" }, { "type": "string", - "description": "Sort by", + "description": "排序字段", "name": "sort_by", "in": "query" }, { "type": "string", - "description": "Sort order", + "description": "排序方向", "name": "sort_order", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "IP 汇总列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPSummaryList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/access-logs/ip-summary/trend": { + "/api/v1/d/access-logs/ip-summary/trend": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "AccessLogs" + "openflare-observability" ], - "summary": "Get access log IP trend", + "summary": "获取访问日志 IP 趋势", "parameters": [ { "type": "string", - "description": "Node ID", + "description": "节点 ID", "name": "node_id", "in": "query" }, { "type": "string", - "description": "Remote address", + "description": "客户端 IP", "name": "remote_addr", - "in": "query", - "required": true + "in": "query" }, { "type": "string", - "description": "Host", + "description": "请求 Host", "name": "host", "in": "query" }, { "type": "integer", - "description": "Hours", + "description": "统计时间范围(小时)", "name": "hours", "in": "query" }, { "type": "integer", - "description": "Bucket minutes", + "description": "时间桶粒度(分钟)", "name": "bucket_minutes", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "IP 访问趋势", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/acme-accounts/default": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "AcmeAccounts" - ], - "summary": "Get default ACME account", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/apply-logs": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Report agent apply result", - "parameters": [ - { - "description": "Apply log payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.ApplyLogPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPTrendView" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/agent/config-versions/active": { + "/api/v1/d/acme-accounts/default": { "get": { "security": [ { - "AccessTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回系统默认 ACME 账号配置,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Agent" + "openflare-tls" ], - "summary": "Get active config for agent", + "summary": "获取默认 ACME 账号", "responses": { "200": { - "description": "OK", + "description": "默认 ACME 账号", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/nodes/heartbeat": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Report agent heartbeat", - "parameters": [ - { - "description": "Agent heartbeat payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.AgentNodePayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AcmeAccount" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/nodes/register": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Register or discover agent node", - "parameters": [ - { - "description": "Agent node payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.AgentNodePayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/agent/waf/ip-groups/sync": { - "post": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Agent" - ], - "summary": "Sync WAF IP groups for agent", - "parameters": [ - { - "description": "WAF IP group sync payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.AgentWAFIPGroupSyncInput" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "403": { + "description": "无管理员权限", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/agent/ws": { + "/api/v1/d/apply-logs": { "get": { "security": [ { - "AccessTokenAuth": [] - } - ], - "tags": [ - "Agent" - ], - "summary": "Upgrade agent connection to websocket", - "responses": {} - } - }, - "/api/apply-logs/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ApplyLogs" + "openflare-apply-log" ], - "summary": "List apply logs", + "summary": "获取配置下发日志", "parameters": [ { "type": "string", - "description": "Node ID", + "description": "节点 ID 筛选", "name": "node_id", "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "pageNo", + "in": "query" + }, + { + "type": "integer", + "description": "页码(别名)", + "name": "page_no", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(别名)", + "name": "page_size", + "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "下发日志列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.ListResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/apply-logs/cleanup": { + "/api/v1/d/apply-logs/cleanup": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按保留天数清理历史下发记录,或删除全部记录,需要管理员权限", "consumes": [ "application/json" ], @@ -707,206 +4895,754 @@ "application/json" ], "tags": [ - "ApplyLogs" + "openflare-apply-log" + ], + "summary": "清理配置下发日志", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/apply_log.CleanupInput" + } + } ], - "summary": "Cleanup apply logs", "responses": { "200": { - "description": "OK", + "description": "清理结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/": { + "/api/v1/d/config-versions": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-config-version" ], - "summary": "List config versions", + "summary": "获取配置版本列表", "responses": { "200": { - "description": "OK", + "description": "配置版本列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ConfigVersionSummary" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/active": { + "/api/v1/d/config-versions/active": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回当前正在使用的配置版本,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-config-version" ], - "summary": "Get active config version", + "summary": "获取当前活跃配置版本", "responses": { "200": { - "description": "OK", + "description": "活跃配置版本", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限、不存在或无活跃版本", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/cleanup": { + "/api/v1/d/config-versions/cleanup": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "删除超出保留数量的非活跃配置版本,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-config-version" ], - "summary": "Cleanup old config versions", + "summary": "清理历史配置版本", "parameters": [ { - "description": "Cleanup request", + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config_version.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/diff": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "对比当前草稿配置与活跃版本之间的差异,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "对比草稿与活跃配置", + "responses": { + "200": { + "description": "配置差异", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigDiffResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/preview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "渲染并返回当前草稿配置的预览结果,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "预览当前草稿配置", + "responses": { + "200": { + "description": "配置预览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigPreviewResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/publish": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前草稿配置发布为新版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "发布配置版本", + "parameters": [ + { + "type": "boolean", + "description": "是否强制发布", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "发布成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定配置版本的完整快照与渲染内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本详情", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "配置版本详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定历史版本设为当前活跃配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "激活配置版本", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dashboard/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-dashboard" + ], + "summary": "获取仪表盘概览", + "responses": { + "200": { + "description": "仪表盘概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dashboard.OverviewPayload" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 DNS 账号", + "responses": { + "200": { + "description": "DNS 账号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 DNS 账号", + "parameters": [ + { + "description": "DNS 账号参数", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.CleanupConfigVersionRequest" + "$ref": "#/definitions/tls.DNSAccountInput" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功的 DNS 账号", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/diff": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ConfigVersions" - ], - "summary": "Diff current draft against active version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/config-versions/preview": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ConfigVersions" - ], - "summary": "Preview config rendering", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/config-versions/publish": { + "/api/v1/d/dns-accounts/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除 DNS 提供商账号,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-tls" ], - "summary": "Publish a new config version", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/config-versions/{id}": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ConfigVersions" - ], - "summary": "Get config version detail", + "summary": "删除 DNS 账号", "parameters": [ { "type": "integer", - "description": "Version ID", + "description": "DNS 账号 ID", "name": "id", "in": "path", "required": true @@ -914,115 +5650,191 @@ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/config-versions/{id}/activate": { + "/api/v1/d/dns-accounts/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "ConfigVersions" + "openflare-tls" ], - "summary": "Activate an existing config version", + "summary": "更新 DNS 账号", "parameters": [ { "type": "integer", - "description": "Version ID", + "description": "DNS 账号 ID", "name": "id", "in": "path", "required": true + }, + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的 DNS 账号", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/dashboard/overview": { + "/api/v1/d/managed-domains": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回全部托管域名及关联证书,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Dashboard" + "openflare-tls" ], - "summary": "Get dashboard overview", + "summary": "列出托管域名", "responses": { "200": { - "description": "OK", + "description": "托管域名列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ManagedDomain" + } + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } - } - } - } - }, - "/api/dns-accounts/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "DnsAccounts" - ], - "summary": "List DNS accounts", - "responses": { - "200": { - "description": "OK", + }, + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -1030,9 +5842,10 @@ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的托管域名记录,需要管理员权限", "consumes": [ "application/json" ], @@ -1040,322 +5853,85 @@ "application/json" ], "tags": [ - "DnsAccounts" + "openflare-tls" ], - "summary": "Create DNS account", + "summary": "创建托管域名", "parameters": [ { - "description": "DNS account payload", - "name": "payload", + "description": "托管域名参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/controller.DnsAccountInput" + "$ref": "#/definitions/tls.ManagedDomainInput" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功的托管域名", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/dns-accounts/{id}/delete": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "DnsAccounts" - ], - "summary": "Delete DNS account", - "parameters": [ - { - "type": "integer", - "description": "DNS Account ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/dns-accounts/{id}/update": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "DnsAccounts" - ], - "summary": "Update DNS account", - "parameters": [ - { - "type": "integer", - "description": "DNS Account ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "DNS account payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.DnsAccountInput" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/apply-log": { - "post": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Flared" - ], - "summary": "Report OpenFlared apply result", - "parameters": [ - { - "description": "Apply log payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.ApplyLogPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/config/active": { - "get": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Flared" - ], - "summary": "Get active tunnel config for OpenFlared", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/heartbeat": { - "post": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Flared" - ], - "summary": "Report OpenFlared heartbeat", - "parameters": [ - { - "description": "Flared heartbeat payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.FlaredHeartbeatPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ManagedDomain" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/flared/ws": { - "get": { - "security": [ - { - "TunnelTokenAuth": [] - } - ], - "tags": [ - "Flared" - ], - "summary": "Upgrade OpenFlared connection to websocket", - "responses": {} - } - }, - "/api/managed-domains/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "ManagedDomains" - ], - "summary": "List managed domains", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - }, - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ManagedDomains" - ], - "summary": "Create managed domain", - "parameters": [ - { - "description": "Managed domain payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.ManagedDomainInput" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/managed-domains/match": { + "/api/v1/d/managed-domains/match": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按域名查询可用的证书匹配候选,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ManagedDomains" + "openflare-tls" ], - "summary": "Match certificate for domain", + "summary": "匹配托管域名证书", "parameters": [ { "type": "string", - "description": "Domain", + "description": "域名", "name": "domain", "in": "query", "required": true @@ -1363,33 +5939,69 @@ ], "responses": { "200": { - "description": "OK", + "description": "证书匹配结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/tls.ManagedDomainMatchResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/managed-domains/{id}/delete": { + "/api/v1/d/managed-domains/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除托管域名,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ManagedDomains" + "openflare-tls" ], - "summary": "Delete managed domain", + "summary": "删除托管域名", "parameters": [ { "type": "integer", - "description": "Managed domain ID", + "description": "托管域名 ID", "name": "id", "in": "path", "required": true @@ -1397,29 +6009,52 @@ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/managed-domains/{id}/update": { + "/api/v1/d/managed-domains/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新托管域名,需要管理员权限", "consumes": [ "application/json" ], @@ -1427,65 +6062,132 @@ "application/json" ], "tags": [ - "ManagedDomains" + "openflare-tls" ], - "summary": "Update managed domain", + "summary": "更新托管域名", "parameters": [ { "type": "integer", - "description": "Managed domain ID", + "description": "托管域名 ID", "name": "id", "in": "path", "required": true }, { - "description": "Managed domain payload", - "name": "payload", + "description": "托管域名参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.ManagedDomainInput" + "$ref": "#/definitions/tls.ManagedDomainInput" } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的托管域名", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ManagedDomain" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/": { + "/api/v1/d/nodes": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回所有节点及最新配置下发记录,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "List nodes", + "summary": "获取节点列表", "responses": { "200": { - "description": "OK", + "description": "节点列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/node.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -1493,9 +6195,10 @@ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的边缘节点记录,需要管理员权限", "consumes": [ "application/json" ], @@ -1503,153 +6206,322 @@ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Create node", + "summary": "创建节点", "parameters": [ { - "description": "Node payload", - "name": "payload", + "description": "节点参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.NodeInput" + "$ref": "#/definitions/node.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/bootstrap-token": { + "/api/v1/d/nodes/bootstrap-token": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回全局节点发现引导令牌,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Get global discovery token", + "summary": "获取引导令牌", "responses": { "200": { - "description": "OK", + "description": "引导令牌", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/bootstrap-token/rotate": { + "/api/v1/d/nodes/bootstrap-token/rotate": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "重新生成全局节点发现引导令牌,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Rotate global discovery token", + "summary": "轮换引导令牌", "responses": { "200": { - "description": "OK", + "description": "新引导令牌", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/agent-release": { + "/api/v1/d/nodes/{id}/agent-release": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定节点可用的最新 Agent 版本信息,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Check latest agent release for node", + "summary": "获取 Agent 发布信息", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true }, { "type": "string", - "description": "stable or preview", + "description": "发布渠道", "name": "channel", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "Agent 发布信息", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.AgentReleaseInfo" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/agent-update": { + "/api/v1/d/nodes/{id}/agent-update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "向指定节点下发 Agent 自更新指令,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Request agent self-update on node", + "summary": "请求 Agent 更新", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数(可选)", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/node.AgentUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定节点记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "删除节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1657,40 +6529,63 @@ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/delete": { + "/api/v1/d/nodes/{id}/force-sync": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "向指定节点下发强制同步当前活跃配置的指令,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Delete node", + "summary": "请求强制同步配置", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1698,134 +6593,139 @@ ], "responses": { "200": { - "description": "OK", + "description": "同步请求已下发", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/force-sync": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "Nodes" - ], - "summary": "Request force sync config on node", - "parameters": [ - { - "type": "integer", - "description": "Node ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/nodes/{id}/observability": { + "/api/v1/d/nodes/{id}/observability": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定节点的指标、健康事件与流量分析数据,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Get node observability details", + "summary": "获取节点可观测性数据", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true }, { "type": "integer", - "description": "Lookback window in hours", + "description": "统计时间范围(小时)", "name": "hours", "in": "query" }, { "type": "integer", - "description": "Max records per section", + "description": "返回记录数量上限", "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "OK", + "description": "可观测性数据", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.ObservabilityView" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/observability/cleanup": { + "/api/v1/d/nodes/{id}/observability/cleanup": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "清理指定节点的历史健康事件记录,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Cleanup node health events", + "summary": "清理节点健康事件", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1833,40 +6733,63 @@ ], "responses": { "200": { - "description": "OK", + "description": "清理结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.HealthEventCleanupResult" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/openresty-restart": { + "/api/v1/d/nodes/{id}/openresty-restart": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "向指定节点下发 OpenResty 重启指令,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Request openresty restart on node", + "summary": "请求重启 OpenResty", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true @@ -1874,29 +6797,52 @@ ], "responses": { "200": { - "description": "OK", + "description": "重启请求已下发", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/nodes/{id}/update": { + "/api/v1/d/nodes/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "更新指定节点的配置信息,需要管理员权限", "consumes": [ "application/json" ], @@ -1904,236 +6850,503 @@ "application/json" ], "tags": [ - "Nodes" + "openflare-node" ], - "summary": "Update node", + "summary": "更新节点", "parameters": [ { "type": "integer", - "description": "Node ID", + "description": "节点 ID", "name": "id", "in": "path", "required": true }, { - "description": "Node payload", - "name": "payload", + "description": "节点参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.NodeInput" + "$ref": "#/definitions/node.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "更新成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/option/": { + "/api/v1/d/notice": { "get": { + "description": "返回 OpenFlare 控制台公告文本,无需登录", "produces": [ "application/json" ], "tags": [ - "Options" + "openflare-option" ], - "summary": "List editable options", + "summary": "获取系统公告", "responses": { "200": { - "description": "OK", + "description": "系统公告", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/option/database/cleanup": { - "post": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Cleanup observability tables", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/option/geoip/lookup": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Test GeoIP lookup", - "parameters": [ - { - "description": "GeoIP lookup payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.geoIPLookupRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/option/update": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Update option", - "parameters": [ - { - "description": "Option payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/model.Option" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, - "400": { - "description": "Bad Request", + "500": { + "description": "内部错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } } } } }, - "/api/option/update-batch": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Options" - ], - "summary": "Batch update options", - "parameters": [ - { - "description": "Batch option payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/controller.optionBatchPayload" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/proxy-routes/": { + "/api/v1/d/option": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回全部非敏感 OpenFlare 配置项,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "列出 OpenFlare 配置项", + "responses": { + "200": { + "description": "配置项列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/database/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按目标与保留天数清理可观测性相关数据表,需要管理员权限", + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-option" + ], + "summary": "清理可观测性数据库", + "parameters": [ + { + "description": "清理参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/option.databaseCleanupInput" + } + } ], - "summary": "List proxy routes", "responses": { "200": { - "description": "OK", + "description": "清理结果", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.databaseCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/geoip/lookup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按提供商与 IP 查询地理位置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "GeoIP 地址查询", + "parameters": [ + { + "description": "查询参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.geoIPLookupRequest" + } + } + ], + "responses": { + "200": { + "description": "GeoIP 查询结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.geoIPLookupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新单个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "更新 OpenFlare 配置项", + "parameters": [ + { + "description": "配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update-batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "批量更新多个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "批量更新 OpenFlare 配置项", + "parameters": [ + { + "description": "批量配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.optionBatchPayload" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有源站及关联代理规则数量,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站列表", + "responses": { + "200": { + "description": "源站列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -2141,9 +7354,10 @@ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的上游源站记录,需要管理员权限", "consumes": [ "application/json" ], @@ -2151,56 +7365,79 @@ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Create proxy route", + "summary": "创建源站", "parameters": [ { - "description": "Proxy route payload", - "name": "payload", + "description": "源站参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.ProxyRouteInput" + "$ref": "#/definitions/origin.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/proxy-routes/{id}": { + "/api/v1/d/origins/{id}": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "返回指定源站信息及关联代理规则摘要,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Get proxy route detail", + "summary": "获取源站详情", "parameters": [ { "type": "integer", - "description": "Route ID", + "description": "源站 ID", "name": "id", "in": "path", "required": true @@ -2208,40 +7445,63 @@ ], "responses": { "200": { - "description": "OK", + "description": "源站详情", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.DetailView" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/proxy-routes/{id}/delete": { + "/api/v1/d/origins/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "删除指定源站记录,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Delete proxy route", + "summary": "删除源站", "parameters": [ { "type": "integer", - "description": "Route ID", + "description": "源站 ID", "name": "id", "in": "path", "required": true @@ -2249,29 +7509,52 @@ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/proxy-routes/{id}/update": { + "/api/v1/d/origins/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "更新指定源站的配置信息,需要管理员权限", "consumes": [ "application/json" ], @@ -2279,145 +7562,126 @@ "application/json" ], "tags": [ - "ProxyRoutes" + "openflare-origin" ], - "summary": "Update proxy route", + "summary": "更新源站", "parameters": [ { "type": "integer", - "description": "Route ID", + "description": "源站 ID", "name": "id", "in": "path", "required": true }, { - "description": "Proxy route payload", - "name": "payload", + "description": "源站参数", + "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.ProxyRouteInput" + "$ref": "#/definitions/origin.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "更新成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/relay/heartbeat": { - "post": { + "/api/v1/d/pages": { + "get": { "security": [ { - "AccessTokenAuth": [] + "SessionCookie": [] } ], - "consumes": [ - "application/json" - ], + "description": "返回全部 OpenFlare Pages 项目,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "Relay" - ], - "summary": "Report relay heartbeat", - "parameters": [ - { - "description": "Relay heartbeat payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.RelayHeartbeatPayload" - } - } + "openflare-pages" ], + "summary": "列出 Pages 项目", "responses": { "200": { - "description": "OK", + "description": "Pages 项目列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.View" + } + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } - } - } - } - }, - "/api/relay/ws": { - "get": { - "security": [ - { - "AccessTokenAuth": [] - } - ], - "tags": [ - "Relay" - ], - "summary": "Upgrade relay connection to websocket", - "responses": {} - } - }, - "/api/status": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Public" - ], - "summary": "Get server status", - "responses": { - "200": { - "description": "OK", + }, + "401": { + "description": "未登录", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } - } - } - } - }, - "/api/tls-certificates/": { - "get": { - "security": [ - { - "OpenFlareTokenAuth": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "TLSCertificates" - ], - "summary": "List TLS certificates", - "responses": { - "200": { - "description": "OK", + }, + "403": { + "description": "无管理员权限", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } @@ -2425,9 +7689,10 @@ "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "创建新的 OpenFlare Pages 项目,需要管理员权限", "consumes": [ "application/json" ], @@ -2435,91 +7700,372 @@ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-pages" ], - "summary": "Create TLS certificate from PEM", + "summary": "创建 Pages 项目", "parameters": [ { - "description": "TLS certificate payload", - "name": "payload", + "description": "项目参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSCertificateInput" + "$ref": "#/definitions/pages.Input" } } ], "responses": { "200": { - "description": "OK", + "description": "创建成功的项目", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/apply": { - "post": { + "/api/v1/d/pages/deployments/{deployment_id}/files": { + "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], - "consumes": [ - "application/json" - ], + "description": "返回指定部署包含的文件清单,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-pages" ], - "summary": "Apply TLS certificate via ACME", + "summary": "列出 Pages 部署文件", "parameters": [ { - "description": "TLS apply payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/service.TLSApplyInput" - } + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true } ], "responses": { "200": { - "description": "OK", + "description": "部署文件列表", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentFileView" + } + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/import-file": { + "/api/v1/d/pages/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 Pages 项目详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 项目详情", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Pages 项目详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定项目的全部部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为指定项目上传 ZIP 部署包,需要管理员权限", "consumes": [ "multipart/form-data" ], @@ -2527,33 +8073,945 @@ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-pages" ], - "summary": "Import TLS certificate from files", + "summary": "上传 Pages 部署包", "parameters": [ { - "type": "string", - "description": "Certificate name", - "name": "name", - "in": "formData", + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", "required": true }, + { + "type": "file", + "description": "部署包 ZIP 文件", + "name": "package", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定部署设为项目当前生效版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "激活 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定项目的部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "更新后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有代理规则配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则列表", + "responses": { + "200": { + "description": "代理规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的反向代理规则,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "创建代理规则", + "parameters": [ + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定代理规则的完整配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则详情", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "代理规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定代理规则,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "删除代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定代理规则的配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "更新代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/status": { + "get": { + "description": "返回版本、认证源与系统公开配置,无需登录", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "获取 OpenFlare 公开状态", + "responses": { + "200": { + "description": "公开状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.statusView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 TLS 证书(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 TLS 证书", + "responses": { + "200": { + "description": "证书列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从 PEM 文本创建 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 TLS 证书", + "parameters": [ + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "通过 ACME 申请新的 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "申请 ACME 证书", + "parameters": [ + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "申请中的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/import-file": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "上传证书与私钥文件创建 TLS 证书,需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "从文件导入 TLS 证书", + "parameters": [ { "type": "string", - "description": "Remark", + "description": "证书名称", + "name": "name", + "in": "formData" + }, + { + "type": "string", + "description": "备注", "name": "remark", "in": "formData" }, { "type": "file", - "description": "Certificate file", + "description": "证书文件", "name": "cert_file", "in": "formData", "required": true }, { "type": "file", - "description": "Private key file", + "description": "私钥文件", "name": "key_file", "in": "formData", "required": true @@ -2561,40 +9019,69 @@ ], "responses": { "200": { - "description": "OK", + "description": "导入成功的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}": { + "/api/v1/d/tls-certificates/{id}": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Get TLS certificate detail", + "summary": "获取 TLS 证书详情", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2602,40 +9089,75 @@ ], "responses": { "200": { - "description": "OK", + "description": "证书详情", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/content": { + "/api/v1/d/tls-certificates/{id}/content": { "get": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 返回证书与私钥 PEM 内容,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Get TLS certificate PEM content", + "summary": "获取 TLS 证书 PEM 内容", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2643,29 +9165,64 @@ ], "responses": { "200": { - "description": "OK", + "description": "证书 PEM 内容", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/tls.CertificateContent" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/convert-acme": { + "/api/v1/d/tls-certificates/{id}/convert-acme": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "将已上传证书转换为 ACME 自动续期模式,需要管理员权限", "consumes": [ "application/json" ], @@ -2673,63 +9230,98 @@ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Convert uploaded TLS certificate to ACME managed certificate", + "summary": "将证书转为 ACME 管理", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true }, { - "description": "TLS apply payload", - "name": "payload", + "description": "ACME 申请参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSApplyInput" + "$ref": "#/definitions/tls.ApplyInput" } } ], "responses": { "200": { - "description": "OK", + "description": "转换后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/delete": { + "/api/v1/d/tls-certificates/{id}/delete": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 删除 TLS 证书,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Delete TLS certificate", + "summary": "删除 TLS 证书", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2737,40 +9329,63 @@ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/renew": { + "/api/v1/d/tls-certificates/{id}/renew": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "手动触发 ACME 证书续期,需要管理员权限", "produces": [ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Renew TLS certificate", + "summary": "续期 ACME 证书", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true @@ -2778,29 +9393,64 @@ ], "responses": { "200": { - "description": "OK", + "description": "续期后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/update": { + "/api/v1/d/tls-certificates/{id}/update": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新 TLS 证书 PEM 信息,需要管理员权限", "consumes": [ "application/json" ], @@ -2808,52 +9458,87 @@ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Update TLS certificate from PEM", + "summary": "更新 TLS 证书", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true }, { - "description": "TLS certificate payload", - "name": "payload", + "description": "证书参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSCertificateInput" + "$ref": "#/definitions/tls.CertificateInput" } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/tls-certificates/{id}/update-acme": { + "/api/v1/d/tls-certificates/{id}/update-acme": { "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], + "description": "按 ID 更新 ACME 证书申请配置,需要管理员权限", "consumes": [ "application/json" ], @@ -2861,81 +9546,87 @@ "application/json" ], "tags": [ - "TLSCertificates" + "openflare-tls" ], - "summary": "Update ACME TLS certificate", + "summary": "更新 ACME 证书配置", "parameters": [ { "type": "integer", - "description": "Certificate ID", + "description": "证书 ID", "name": "id", "in": "path", "required": true }, { - "description": "TLS apply payload", - "name": "payload", + "description": "ACME 申请参数", + "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/service.TLSApplyInput" + "$ref": "#/definitions/tls.ApplyInput" } } ], "responses": { "200": { - "description": "OK", + "description": "更新后的证书", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] } }, "400": { - "description": "Bad Request", + "description": "参数错误", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/update/latest-release": { - "get": { + "/api/v1/d/uptimekuma/sync": { + "post": { "security": [ { - "OpenFlareTokenAuth": [] + "SessionCookie": [] } ], - "produces": [ - "application/json" - ], - "tags": [ - "Update" - ], - "summary": "Get latest GitHub release", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/api/update/logs/ws": { - "get": { - "tags": [ - "Update" - ], - "summary": "Stream server upgrade logs over websocket", - "responses": {} - } - }, - "/api/update/manual-upgrade": { - "post": { + "description": "将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限", "consumes": [ "application/json" ], @@ -2943,22 +9634,1601 @@ "application/json" ], "tags": [ - "Update" + "openflare-option" ], - "summary": "Confirm upgrade with previously uploaded server binary", + "summary": "同步 Uptime Kuma", "responses": { "200": { - "description": "OK", + "description": "同步成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/update/manual-upload": { + "/api/v1/d/waf/ip-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF IP 组", + "responses": { + "200": { + "description": "IP 组列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF IP 组", + "parameters": [ + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "测试 WAF IP 组自动配置", + "parameters": [ + { + "description": "自动配置参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupAutoTestInput" + } + } + ], + "responses": { + "200": { + "description": "测试结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupAutoTestResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 WAF IP 组详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF IP 组详情", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "IP 组详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 WAF IP 组外部 IP 同步,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 WAF 规则组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF 规则组", + "responses": { + "200": { + "description": "规则组列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 WAF 规则组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF 规则组", + "parameters": [ + { + "description": "规则组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.RuleGroupInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的规则组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 WAF 规则组详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF 规则组详情", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "规则组详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 WAF 规则组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/sites": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "替换 WAF 规则组关联的代理站点列表,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "替换规则组站点绑定", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "站点 ID 列表", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IDsRequest" + } + } + ], + "responses": { + "200": { + "description": "更新后的规则组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 WAF 规则组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "规则组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.RuleGroupInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的规则组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/sites/{route_id}/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回代理站点关联的 WAF 规则组绑定,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "替换代理站点关联的 WAF 规则组列表,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "替换站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + }, + { + "description": "规则组 ID 列表", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IDsRequest" + } + } + ], + "responses": { + "200": { + "description": "更新后的站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/callback": { + "post": { + "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 回调处理", + "parameters": [ + { + "description": "回调请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oauth.CallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "登录或绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthCallbackResult" + } + } + } + ] + } + }, + "400": { + "description": "state 无效、参数错误或认证源错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "绑定场景未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "OAuth 认证失败或内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取外部帐号列表", + "responses": { + "200": { + "description": "外部帐号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ExternalAccountView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解除外部帐号绑定", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "外部帐号绑定记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "解除绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或解除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/login": { + "get": { + "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取登录授权地址", + "parameters": [ + { + "type": "string", + "description": "认证源名称,为空使用第一个启用的认证源", + "name": "source", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未配置", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "Redis 异常 or 构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/sources": { + "get": { + "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取可用登录源", + "responses": { + "200": { + "description": "登录源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/oauth.AuthSourceView" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/oauth/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/{source}/authorize": { + "get": { + "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "发起指定认证源授权", + "parameters": [ + { + "type": "string", + "description": "认证源名称", + "name": "source", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", + "name": "purpose", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "Redis 异常或构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重", "consumes": [ "multipart/form-data" ], @@ -2966,35 +11236,939 @@ "application/json" ], "tags": [ - "Update" + "upload" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "要上传的文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)", + "name": "type", + "in": "formData" + }, + { + "type": "string", + "description": "额外的 JSON 格式元数据", + "name": "metadata", + "in": "formData" + } ], - "summary": "Upload server binary and inspect version before upgrade", "responses": { "200": { - "description": "OK", + "description": "上传成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "400": { + "description": "请求参数错误或文件受限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" } } } } }, - "/api/update/upgrade": { - "post": { + "/api/v1/upload/my": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤", "produces": [ "application/json" ], "tags": [ - "Update" + "upload" + ], + "summary": "获取我的文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + } ], - "summary": "Upgrade server binary from latest GitHub release", "responses": { "200": { - "description": "OK", + "description": "查询成功", "schema": { - "type": "object", - "additionalProperties": true + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listMyFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/upload/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新当前用户本人的文件名或访问权限模式 (AccessMode)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "更新我的文件信息", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新字段", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateMyFileRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前用户本人的文件状态置为 deleted(软删除)", + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "删除我的文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的所有 active access tokens(脱敏后)", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前用户的 AccessToken 列表", + "responses": { + "200": { + "description": "令牌列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AccessToken" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "创建一个新的 AccessToken", + "parameters": [ + { + "description": "令牌名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "新建令牌成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或超限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "撤销并删除一个属于当前用户的 API 访问令牌", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "删除一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "轮换一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "令牌轮换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/change-password": { + "post": { + "description": "修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改用户密码", + "parameters": [ + { + "description": "修改密码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.changePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "修改密码成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "原密码错误或新密码不符合要求", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "请先登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/login": { + "post": { + "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户密码登录", + "parameters": [ + { + "description": "登录请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.loginRequest" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "用户名或密码错误、帐号已禁用等", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除用户登录 Session,完成退出", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/profile": { + "put": { + "description": "修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改当前登录用户的个人资料", + "parameters": [ + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功,返回更新后的用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "邮箱已被占用或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/register": { + "post": { + "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.registerRequest" + } + } + ], + "responses": { + "200": { + "description": "注册并登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误、用户名已存在或注册已关闭", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/self": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/send-email-code": { + "post": { + "description": "向指定邮箱发送验证码(用于注册场景)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "发送邮箱验证码", + "parameters": [ + { + "description": "发送验证码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.sendEmailCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "发送成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/f/{id}": { + "get": { + "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "summary": "获取已上传文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功获取文件内容", + "schema": { + "type": "file" + } + }, + "400": { + "description": "文件 ID 格式错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件未找到", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/robots.txt": { + "get": { + "description": "根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容", + "produces": [ + "text/plain" + ], + "tags": [ + "config" + ], + "summary": "获取 robots.txt", + "responses": { + "200": { + "description": "robots.txt 内容", + "schema": { + "type": "string" } } } @@ -3002,350 +12176,1073 @@ } }, "definitions": { - "controller.CleanupConfigVersionRequest": { + "apply_log.CleanupInput": { "type": "object", - "required": [ - "keep_count" - ], "properties": { - "keep_count": { - "type": "integer", - "minimum": 3 + "delete_all": { + "type": "boolean" + }, + "retention_days": { + "type": "integer" } } }, - "controller.DnsAccountInput": { + "apply_log.CleanupResult": { "type": "object", "properties": { - "authorization": { + "cutoff": { "type": "string" }, + "delete_all": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.ListResult": { + "type": "object", + "properties": { + "current": { + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + }, + "total": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + } + }, + "auth_source.AuthSourceRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, "name": { "type": "string" }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, "type": { "type": "string" } } }, - "controller.geoIPLookupRequest": { + "auth_source.ToggleAuthSourceRequest": { "type": "object", "properties": { - "ip": { - "type": "string" + "is_active": { + "type": "boolean" + } + } + }, + "cache.updateCacheConfigRequest": { + "type": "object", + "required": [ + "max_size_mb", + "ttl_minutes" + ], + "properties": { + "lru_enabled": { + "type": "boolean" }, - "provider": { - "type": "string" - } - } - }, - "controller.optionBatchPayload": { - "type": "object", - "properties": { - "options": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Option" - } - } - } - }, - "model.Option": { - "type": "object", - "properties": { - "key": { - "type": "string" + "max_size_mb": { + "type": "integer", + "minimum": 1 }, - "value": { - "type": "string" + "ttl_minutes": { + "type": "integer", + "minimum": 0 } } }, - "service.AgentBufferedObservabilityRecord": { + "cap.ChallengeResponse": { "type": "object", "properties": { - "access_logs": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeAccessLog" + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } } }, - "openresty_observation": { - "$ref": "#/definitions/service.AgentNodeOpenrestyObservation" + "expires": { + "description": "ms timestamp", + "type": "integer" }, - "snapshot": { - "$ref": "#/definitions/service.AgentNodeMetricSnapshot" + "token": { + "type": "string" + } + } + }, + "cap.challengeRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + } + }, + "cap.redeemRequest": { + "type": "object", + "required": [ + "solutions", + "token" + ], + "properties": { + "scope": { + "type": "string" }, - "traffic_report": { - "$ref": "#/definitions/service.AgentNodeTrafficReport" + "solutions": { + "type": "array", + "items": { + "type": "integer" + } }, - "window_started_at_unix": { + "token": { + "type": "string" + } + } + }, + "config_version.CleanupInput": { + "type": "object", + "properties": { + "keep_count": { "type": "integer" } } }, - "service.AgentNodeAccessLog": { + "config_version.CleanupResult": { "type": "object", "properties": { - "host": { - "type": "string" - }, - "logged_at_unix": { + "deleted_count": { "type": "integer" }, - "path": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "status_code": { - "type": "integer" - } - } - }, - "service.AgentNodeHealthEvent": { - "type": "object", - "properties": { - "event_type": { - "type": "string" - }, "message": { "type": "string" + } + } + }, + "config_version.ConfigDiffResult": { + "type": "object", + "properties": { + "active_version": { + "type": "string" }, - "metadata": { - "type": "object", - "additionalProperties": { + "active_website_count": { + "type": "integer" + }, + "added_domains": { + "type": "array", + "items": { "type": "string" } }, - "severity": { + "added_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed_option_details": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.ConfigOptionDiffItem" + } + }, + "changed_option_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "current_website_count": { + "type": "integer" + }, + "main_config_changed": { + "type": "boolean" + }, + "modified_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "modified_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "waf_config_changed": { + "type": "boolean" + } + } + }, + "config_version.ConfigOptionDiffItem": { + "type": "object", + "properties": { + "current_value": { "type": "string" }, - "triggered_at_unix": { + "key": { + "type": "string" + }, + "previous_value": { + "type": "string" + } + } + }, + "config_version.ConfigPreviewResult": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "route_config": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "snapshot_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.SupportFile" + } + }, + "website_count": { "type": "integer" } } }, - "service.AgentNodeMetricSnapshot": { + "config_version.SupportFile": { "type": "object", "properties": { - "captured_at_unix": { - "type": "integer" + "content": { + "type": "string" }, - "cpu_usage_percent": { + "path": { + "type": "string" + } + } + }, + "dashboard.Capacity": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { "type": "number" }, - "disk_read_bytes": { + "average_memory_usage_percent": { + "type": "number" + }, + "high_cpu_nodes": { "type": "integer" }, - "disk_write_bytes": { + "high_memory_nodes": { "type": "integer" }, - "memory_total_bytes": { - "type": "integer" - }, - "memory_used_bytes": { - "type": "integer" - }, - "network_rx_bytes": { - "type": "integer" - }, - "network_tx_bytes": { - "type": "integer" - }, - "storage_total_bytes": { - "type": "integer" - }, - "storage_used_bytes": { + "high_storage_nodes": { "type": "integer" } } }, - "service.AgentNodeOpenrestyObservation": { + "dashboard.OverviewPayload": { "type": "object", "properties": { - "captured_at_unix": { - "type": "integer" + "capacity": { + "$ref": "#/definitions/dashboard.Capacity" }, - "openresty_connections": { - "type": "integer" + "distributions": { + "$ref": "#/definitions/dashboard.distributionsPayload" }, - "openresty_rx_bytes": { - "type": "integer" + "generated_at": {}, + "nodes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } }, - "openresty_tx_bytes": { - "type": "integer" + "summary": { + "$ref": "#/definitions/dashboard.Summary" + }, + "traffic": { + "$ref": "#/definitions/dashboard.Traffic" + }, + "trends": { + "$ref": "#/definitions/dashboard.trendsPayload" } } }, - "service.AgentNodePayload": { + "dashboard.Summary": { "type": "object", "properties": { - "access_logs": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeAccessLog" - } - }, - "buffered_observability": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentBufferedObservabilityRecord" - } - }, - "current_version": { - "type": "string" - }, - "ext_version": { - "type": "string" - }, - "health_events": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeHealthEvent" - } - }, - "ip": { - "type": "string" - }, - "last_error": { - "type": "string" - }, - "name": { - "type": "string" - }, - "node_id": { - "type": "string" - }, - "openresty_message": { - "type": "string" - }, - "openresty_observation": { - "$ref": "#/definitions/service.AgentNodeOpenrestyObservation" - }, - "openresty_status": { - "type": "string" - }, - "profile": { - "$ref": "#/definitions/service.AgentNodeSystemProfile" - }, - "snapshot": { - "$ref": "#/definitions/service.AgentNodeMetricSnapshot" - }, - "traffic_report": { - "$ref": "#/definitions/service.AgentNodeTrafficReport" - }, - "version": { - "type": "string" - }, - "waf_ip_group_checksums": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "service.AgentNodeSystemProfile": { - "type": "object", - "properties": { - "architecture": { - "type": "string" - }, - "cpu_cores": { + "offline_nodes": { "type": "integer" }, - "cpu_model": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "kernel_version": { - "type": "string" - }, - "os_name": { - "type": "string" - }, - "os_version": { - "type": "string" - }, - "reported_at_unix": { + "online_nodes": { "type": "integer" }, - "total_disk_bytes": { + "pending_nodes": { "type": "integer" }, - "total_memory_bytes": { + "total_nodes": { "type": "integer" }, - "uptime_seconds": { + "unhealthy_nodes": { "type": "integer" } } }, - "service.AgentNodeTrafficReport": { + "dashboard.Traffic": { "type": "object", "properties": { "error_count": { "type": "integer" }, + "estimated_qps": { + "type": "number" + }, + "reported_nodes": { + "type": "integer" + }, "request_count": { "type": "integer" }, + "unique_visitors": { + "type": "integer" + } + } + }, + "dashboard.distributionsPayload": { + "type": "object", + "properties": { "source_countries": { - "type": "object", - "additionalProperties": { - "type": "integer" + "type": "array", + "items": { + "type": "array", + "items": {} } }, "status_codes": { - "type": "object", - "additionalProperties": { - "type": "integer" + "type": "array", + "items": { + "type": "array", + "items": {} } }, "top_domains": { - "type": "object", - "additionalProperties": { - "type": "integer" + "type": "array", + "items": { + "type": "array", + "items": {} } - }, - "unique_visitor_count": { - "type": "integer" - }, - "window_ended_at_unix": { - "type": "integer" - }, - "window_started_at_unix": { - "type": "integer" } } }, - "service.AgentWAFIPGroupSyncInput": { + "dashboard.trendsPayload": { "type": "object", "properties": { - "checksums": { - "type": "object", - "additionalProperties": { + "capacity_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "network_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "traffic_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, + "db_manage.DBOverviewResponse": { + "type": "object", + "properties": { + "connections": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "string" + }, + "table_count": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "db_manage.ExecuteSQLRequest": { + "type": "object", + "required": [ + "sql" + ], + "properties": { + "sql": { + "type": "string" + } + } + }, + "db_manage.ExecuteSQLResponse": { + "type": "object", + "properties": { + "affected_rows": { + "type": "integer" + }, + "columns": { + "type": "array", + "items": { "type": "string" } }, - "ids": { + "execution_time_ms": { + "type": "integer" + }, + "results": { "type": "array", "items": { - "type": "integer" + "type": "object", + "additionalProperties": true + } + }, + "type": { + "description": "\"select\" 或 \"exec\"", + "type": "string" + } + } + }, + "diskcache.Status": { + "type": "object", + "properties": { + "base_path": { + "type": "string" + }, + "keys_count": { + "type": "integer" + }, + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "ttl_minutes": { + "type": "integer" + } + } + }, + "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "handler.batchDownloadRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" } } } }, - "service.ApplyLogPayload": { + "handler.distributionItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.fileStatsResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + }, + "total_count": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.trendItem" + } + }, + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + } + } + }, + "handler.listFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.listMyFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.updateMyFileRequest": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer", + "enum": [ + 0, + 1 + ] + }, + "file_name": { + "type": "string", + "maxLength": 255 + } + } + }, + "logger.LogEntry": { + "type": "object", + "properties": { + "data": { + "description": "一行日志原文(含换行符)", + "type": "string" + }, + "index": { + "description": "全局递增序号", + "type": "integer" + } + } + }, + "logs.accessLogItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "headers": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "ip": { + "type": "string" + }, + "latency": { + "type": "integer" + }, + "method": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "path": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_agent": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "logs.accessLogsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.accessLogItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "logs.browserItem": { + "type": "object", + "properties": { + "browser": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + }, + "logs.logsAnalyticsResponse": { + "type": "object", + "properties": { + "browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.browserItem" + } + }, + "top_users": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.topUserItem" + } + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.trendItem" + } + } + } + }, + "logs.logsResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/definitions/logger.LogEntry" + } + }, + "next_cursor": { + "description": "用于加载更早日志的 cursor", + "type": "integer" + } + } + }, + "logs.topUserItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "logs.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + } + } + }, + "model.AccessToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "masked_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "model.AcmeAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.AuthSource": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret_configured": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ConfigVersion": { "type": "object", "properties": { "checksum": { "type": "string" }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "snapshot_json": { + "type": "string" + }, + "support_files_json": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.ConfigVersionSummary": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "version": { + "type": "string" + } + } + }, + "model.DNSAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ExternalAccountView": { + "type": "object", + "properties": { + "auth_source_id": { + "type": "integer" + }, + "auth_source_label": { + "type": "string" + }, + "auth_source_name": { + "type": "string" + }, + "auth_source_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "external_username": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "model.ManagedDomain": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.OpenFlareApplyLog": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, "main_config_checksum": { "type": "string" }, @@ -3369,67 +13266,746 @@ } } }, - "service.FlaredConnectedRelay": { + "model.OpenFlareHealthEvent": { "type": "object", "properties": { - "proxy_count": { + "created_at": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "first_triggered_at": { + "type": "string" + }, + "id": { "type": "integer" }, - "relay_node_id": { + "last_triggered_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata_json": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "resolved_at": { + "type": "string" + }, + "severity": { "type": "string" }, "status": { "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "service.FlaredHeartbeatPayload": { + "model.OpenFlareMetricSnapshot": { "type": "object", "properties": { - "client_version": { + "captured_at": { "type": "string" }, - "connected_relays": { + "cpu_usage_percent": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "network_rx_bytes": { + "type": "integer" + }, + "network_tx_bytes": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "model.OpenFlareNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kernel_version": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "model.OpenFlareOption": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "model.OpenFlareRequestReport": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "error_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "request_count": { + "type": "integer" + }, + "source_countries_json": { + "type": "string" + }, + "status_codes_json": { + "type": "string" + }, + "top_domains_json": { + "type": "string" + }, + "unique_visitor_count": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "model.PushChannel": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "description": "备注", + "type": "string" + }, + "enabled": { + "description": "通道是否启用", + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "通道名称,仅英文字母和下划线,唯一", + "type": "string" + }, + "other": { + "description": "请求体/SMTP 密码等", + "type": "string" + }, + "token": { + "description": "鉴权令牌或发信用户名等", + "type": "string" + }, + "type": { + "description": "通道类型:custom, lark, email", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "url": { + "description": "请求地址,HTTPS 协议或 SMTP 地址", + "type": "string" + } + } + }, + "model.PushEvent": { + "type": "object", + "properties": { + "channels": { + "description": "推送渠道列表,如 [\"lark\"]", "type": "array", "items": { - "$ref": "#/definitions/service.FlaredConnectedRelay" + "type": "string" } }, - "current_checksum": { + "created_at": { + "type": "string" + }, + "enabled": { + "description": "是否启用", + "type": "boolean" + }, + "event_key": { + "description": "如 admin_login", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "如 管理员登录", + "type": "string" + }, + "targets": { + "description": "推送目标用户/邮箱列表", + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "description": "关联的异步任务类型", + "type": "string" + }, + "template": { + "description": "消息模板 JSON", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.PushHistory": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "error_msg": { + "type": "string" + }, + "event_key": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "string" + }, + "status": { + "description": "success / failed", + "type": "string" + }, + "target": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "model.Schedule": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.SystemConfig": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer" + } + } + }, + "model.TLSCertificate": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "not_after": { + "type": "string" + }, + "not_before": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.TaskExecution": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "error_message": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "log": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "result": { + "type": "string" + }, + "retry_count": { + "type": "integer" + }, + "retryable": { + "type": "boolean" + }, + "started_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/model.TaskExecutionStatus" + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.TaskExecutionStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "succeeded", + "failed" + ], + "x-enum-varnames": [ + "TaskExecutionStatusPending", + "TaskExecutionStatusRunning", + "TaskExecutionStatusSucceeded", + "TaskExecutionStatusFailed" + ] + }, + "model.Template": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.Upload": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "extension": { + "description": "文件后缀名 (不含点,如 png, pdf)", + "type": "string" + }, + "file_name": { + "description": "原始文件名 (例如: image.png)", + "type": "string" + }, + "file_path": { + "description": "文件相对路径 / S3 Key", + "type": "string" + }, + "file_size": { + "description": "文件大小(字节)", + "type": "integer" + }, + "hash": { + "description": "文件哈希 (SHA-256/MD5,可用于排重)", + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "metadata": { + "description": "业务扩展元数据", + "allOf": [ + { + "$ref": "#/definitions/model.UploadMetadata" + } + ] + }, + "mime_type": { + "description": "媒体类型 (MIME, 如 image/png)", + "type": "string" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/model.UploadStatus" + } + ] + }, + "type": { + "description": "业务标识类型 (如 avatar, doc, attachment)", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "model.UploadMetadata": { + "type": "object", + "properties": { + "bucket": { + "description": "存储桶名称 (适用于 S3 等)", + "type": "string" + }, + "client_ip": { + "description": "上传者 IP", + "type": "string" + }, + "duration": { + "description": "音视频时长 (s)", + "type": "number" + }, + "extra": { + "description": "其它任意业务自定义元数据", + "type": "object", + "additionalProperties": {} + }, + "height": { + "description": "图像/视频高度 (px)", + "type": "integer" + }, + "original_mime": { + "description": "原始 MIME 类型", + "type": "string" + }, + "user_agent": { + "description": "上传者的 UA", + "type": "string" + }, + "width": { + "description": "图像/视频宽度 (px)", + "type": "integer" + } + } + }, + "model.UploadStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "deleted" + ], + "x-enum-comments": { + "UploadStatusDeleted": "已删除", + "UploadStatusPending": "待使用", + "UploadStatusUsed": "已使用" + }, + "x-enum-descriptions": [ + "待使用", + "已使用", + "已删除" + ], + "x-enum-varnames": [ + "UploadStatusPending", + "UploadStatusUsed", + "UploadStatusDeleted" + ] + }, + "node.AgentReleaseInfo": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "channel": { "type": "string" }, "current_version": { "type": "string" }, - "frp_version": { - "type": "string" - }, - "ip": { - "type": "string" - }, - "tunnel_status": { - "type": "string" - } - } - }, - "service.ManagedDomainInput": { - "type": "object", - "properties": { - "cert_id": { - "type": "integer" - }, - "domain": { - "type": "string" - }, - "enabled": { + "has_update": { "type": "boolean" }, - "remark": { + "html_url": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "requested_channel": { + "type": "string" + }, + "requested_tag": { + "type": "string" + }, + "tag_name": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + } + } + }, + "node.AgentUpdateInput": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "tag_name": { "type": "string" } } }, - "service.NodeInput": { + "node.BootstrapView": { + "type": "object", + "properties": { + "discovery_token": { + "type": "string" + } + } + }, + "node.HealthEventCleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + } + }, + "node.Input": { "type": "object", "properties": { "auto_update_enabled": { @@ -3457,7 +14033,6 @@ "type": "string" }, "node_type": { - "description": "TunnelRelay fields", "type": "string" }, "relay_agent_access_addr": { @@ -3480,7 +14055,1199 @@ } } }, - "service.ProxyRouteCustomHeaderInput": { + "node.ObservabilityView": { + "type": "object", + "properties": { + "analytics": { + "$ref": "#/definitions/observability.NodeAnalytics" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareHealthEvent" + } + }, + "metric_snapshots": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareMetricSnapshot" + } + }, + "node_id": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/model.OpenFlareNodeSystemProfile" + }, + "relay_dashboard": { + "$ref": "#/definitions/observability.RelayDashboardSnapshot" + }, + "traffic_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareRequestReport" + } + }, + "trends": { + "$ref": "#/definitions/observability.NodeTrends" + } + } + }, + "node.View": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": {}, + "latest_apply_at": { + "type": "string" + }, + "latest_apply_checksum": { + "type": "string" + }, + "latest_apply_message": { + "type": "string" + }, + "latest_apply_result": { + "type": "string" + }, + "latest_main_config_checksum": { + "type": "string" + }, + "latest_route_config_checksum": { + "type": "string" + }, + "latest_support_file_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "oauth.AuthSourceView": { + "type": "object", + "properties": { + "client_secret_configured": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oauth.BasicUserInfo": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "need_change_password": { + "type": "boolean" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "oauth.CallbackRequest": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "oauth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "oauth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + }, + "observability.AccessLogCleanupInput": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogCleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogIPSummaryList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPSummaryView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + } + } + }, + "observability.AccessLogIPSummaryView": { + "type": "object", + "properties": { + "last_seen_at": { + "type": "string" + }, + "recent_requests": { + "type": "integer" + }, + "remote_addr": { + "type": "string" + }, + "total_requests": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "request_count": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendView": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "hours": { + "type": "integer" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPTrendPoint" + } + }, + "remote_addr": { + "type": "string" + } + } + }, + "observability.AccessLogList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.AccessLogView": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "logged_at": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "status_code": { + "type": "integer" + } + } + }, + "observability.CapacityTrendPoint": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "bucket_started_at": { + "type": "string" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DiskIOTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DistributionItem": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPList": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogIPView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPView": { + "type": "object", + "properties": { + "client_error_count": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogList": { + "type": "object", + "properties": { + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_bucket": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogView": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "client_error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + }, + "unique_host_count": { + "type": "integer" + }, + "unique_ip_count": { + "type": "integer" + } + } + }, + "observability.HealthSummary": { + "type": "object", + "properties": { + "active_alerts": { + "type": "integer" + }, + "critical_alerts": { + "type": "integer" + }, + "has_capacity_risk": { + "type": "boolean" + }, + "has_runtime_risk": { + "type": "boolean" + }, + "has_traffic_risk": { + "type": "boolean" + }, + "info_alerts": { + "type": "integer" + }, + "resolved_alerts": { + "type": "integer" + }, + "warning_alerts": { + "type": "integer" + } + } + }, + "observability.NetworkTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "network_rx_bytes": { + "type": "integer" + }, + "network_tx_bytes": { + "type": "integer" + }, + "openresty_rx_bytes": { + "type": "integer" + }, + "openresty_tx_bytes": { + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.NodeAnalytics": { + "type": "object", + "properties": { + "distributions": { + "$ref": "#/definitions/observability.TrafficDistributions" + }, + "health": { + "$ref": "#/definitions/observability.HealthSummary" + }, + "traffic": { + "$ref": "#/definitions/observability.TrafficWindowSummary" + } + } + }, + "observability.NodeTrends": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.CapacityTrendPoint" + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DiskIOTrendPoint" + } + }, + "network_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NetworkTrendPoint" + } + }, + "traffic_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.TrafficTrendPoint" + } + } + } + }, + "observability.RelayDashboardSnapshot": { + "type": "object", + "properties": { + "client_counts": { + "type": "integer" + }, + "offline_proxies": { + "type": "integer" + }, + "online_proxies": { + "type": "integer" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.RelayProxyStat" + } + }, + "total_connections": { + "type": "integer" + }, + "total_proxies": { + "type": "integer" + } + } + }, + "observability.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "observability.TrafficDistributions": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.TrafficTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + } + } + }, + "observability.TrafficWindowSummary": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "error_rate_percent": { + "type": "number" + }, + "estimated_qps": { + "type": "number" + }, + "request_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "option.databaseCleanupInput": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer" + }, + "target": { + "type": "string" + } + } + }, + "option.databaseCleanupResult": { + "type": "object", + "properties": { + "delete_all": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + }, + "target": { + "type": "string" + }, + "target_label": { + "type": "string" + } + } + }, + "option.geoIPLookupRequest": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.geoIPLookupView": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "iso_code": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.optionBatchPayload": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + }, + "option.publicAuthSourceView": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "option.statusView": { + "type": "object", + "properties": { + "auth_sources": { + "type": "array", + "items": { + "$ref": "#/definitions/option.publicAuthSourceView" + } + }, + "cap_login_enabled": { + "type": "boolean" + }, + "email_verification": { + "type": "boolean" + }, + "footer_html": { + "type": "string" + }, + "github_client_id": { + "type": "string" + }, + "github_oauth": { + "type": "boolean" + }, + "home_page_link": { + "type": "string" + }, + "password_register_enabled": { + "type": "boolean" + }, + "server_address": { + "type": "string" + }, + "start_time": { + "type": "integer" + }, + "system_name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "wechat_login": { + "type": "boolean" + }, + "wechat_qrcode": { + "type": "string" + } + } + }, + "origin.DetailView": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.RouteSummary" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.Input": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "origin.RouteSummary": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.View": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "pages.DeploymentFileView": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "pages.DeploymentView": { + "type": "object", + "properties": { + "activated_at": { + "type": "string" + }, + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "deployment_number": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "total_size": { + "type": "integer" + } + } + }, + "pages.Input": { + "type": "object", + "properties": { + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + } + } + }, + "pages.View": { + "type": "object", + "properties": { + "active_deployment": { + "$ref": "#/definitions/pages.DeploymentView" + }, + "active_deployment_id": { + "type": "integer" + }, + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_count": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "proxy_route.CustomHeaderInput": { "type": "object", "properties": { "key": { @@ -3491,7 +15258,7 @@ } } }, - "service.ProxyRouteInput": { + "proxy_route.Input": { "type": "object", "properties": { "basic_auth_enabled": { @@ -3527,7 +15294,7 @@ "custom_headers": { "type": "array", "items": { - "$ref": "#/definitions/service.ProxyRouteCustomHeaderInput" + "$ref": "#/definitions/proxy_route.CustomHeaderInput" } }, "domain": { @@ -3581,11 +15348,8 @@ "origin_url": { "type": "string" }, - "pow_config": { - "type": "string" - }, - "pow_enabled": { - "type": "boolean" + "pages_project_id": { + "type": "integer" }, "redirect_http": { "type": "boolean" @@ -3619,80 +15383,838 @@ } } }, - "service.RelayHeartbeatPayload": { + "proxy_route.View": { "type": "object", "properties": { - "frp_version": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { "type": "string" }, - "frps_client_count": { - "type": "integer" - }, - "frps_connections": { - "type": "integer" - }, - "frps_proxies": { - "type": "array", - "items": { - "$ref": "#/definitions/service.RelayProxyStat" - } - }, - "frps_proxy_count": { - "type": "integer" - }, - "health_events": { - "type": "array", - "items": { - "$ref": "#/definitions/service.AgentNodeHealthEvent" - } - }, - "ip": { + "basic_auth_username": { "type": "string" }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rule_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "cache_rules": { + "type": "string" + }, + "cert_id": { + "type": "integer" + }, + "cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "custom_header_list": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "custom_headers": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domain_cert_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "domain_count": { + "type": "integer" + }, + "domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "primary_domain": { + "type": "string" + }, + "redirect_http": { + "type": "boolean" + }, + "remark": { + "type": "string" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "upstream_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "string" + } + } + }, + "push.Config": { + "type": "object", + "properties": { + "channel": { + "description": "渠道名称,例如 \"lark\", \"custom\", \"email\" 等,唯一标识", + "type": "string" + }, + "ext": { + "description": "预留拓展 JSON 配置", + "type": "object", + "additionalProperties": {} + }, + "key": { + "description": "AppID 或 SMTP 用户名", + "type": "string" + }, + "secret": { + "description": "签名密钥或 SMTP 密码/Token", + "type": "string" + }, + "url": { + "description": "Webhook 地址或 SMTP 地址", + "type": "string" + } + } + }, + "push.CreateChannelRequest": { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, "name": { "type": "string" }, - "profile": { - "$ref": "#/definitions/service.AgentNodeSystemProfile" - }, - "relay_status": { + "other": { "type": "string" }, - "snapshot": { - "$ref": "#/definitions/service.AgentNodeMetricSnapshot" + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.CreateEventRequest": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "event_key": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "description": "关联的异步任务类型", + "type": "string" + }, + "template": { + "type": "string" + } + } + }, + "push.Definition": { + "type": "object", + "properties": { + "description": { + "description": "short description", + "type": "string" + }, + "fields": { + "description": "form fields", + "type": "array", + "items": { + "$ref": "#/definitions/push.Field" + } + }, + "name": { + "description": "display name", + "type": "string" + }, + "type": { + "description": "channel type (e.g., custom, lark, email)", + "type": "string" + } + } + }, + "push.EventMetadata": { + "type": "object", + "properties": { + "default_template": { + "$ref": "#/definitions/push.NotificationMessage" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "push.Field": { + "type": "object", + "properties": { + "description": { + "description": "field explanation/help text", + "type": "string" + }, + "key": { + "description": "unique key for the field (e.g. url, token, other)", + "type": "string" + }, + "label": { + "description": "human readable label (e.g. \"Webhook 地址\")", + "type": "string" + }, + "placeholder": { + "description": "input placeholder", + "type": "string" + }, + "required": { + "description": "whether this field is required", + "type": "boolean" + }, + "type": { + "description": "input type: \"text\" | \"password\" | \"textarea\"", + "type": "string" + } + } + }, + "push.NotificationMessage": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "ext": { + "type": "object", + "additionalProperties": {} + }, + "level": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "push.TestChannelRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "target": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.TestPushRequest": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/push.Config" + }, + "target": { + "type": "string" + } + } + }, + "push.UpdateChannelRequest": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.UpdateEventRequest": { + "type": "object", + "required": [ + "template" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + } + } + }, + "push.pushHistoriesResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushHistory" + } + }, + "total": { + "type": "integer" + } + } + }, + "response.Any": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string", + "example": "" + } + } + }, + "status.DatabaseInfoResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" }, "version": { "type": "string" } } }, - "service.RelayProxyStat": { + "status.SystemStatusResponse": { "type": "object", "properties": { - "client_addr": { + "alloc": { "type": "string" }, - "client_version": { + "buck_hash_sys": { "type": "string" }, - "last_close_time": { + "frees": { + "type": "integer" + }, + "gc_sys": { "type": "string" }, - "last_start_time": { + "heap_alloc": { "type": "string" }, + "heap_idle": { + "type": "string" + }, + "heap_inuse": { + "type": "string" + }, + "heap_objects": { + "type": "integer" + }, + "heap_released": { + "type": "string" + }, + "heap_sys": { + "type": "string" + }, + "last_gc_time": { + "type": "string" + }, + "last_pause": { + "type": "string" + }, + "lookups": { + "type": "integer" + }, + "mallocs": { + "type": "integer" + }, + "mcache_inuse": { + "type": "string" + }, + "mcache_sys": { + "type": "string" + }, + "mspan_inuse": { + "type": "string" + }, + "mspan_sys": { + "type": "string" + }, + "next_gc": { + "type": "string" + }, + "num_gc": { + "type": "integer" + }, + "num_goroutine": { + "type": "integer" + }, + "other_sys": { + "type": "string" + }, + "pause_total_ns": { + "type": "string" + }, + "stack_inuse": { + "type": "string" + }, + "stack_sys": { + "type": "string" + }, + "sys": { + "type": "string" + }, + "total_alloc": { + "type": "string" + }, + "uptime": { + "type": "string" + } + } + }, + "system_config.CreateSystemConfigRequest": { + "type": "object", + "required": [ + "key", + "type", + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 64 + }, + "type": { + "type": "string", + "enum": [ + "system", + "business" + ] + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "system_config.TestSMTPRequest": { + "type": "object", + "required": [ + "smtp_host", + "smtp_password", + "smtp_port", + "smtp_username", + "to" + ], + "properties": { + "smtp_host": { + "type": "string", + "maxLength": 255 + }, + "smtp_password": { + "type": "string", + "maxLength": 255 + }, + "smtp_port": { + "type": "integer" + }, + "smtp_username": { + "type": "string", + "maxLength": 255 + }, + "to": { + "type": "string" + } + } + }, + "system_config.TestSMTPResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "log": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "system_config.UpdateSystemConfigRequest": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "task.CreateScheduleRequest": { + "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], + "properties": { + "cron": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, "name": { "type": "string" }, - "status": { + "payload": { "type": "string" }, + "task_type": { + "type": "string" + } + } + }, + "task.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "task.TaskMeta": { + "type": "object", + "properties": { + "asynq_task": { + "type": "string" + }, + "description": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskParam" + } + }, + "queue": { + "type": "string" + }, + "retryable": { + "description": "是否支持手动重试", + "type": "boolean" + }, + "supports_time": { + "type": "boolean" + }, "type": { "type": "string" } } }, - "service.TLSApplyInput": { + "task.TaskParam": { + "type": "object", + "properties": { + "description": { + "description": "描述", + "type": "string" + }, + "label": { + "description": "显示名称", + "type": "string" + }, + "name": { + "description": "参数键名", + "type": "string" + }, + "placeholder": { + "description": "占位符", + "type": "string" + }, + "required": { + "description": "是否必填", + "type": "boolean" + }, + "type": { + "description": "类型:string, text, number, boolean", + "type": "string" + } + } + }, + "task.UpdateScheduleRequest": { + "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], + "properties": { + "cron": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + } + } + }, + "template.CreateTemplateRequest": { + "type": "object", + "required": [ + "content", + "key", + "name", + "type" + ], + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 80 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "template.UpdateTemplateRequest": { + "type": "object", + "required": [ + "content", + "name", + "type" + ], + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "tls.ApplyInput": { "type": "object", "properties": { "acme_account_id": { @@ -3733,7 +16255,66 @@ } } }, - "service.TLSCertificateInput": { + "tls.CertificateContent": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "cert_pem": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateInput": { "type": "object", "properties": { "cert_pem": { @@ -3749,20 +16330,799 @@ "type": "string" } } + }, + "tls.DNSAccountInput": { + "type": "object", + "properties": { + "authorization": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "tls.ManagedDomainInput": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "remark": { + "type": "string" + } + } + }, + "tls.ManagedDomainMatchCandidate": { + "type": "object", + "properties": { + "certificate_id": { + "type": "integer" + }, + "certificate_name": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "managed_domain_id": { + "type": "integer" + }, + "match_type": { + "type": "string" + } + } + }, + "tls.ManagedDomainMatchResult": { + "type": "object", + "properties": { + "candidate": { + "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/definitions/tls.ManagedDomainMatchCandidate" + } + }, + "domain": { + "type": "string" + }, + "matched": { + "type": "boolean" + } + } + }, + "updater.Status": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, + "user.changePasswordRequest": { + "type": "object", + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "user.createTokenRequest": { + "type": "object", + "properties": { + "is_admin": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "user.createUserRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + } + } + }, + "user.listUsersResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user.user" + } + } + } + }, + "user.loginRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.registerRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.sendEmailCodeRequest": { + "type": "object", + "required": [ + "email", + "scene" + ], + "properties": { + "email": { + "type": "string" + }, + "scene": { + "type": "string" + } + } + }, + "user.tokenResponse": { + "type": "object", + "properties": { + "record": { + "$ref": "#/definitions/model.AccessToken" + }, + "token": { + "type": "string" + } + } + }, + "user.updateProfileRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "user.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "user.user": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "last_login_at": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "waf.IDsRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestResult": { + "type": "object", + "properties": { + "lookback_minutes": { + "type": "integer" + }, + "matched_count": { + "type": "integer" + }, + "matched_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "rule_count": { + "type": "integer" + }, + "tested_at": { + "type": "string" + } + } + }, + "waf.IPGroupExtIPView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, + "waf.IPGroupInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "waf.IPGroupSyncResult": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/waf.IPGroupView" + }, + "ip_count": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "synced_at": { + "type": "string" + } + } + }, + "waf.IPGroupView": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ext_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupExtIPView" + } + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "last_sync_message": { + "type": "string" + }, + "last_sync_status": { + "type": "string" + }, + "last_synced_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "referenced_by_rule_count": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.PoWConfig": { + "type": "object", + "properties": { + "algorithm": { + "type": "string" + }, + "blacklist": { + "$ref": "#/definitions/waf.PoWListConfig" + }, + "challenge_ttl": { + "type": "integer" + }, + "difficulty": { + "type": "integer" + }, + "session_ttl": { + "type": "integer" + }, + "whitelist": { + "$ref": "#/definitions/waf.PoWListConfig" + } + } + }, + "waf.PoWListConfig": { + "type": "object", + "properties": { + "ip_cidrs": { + "type": "array", + "items": { + "type": "string" + } + }, + "ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "path_regexes": { + "type": "array", + "items": { + "type": "string" + } + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "user_agents": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "waf.RuleGroupInput": { + "type": "object", + "properties": { + "block_response_body": { + "type": "string" + }, + "block_status_code": { + "type": "integer" + }, + "country_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "country_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "ip_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_blacklist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ip_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_whitelist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "name": { + "type": "string" + }, + "pow_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "pow_enabled": { + "type": "boolean" + }, + "region_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "region_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "remark": { + "type": "string" + } + } + }, + "waf.RuleGroupView": { + "type": "object", + "properties": { + "applied_site_count": { + "type": "integer" + }, + "applied_site_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "block_response_body": { + "type": "string" + }, + "block_status_code": { + "type": "integer" + }, + "country_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "country_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_blacklist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ip_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "ip_whitelist_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "is_global": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "pow_config": { + "$ref": "#/definitions/waf.PoWConfig" + }, + "pow_enabled": { + "type": "boolean" + }, + "region_blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "region_whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "remark": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.SiteRuleGroupsView": { + "type": "object", + "properties": { + "applied_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "applied_rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleGroupView" + } + }, + "global_rule_group": { + "$ref": "#/definitions/waf.RuleGroupView" + }, + "route_id": { + "type": "integer" + }, + "rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleGroupView" + } + } + } } }, "securityDefinitions": { - "AccessTokenAuth": { - "description": "Agent API 使用节点专属 Agent Token 或全局 Discovery Token", + "SessionCookie": { "type": "apiKey", - "name": "X-Agent-Token", - "in": "header" - }, - "OpenFlareTokenAuth": { - "description": "管理端 API 使用登录后返回的用户 Token", - "type": "apiKey", - "name": "OPENFLARE_TOKEN", - "in": "header" + "name": "session", + "in": "cookie" } } } \ No newline at end of file diff --git a/openflare-server/docs/swagger.yaml b/openflare-server/docs/swagger.yaml index 02a91b39..a29730ad 100644 --- a/openflare-server/docs/swagger.yaml +++ b/openflare-server/docs/swagger.yaml @@ -1,230 +1,708 @@ basePath: / definitions: - controller.CleanupConfigVersionRequest: + apply_log.CleanupInput: properties: - keep_count: - minimum: 3 + delete_all: + type: boolean + retention_days: type: integer - required: - - keep_count type: object - controller.DnsAccountInput: + apply_log.CleanupResult: properties: - authorization: + cutoff: type: string + delete_all: + type: boolean + deleted_count: + type: integer + retention_days: + type: integer + type: object + apply_log.ListResult: + properties: + current: + type: integer + rows: + items: + $ref: '#/definitions/model.OpenFlareApplyLog' + type: array + total: + type: integer + totalPage: + type: integer + type: object + auth_source.AuthSourceRequest: + properties: + client_id: + type: string + client_secret: + type: string + display_name: + type: string + icon_url: + type: string + is_active: + type: boolean name: type: string + openid_discovery_url: + type: string + scopes: + type: string type: type: string type: object - controller.geoIPLookupRequest: + auth_source.ToggleAuthSourceRequest: properties: - ip: - type: string - provider: + is_active: + type: boolean + type: object + cache.updateCacheConfigRequest: + properties: + lru_enabled: + type: boolean + max_size_mb: + minimum: 1 + type: integer + ttl_minutes: + minimum: 0 + type: integer + required: + - max_size_mb + - ttl_minutes + type: object + cap.ChallengeResponse: + properties: + challenge: + properties: + c: + type: integer + d: + type: integer + s: + type: integer + type: object + expires: + description: ms timestamp + type: integer + token: type: string type: object - controller.optionBatchPayload: + cap.challengeRequest: properties: - options: + scope: + type: string + type: object + cap.redeemRequest: + properties: + scope: + type: string + solutions: items: - $ref: '#/definitions/model.Option' + type: integer type: array - type: object - model.Option: - properties: - key: - type: string - value: + token: type: string + required: + - solutions + - token type: object - service.AgentBufferedObservabilityRecord: + config_version.CleanupInput: properties: - access_logs: - items: - $ref: '#/definitions/service.AgentNodeAccessLog' - type: array - openresty_observation: - $ref: '#/definitions/service.AgentNodeOpenrestyObservation' - snapshot: - $ref: '#/definitions/service.AgentNodeMetricSnapshot' - traffic_report: - $ref: '#/definitions/service.AgentNodeTrafficReport' - window_started_at_unix: + keep_count: type: integer type: object - service.AgentNodeAccessLog: + config_version.CleanupResult: properties: - host: - type: string - logged_at_unix: + deleted_count: type: integer - path: - type: string - remote_addr: - type: string - status_code: - type: integer - type: object - service.AgentNodeHealthEvent: - properties: - event_type: - type: string message: type: string - metadata: - additionalProperties: + type: object + config_version.ConfigDiffResult: + properties: + active_version: + type: string + active_website_count: + type: integer + added_domains: + items: type: string - type: object - severity: - type: string - triggered_at_unix: - type: integer - type: object - service.AgentNodeMetricSnapshot: - properties: - captured_at_unix: - type: integer - cpu_usage_percent: - type: number - disk_read_bytes: - type: integer - disk_write_bytes: - type: integer - memory_total_bytes: - type: integer - memory_used_bytes: - type: integer - network_rx_bytes: - type: integer - network_tx_bytes: - type: integer - storage_total_bytes: - type: integer - storage_used_bytes: - type: integer - type: object - service.AgentNodeOpenrestyObservation: - properties: - captured_at_unix: - type: integer - openresty_connections: - type: integer - openresty_rx_bytes: - type: integer - openresty_tx_bytes: - type: integer - type: object - service.AgentNodePayload: - properties: - access_logs: - items: - $ref: '#/definitions/service.AgentNodeAccessLog' type: array - buffered_observability: + added_sites: items: - $ref: '#/definitions/service.AgentBufferedObservabilityRecord' - type: array - current_version: - type: string - ext_version: - type: string - health_events: - items: - $ref: '#/definitions/service.AgentNodeHealthEvent' - type: array - ip: - type: string - last_error: - type: string - name: - type: string - node_id: - type: string - openresty_message: - type: string - openresty_observation: - $ref: '#/definitions/service.AgentNodeOpenrestyObservation' - openresty_status: - type: string - profile: - $ref: '#/definitions/service.AgentNodeSystemProfile' - snapshot: - $ref: '#/definitions/service.AgentNodeMetricSnapshot' - traffic_report: - $ref: '#/definitions/service.AgentNodeTrafficReport' - version: - type: string - waf_ip_group_checksums: - additionalProperties: type: string - type: object - type: object - service.AgentNodeSystemProfile: - properties: - architecture: - type: string - cpu_cores: - type: integer - cpu_model: - type: string - hostname: - type: string - kernel_version: - type: string - os_name: - type: string - os_version: - type: string - reported_at_unix: - type: integer - total_disk_bytes: - type: integer - total_memory_bytes: - type: integer - uptime_seconds: - type: integer - type: object - service.AgentNodeTrafficReport: - properties: - error_count: - type: integer - request_count: - type: integer - source_countries: - additionalProperties: - type: integer - type: object - status_codes: - additionalProperties: - type: integer - type: object - top_domains: - additionalProperties: - type: integer - type: object - unique_visitor_count: - type: integer - window_ended_at_unix: - type: integer - window_started_at_unix: - type: integer - type: object - service.AgentWAFIPGroupSyncInput: - properties: - checksums: - additionalProperties: - type: string - type: object - ids: - items: - type: integer type: array + changed_option_details: + items: + $ref: '#/definitions/config_version.ConfigOptionDiffItem' + type: array + changed_option_keys: + items: + type: string + type: array + current_website_count: + type: integer + main_config_changed: + type: boolean + modified_domains: + items: + type: string + type: array + modified_sites: + items: + type: string + type: array + removed_domains: + items: + type: string + type: array + removed_sites: + items: + type: string + type: array + waf_config_changed: + type: boolean type: object - service.ApplyLogPayload: + config_version.ConfigOptionDiffItem: + properties: + current_value: + type: string + key: + type: string + previous_value: + type: string + type: object + config_version.ConfigPreviewResult: properties: checksum: type: string + main_config: + type: string + rendered_config: + type: string + route_config: + type: string + route_count: + type: integer + snapshot_json: + type: string + support_files: + items: + $ref: '#/definitions/config_version.SupportFile' + type: array + website_count: + type: integer + type: object + config_version.SupportFile: + properties: + content: + type: string + path: + type: string + type: object + dashboard.Capacity: + properties: + average_cpu_usage_percent: + type: number + average_memory_usage_percent: + type: number + high_cpu_nodes: + type: integer + high_memory_nodes: + type: integer + high_storage_nodes: + type: integer + type: object + dashboard.OverviewPayload: + properties: + capacity: + $ref: '#/definitions/dashboard.Capacity' + distributions: + $ref: '#/definitions/dashboard.distributionsPayload' + generated_at: {} + nodes: + items: + items: {} + type: array + type: array + summary: + $ref: '#/definitions/dashboard.Summary' + traffic: + $ref: '#/definitions/dashboard.Traffic' + trends: + $ref: '#/definitions/dashboard.trendsPayload' + type: object + dashboard.Summary: + properties: + offline_nodes: + type: integer + online_nodes: + type: integer + pending_nodes: + type: integer + total_nodes: + type: integer + unhealthy_nodes: + type: integer + type: object + dashboard.Traffic: + properties: + error_count: + type: integer + estimated_qps: + type: number + reported_nodes: + type: integer + request_count: + type: integer + unique_visitors: + type: integer + type: object + dashboard.distributionsPayload: + properties: + source_countries: + items: + items: {} + type: array + type: array + status_codes: + items: + items: {} + type: array + type: array + top_domains: + items: + items: {} + type: array + type: array + type: object + dashboard.trendsPayload: + properties: + capacity_24h: + items: + items: {} + type: array + type: array + disk_io_24h: + items: + items: {} + type: array + type: array + network_24h: + items: + items: {} + type: array + type: array + traffic_24h: + items: + items: {} + type: array + type: array + type: object + db_manage.DBOverviewResponse: + properties: + connections: + type: integer + name: + type: string + size: + type: string + table_count: + type: integer + type: + type: string + version: + type: string + type: object + db_manage.ExecuteSQLRequest: + properties: + sql: + type: string + required: + - sql + type: object + db_manage.ExecuteSQLResponse: + properties: + affected_rows: + type: integer + columns: + items: + type: string + type: array + execution_time_ms: + type: integer + results: + items: + additionalProperties: true + type: object + type: array + type: + description: '"select" 或 "exec"' + type: string + type: object + diskcache.Status: + properties: + base_path: + type: string + keys_count: + type: integer + lru_enabled: + type: boolean + max_size_mb: + type: integer + total_size: + type: integer + ttl_minutes: + type: integer + type: object + github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse: + properties: + error: + type: string + expires: + type: integer + success: + type: boolean + token: + type: string + type: object + handler.batchDownloadRequest: + properties: + ids: + items: + type: string + minItems: 1 + type: array + required: + - ids + type: object + handler.distributionItem: + properties: + count: + type: integer + name: + type: string + size: + type: integer + type: object + handler.fileStatsResponse: + properties: + categories: + items: + $ref: '#/definitions/handler.distributionItem' + type: array + total_count: + type: integer + total_size: + type: integer + trend: + items: + $ref: '#/definitions/handler.trendItem' + type: array + types: + items: + $ref: '#/definitions/handler.distributionItem' + type: array + type: object + handler.listFilesResponse: + properties: + items: + items: + $ref: '#/definitions/model.Upload' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + handler.listMyFilesResponse: + properties: + items: + items: + $ref: '#/definitions/model.Upload' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + handler.trendItem: + properties: + count: + type: integer + date: + type: string + size: + type: integer + type: object + handler.updateMyFileRequest: + properties: + access_mode: + enum: + - 0 + - 1 + type: integer + file_name: + maxLength: 255 + type: string + type: object + logger.LogEntry: + properties: + data: + description: 一行日志原文(含换行符) + type: string + index: + description: 全局递增序号 + type: integer + type: object + logs.accessLogItem: + properties: + created_at: + type: string + headers: + type: string + id: + example: "0" + type: string + ip: + type: string + latency: + type: integer + method: + type: string + nickname: + type: string + path: + type: string + status: + type: integer + user_agent: + type: string + user_id: + example: "0" + type: string + username: + type: string + type: object + logs.accessLogsResponse: + properties: + list: + items: + $ref: '#/definitions/logs.accessLogItem' + type: array + total: + type: integer + type: object + logs.browserItem: + properties: + browser: + type: string + count: + type: integer + type: object + logs.logsAnalyticsResponse: + properties: + browsers: + items: + $ref: '#/definitions/logs.browserItem' + type: array + top_users: + items: + $ref: '#/definitions/logs.topUserItem' + type: array + trend: + items: + $ref: '#/definitions/logs.trendItem' + type: array + type: object + logs.logsResponse: + properties: + has_more: + type: boolean + lines: + items: + $ref: '#/definitions/logger.LogEntry' + type: array + next_cursor: + description: 用于加载更早日志的 cursor + type: integer + type: object + logs.topUserItem: + properties: + count: + type: integer + nickname: + type: string + user_id: + example: "0" + type: string + username: + type: string + type: object + logs.trendItem: + properties: + count: + type: integer + date: + type: string + type: object + model.AccessToken: + properties: + created_at: + type: string + id: + type: integer + is_admin: + type: boolean + masked_token: + type: string + name: + type: string + updated_at: + type: string + user_id: + type: integer + type: object + model.AcmeAccount: + properties: + created_at: + type: string + email: + type: string + id: + type: integer + updated_at: + type: string + url: + type: string + type: object + model.AuthSource: + properties: + client_id: + type: string + client_secret_configured: + type: boolean + created_at: + type: string + display_name: + type: string + icon_url: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + openid_discovery_url: + type: string + scopes: + type: string + type: + type: string + updated_at: + type: string + type: object + model.ConfigVersion: + properties: + checksum: + type: string + created_at: + type: string + created_by: + type: string + id: + type: integer + is_active: + type: boolean + main_config: + type: string + rendered_config: + type: string + snapshot_json: + type: string + support_files_json: + type: string + version: + type: string + type: object + model.ConfigVersionSummary: + properties: + checksum: + type: string + created_at: + type: string + created_by: + type: string + id: + type: integer + is_active: + type: boolean + version: + type: string + type: object + model.DNSAccount: + properties: + created_at: + type: string + id: + type: integer + name: + type: string + type: + type: string + updated_at: + type: string + type: object + model.ExternalAccountView: + properties: + auth_source_id: + type: integer + auth_source_label: + type: string + auth_source_name: + type: string + auth_source_type: + type: string + created_at: + type: string + email: + type: string + external_username: + type: string + id: + type: integer + type: object + model.ManagedDomain: + properties: + cert_id: + type: integer + created_at: + type: string + domain: + type: string + enabled: + type: boolean + id: + type: integer + remark: + type: string + updated_at: + type: string + type: object + model.OpenFlareApplyLog: + properties: + checksum: + type: string + created_at: + type: string + id: + type: integer main_config_checksum: type: string message: @@ -240,46 +718,507 @@ definitions: version: type: string type: object - service.FlaredConnectedRelay: + model.OpenFlareHealthEvent: properties: - proxy_count: + created_at: + type: string + event_type: + type: string + first_triggered_at: + type: string + id: type: integer - relay_node_id: + last_triggered_at: + type: string + message: + type: string + metadata_json: + type: string + node_id: + type: string + reported_at: + type: string + resolved_at: + type: string + severity: type: string status: type: string - type: object - service.FlaredHeartbeatPayload: - properties: - client_version: + updated_at: type: string - connected_relays: + type: object + model.OpenFlareMetricSnapshot: + properties: + captured_at: + type: string + cpu_usage_percent: + type: number + created_at: + type: string + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + id: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + network_rx_bytes: + type: integer + network_tx_bytes: + type: integer + node_id: + type: string + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + model.OpenFlareNodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + created_at: + type: string + hostname: + type: string + id: + type: integer + kernel_version: + type: string + node_id: + type: string + os_name: + type: string + os_version: + type: string + reported_at: + type: string + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + updated_at: + type: string + uptime_seconds: + type: integer + type: object + model.OpenFlareOption: + properties: + key: + type: string + value: + type: string + type: object + model.OpenFlareRequestReport: + properties: + created_at: + type: string + error_count: + type: integer + id: + type: integer + node_id: + type: string + request_count: + type: integer + source_countries_json: + type: string + status_codes_json: + type: string + top_domains_json: + type: string + unique_visitor_count: + type: integer + window_ended_at: + type: string + window_started_at: + type: string + type: object + model.PushChannel: + properties: + created_at: + type: string + description: + description: 备注 + type: string + enabled: + description: 通道是否启用 + type: boolean + id: + type: integer + name: + description: 通道名称,仅英文字母和下划线,唯一 + type: string + other: + description: 请求体/SMTP 密码等 + type: string + token: + description: 鉴权令牌或发信用户名等 + type: string + type: + description: 通道类型:custom, lark, email + type: string + updated_at: + type: string + url: + description: 请求地址,HTTPS 协议或 SMTP 地址 + type: string + type: object + model.PushEvent: + properties: + channels: + description: 推送渠道列表,如 ["lark"] items: - $ref: '#/definitions/service.FlaredConnectedRelay' + type: string type: array - current_checksum: + created_at: + type: string + enabled: + description: 是否启用 + type: boolean + event_key: + description: 如 admin_login + type: string + id: + type: integer + name: + description: 如 管理员登录 + type: string + targets: + description: 推送目标用户/邮箱列表 + items: + type: string + type: array + task_type: + description: 关联的异步任务类型 + type: string + template: + description: 消息模板 JSON + type: string + updated_at: + type: string + type: object + model.PushHistory: + properties: + channel: + type: string + content: + type: string + created_at: + type: string + error_msg: + type: string + event_key: + type: string + id: + type: integer + level: + type: string + status: + description: success / failed + type: string + target: + type: string + title: + type: string + type: object + model.Schedule: + properties: + created_at: + type: string + cron: + type: string + id: + example: "0" + type: string + is_active: + type: boolean + name: + type: string + payload: + type: string + task_type: + type: string + updated_at: + type: string + type: object + model.SystemConfig: + properties: + created_at: + type: string + description: + type: string + key: + type: string + type: + type: string + updated_at: + type: string + value: + type: string + visibility: + type: integer + type: object + model.TLSCertificate: + properties: + acme_account_id: + type: integer + apply_message: + type: string + apply_status: + type: string + auto_renew: + type: boolean + created_at: + type: string + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + id: + type: integer + key_algorithm: + type: string + name: + type: string + not_after: + type: string + not_before: + type: string + other_domains: + type: string + primary_domain: + type: string + provider: + type: string + remark: + type: string + skip_dns: + type: boolean + updated_at: + type: string + type: object + model.TaskExecution: + properties: + created_at: + type: string + duration: + type: integer + error_message: + type: string + finished_at: + type: string + id: + example: "0" + type: string + log: + type: string + max_retry: + type: integer + payload: + type: string + result: + type: string + retry_count: + type: integer + retryable: + type: boolean + started_at: + type: string + status: + $ref: '#/definitions/model.TaskExecutionStatus' + task_id: + type: string + task_name: + type: string + task_type: + type: string + triggered_by: + type: string + updated_at: + type: string + type: object + model.TaskExecutionStatus: + enum: + - pending + - running + - succeeded + - failed + type: string + x-enum-varnames: + - TaskExecutionStatusPending + - TaskExecutionStatusRunning + - TaskExecutionStatusSucceeded + - TaskExecutionStatusFailed + model.Template: + properties: + content: + type: string + created_at: + type: string + description: + type: string + id: + type: integer + is_system: + type: boolean + key: + type: string + name: + type: string + subject: + type: string + type: + type: string + updated_at: + type: string + type: object + model.Upload: + properties: + access_mode: + type: integer + created_at: + type: string + extension: + description: 文件后缀名 (不含点,如 png, pdf) + type: string + file_name: + description: '原始文件名 (例如: image.png)' + type: string + file_path: + description: 文件相对路径 / S3 Key + type: string + file_size: + description: 文件大小(字节) + type: integer + hash: + description: 文件哈希 (SHA-256/MD5,可用于排重) + type: string + id: + example: "0" + type: string + metadata: + allOf: + - $ref: '#/definitions/model.UploadMetadata' + description: 业务扩展元数据 + mime_type: + description: 媒体类型 (MIME, 如 image/png) + type: string + status: + allOf: + - $ref: '#/definitions/model.UploadStatus' + description: 状态 + type: + description: 业务标识类型 (如 avatar, doc, attachment) + type: string + updated_at: + type: string + user_id: + example: "0" + type: string + type: object + model.UploadMetadata: + properties: + bucket: + description: 存储桶名称 (适用于 S3 等) + type: string + client_ip: + description: 上传者 IP + type: string + duration: + description: 音视频时长 (s) + type: number + extra: + additionalProperties: {} + description: 其它任意业务自定义元数据 + type: object + height: + description: 图像/视频高度 (px) + type: integer + original_mime: + description: 原始 MIME 类型 + type: string + user_agent: + description: 上传者的 UA + type: string + width: + description: 图像/视频宽度 (px) + type: integer + type: object + model.UploadStatus: + enum: + - pending + - used + - deleted + type: string + x-enum-comments: + UploadStatusDeleted: 已删除 + UploadStatusPending: 待使用 + UploadStatusUsed: 已使用 + x-enum-descriptions: + - 待使用 + - 已使用 + - 已删除 + x-enum-varnames: + - UploadStatusPending + - UploadStatusUsed + - UploadStatusDeleted + node.AgentReleaseInfo: + properties: + body: + type: string + channel: type: string current_version: type: string - frp_version: - type: string - ip: - type: string - tunnel_status: - type: string - type: object - service.ManagedDomainInput: - properties: - cert_id: - type: integer - domain: - type: string - enabled: + has_update: type: boolean - remark: + html_url: + type: string + prerelease: + type: boolean + published_at: + type: string + requested_channel: + type: string + requested_tag: + type: string + tag_name: + type: string + update_requested: + type: boolean + type: object + node.AgentUpdateInput: + properties: + channel: + type: string + tag_name: type: string type: object - service.NodeInput: + node.BootstrapView: + properties: + discovery_token: + type: string + type: object + node.HealthEventCleanupResult: + properties: + deleted_count: + type: integer + node_id: + type: string + type: object + node.Input: properties: auto_update_enabled: type: boolean @@ -298,7 +1237,6 @@ definitions: name: type: string node_type: - description: TunnelRelay fields type: string relay_agent_access_addr: type: string @@ -313,14 +1251,794 @@ definitions: relay_web_server_enabled: type: boolean type: object - service.ProxyRouteCustomHeaderInput: + node.ObservabilityView: + properties: + analytics: + $ref: '#/definitions/observability.NodeAnalytics' + health_events: + items: + $ref: '#/definitions/model.OpenFlareHealthEvent' + type: array + metric_snapshots: + items: + $ref: '#/definitions/model.OpenFlareMetricSnapshot' + type: array + node_id: + type: string + profile: + $ref: '#/definitions/model.OpenFlareNodeSystemProfile' + relay_dashboard: + $ref: '#/definitions/observability.RelayDashboardSnapshot' + traffic_reports: + items: + $ref: '#/definitions/model.OpenFlareRequestReport' + type: array + trends: + $ref: '#/definitions/observability.NodeTrends' + type: object + node.View: + properties: + access_token: + type: string + auto_update_enabled: + type: boolean + created_at: + type: string + current_version: + type: string + ext_version: + type: string + geo_latitude: + type: number + geo_longitude: + type: number + geo_manual_override: + type: boolean + geo_name: + type: string + id: + type: integer + ip: + type: string + ip_manual_override: + type: boolean + last_error: + type: string + last_seen_at: {} + latest_apply_at: + type: string + latest_apply_checksum: + type: string + latest_apply_message: + type: string + latest_apply_result: + type: string + latest_main_config_checksum: + type: string + latest_route_config_checksum: + type: string + latest_support_file_count: + type: integer + name: + type: string + node_id: + type: string + node_type: + type: string + openresty_message: + type: string + openresty_status: + type: string + relay_agent_access_addr: + type: string + relay_bind_port: + type: integer + relay_client_access_addr: + type: string + relay_client_proxy_url: + type: string + relay_status: + type: string + relay_vhost_http_port: + type: integer + relay_web_server_enabled: + type: boolean + restart_openresty_requested: + type: boolean + status: + type: string + update_channel: + type: string + update_requested: + type: boolean + update_tag: + type: string + updated_at: + type: string + version: + type: string + type: object + oauth.AuthSourceView: + properties: + client_secret_configured: + type: boolean + display_name: + type: string + icon_url: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + type: + type: string + type: object + oauth.BasicUserInfo: + properties: + avatar_url: + type: string + bio: + type: string + email: + type: string + gender: + type: string + id: + type: integer + is_admin: + type: boolean + location: + type: string + need_change_password: + type: boolean + nickname: + type: string + phone: + type: string + username: + type: string + website: + type: string + type: object + oauth.CallbackRequest: + properties: + code: + type: string + state: + type: string + required: + - code + - state + type: object + oauth.OAuthAuthorizeResponse: + properties: + authorize_url: + type: string + type: object + oauth.OAuthCallbackResult: + properties: + status: + type: string + user: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + observability.AccessLogCleanupInput: + properties: + retention_days: + type: integer + type: object + observability.AccessLogCleanupResult: + properties: + cutoff: + type: string + deleted_count: + type: integer + retention_days: + type: integer + type: object + observability.AccessLogIPSummaryList: + properties: + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.AccessLogIPSummaryView' + type: array + page: + type: integer + page_size: + type: integer + sort_by: + type: string + sort_order: + type: string + total_ip: + type: integer + type: object + observability.AccessLogIPSummaryView: + properties: + last_seen_at: + type: string + recent_requests: + type: integer + remote_addr: + type: string + total_requests: + type: integer + type: object + observability.AccessLogIPTrendPoint: + properties: + bucket_started_at: + type: string + request_count: + type: integer + type: object + observability.AccessLogIPTrendView: + properties: + bucket_minutes: + type: integer + hours: + type: integer + points: + items: + $ref: '#/definitions/observability.AccessLogIPTrendPoint' + type: array + remote_addr: + type: string + type: object + observability.AccessLogList: + properties: + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.AccessLogView' + type: array + page: + type: integer + page_size: + type: integer + total_ip: + type: integer + total_record: + type: integer + type: object + observability.AccessLogView: + properties: + host: + type: string + id: + type: integer + logged_at: + type: string + node_id: + type: string + node_name: + type: string + path: + type: string + region: + type: string + remote_addr: + type: string + status_code: + type: integer + type: object + observability.CapacityTrendPoint: + properties: + average_cpu_usage_percent: + type: number + average_memory_usage_percent: + type: number + bucket_started_at: + type: string + reported_nodes: + type: integer + type: object + observability.DiskIOTrendPoint: + properties: + bucket_started_at: + type: string + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + reported_nodes: + type: integer + type: object + observability.DistributionItem: + properties: + key: + type: string + value: + type: integer + type: object + observability.FoldedAccessLogIPList: + properties: + bucket_started_at: + type: string + fold_minutes: + type: integer + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.FoldedAccessLogIPView' + type: array + page: + type: integer + page_size: + type: integer + sort_by: + type: string + sort_order: + type: string + total_ip: + type: integer + type: object + observability.FoldedAccessLogIPView: + properties: + client_error_count: + type: integer + last_seen_at: + type: string + remote_addr: + type: string + request_count: + type: integer + server_error_count: + type: integer + success_count: + type: integer + type: object + observability.FoldedAccessLogList: + properties: + fold_minutes: + type: integer + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.FoldedAccessLogView' + type: array + page: + type: integer + page_size: + type: integer + total_bucket: + type: integer + total_ip: + type: integer + total_record: + type: integer + type: object + observability.FoldedAccessLogView: + properties: + bucket_started_at: + type: string + client_error_count: + type: integer + request_count: + type: integer + server_error_count: + type: integer + success_count: + type: integer + unique_host_count: + type: integer + unique_ip_count: + type: integer + type: object + observability.HealthSummary: + properties: + active_alerts: + type: integer + critical_alerts: + type: integer + has_capacity_risk: + type: boolean + has_runtime_risk: + type: boolean + has_traffic_risk: + type: boolean + info_alerts: + type: integer + resolved_alerts: + type: integer + warning_alerts: + type: integer + type: object + observability.NetworkTrendPoint: + properties: + bucket_started_at: + type: string + network_rx_bytes: + type: integer + network_tx_bytes: + type: integer + openresty_rx_bytes: + type: integer + openresty_tx_bytes: + type: integer + reported_nodes: + type: integer + type: object + observability.NodeAnalytics: + properties: + distributions: + $ref: '#/definitions/observability.TrafficDistributions' + health: + $ref: '#/definitions/observability.HealthSummary' + traffic: + $ref: '#/definitions/observability.TrafficWindowSummary' + type: object + observability.NodeTrends: + properties: + capacity_24h: + items: + $ref: '#/definitions/observability.CapacityTrendPoint' + type: array + disk_io_24h: + items: + $ref: '#/definitions/observability.DiskIOTrendPoint' + type: array + network_24h: + items: + $ref: '#/definitions/observability.NetworkTrendPoint' + type: array + traffic_24h: + items: + $ref: '#/definitions/observability.TrafficTrendPoint' + type: array + type: object + observability.RelayDashboardSnapshot: + properties: + client_counts: + type: integer + offline_proxies: + type: integer + online_proxies: + type: integer + proxies: + items: + $ref: '#/definitions/observability.RelayProxyStat' + type: array + total_connections: + type: integer + total_proxies: + type: integer + type: object + observability.RelayProxyStat: + properties: + client_addr: + type: string + client_version: + type: string + last_close_time: + type: string + last_start_time: + type: string + name: + type: string + status: + type: string + type: + type: string + type: object + observability.TrafficDistributions: + properties: + source_countries: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_domains: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + type: object + observability.TrafficTrendPoint: + properties: + bucket_started_at: + type: string + error_count: + type: integer + request_count: + type: integer + unique_visitor_count: + type: integer + type: object + observability.TrafficWindowSummary: + properties: + error_count: + type: integer + error_rate_percent: + type: number + estimated_qps: + type: number + request_count: + type: integer + unique_visitor_count: + type: integer + window_ended_at: + type: string + window_started_at: + type: string + type: object + option.databaseCleanupInput: + properties: + retention_days: + type: integer + target: + type: string + type: object + option.databaseCleanupResult: + properties: + delete_all: + type: boolean + deleted_count: + type: integer + retention_days: + type: integer + target: + type: string + target_label: + type: string + type: object + option.geoIPLookupRequest: + properties: + ip: + type: string + provider: + type: string + type: object + option.geoIPLookupView: + properties: + ip: + type: string + iso_code: + type: string + latitude: + type: number + longitude: + type: number + name: + type: string + provider: + type: string + type: object + option.optionBatchPayload: + properties: + options: + items: + $ref: '#/definitions/model.OpenFlareOption' + type: array + type: object + option.publicAuthSourceView: + properties: + authorize_url: + type: string + display_name: + type: string + icon_url: + type: string + id: + type: integer + name: + type: string + type: + type: string + type: object + option.statusView: + properties: + auth_sources: + items: + $ref: '#/definitions/option.publicAuthSourceView' + type: array + cap_login_enabled: + type: boolean + email_verification: + type: boolean + footer_html: + type: string + github_client_id: + type: string + github_oauth: + type: boolean + home_page_link: + type: string + password_register_enabled: + type: boolean + server_address: + type: string + start_time: + type: integer + system_name: + type: string + version: + type: string + wechat_login: + type: boolean + wechat_qrcode: + type: string + type: object + origin.DetailView: + properties: + address: + type: string + created_at: + type: string + id: + type: integer + name: + type: string + remark: + type: string + route_count: + type: integer + routes: + items: + $ref: '#/definitions/origin.RouteSummary' + type: array + updated_at: + type: string + type: object + origin.Input: + properties: + address: + type: string + name: + type: string + remark: + type: string + type: object + origin.RouteSummary: + properties: + domain: + type: string + enabled: + type: boolean + id: + type: integer + origin_url: + type: string + updated_at: + type: string + type: object + origin.View: + properties: + address: + type: string + created_at: + type: string + id: + type: integer + name: + type: string + remark: + type: string + route_count: + type: integer + updated_at: + type: string + type: object + pages.DeploymentFileView: + properties: + checksum: + type: string + created_at: + type: string + deployment_id: + type: integer + id: + type: integer + path: + type: string + size: + type: integer + type: object + pages.DeploymentView: + properties: + activated_at: + type: string + checksum: + type: string + created_at: + type: string + created_by: + type: string + deployment_number: + type: integer + file_count: + type: integer + id: + type: integer + project_id: + type: integer + status: + type: string + total_size: + type: integer + type: object + pages.Input: + properties: + api_proxy_enabled: + type: boolean + api_proxy_pass: + type: string + api_proxy_path: + type: string + api_proxy_rewrite: + type: string + description: + type: string + enabled: + type: boolean + entry_file: + type: string + name: + type: string + root_dir: + type: string + slug: + type: string + spa_fallback_enabled: + type: boolean + spa_fallback_path: + type: string + type: object + pages.View: + properties: + active_deployment: + $ref: '#/definitions/pages.DeploymentView' + active_deployment_id: + type: integer + api_proxy_enabled: + type: boolean + api_proxy_pass: + type: string + api_proxy_path: + type: string + api_proxy_rewrite: + type: string + created_at: + type: string + deployment_count: + type: integer + description: + type: string + enabled: + type: boolean + entry_file: + type: string + id: + type: integer + name: + type: string + root_dir: + type: string + slug: + type: string + spa_fallback_enabled: + type: boolean + spa_fallback_path: + type: string + updated_at: + type: string + type: object + proxy_route.CustomHeaderInput: properties: key: type: string value: type: string type: object - service.ProxyRouteInput: + proxy_route.Input: properties: basic_auth_enabled: type: boolean @@ -344,7 +2062,7 @@ definitions: type: array custom_headers: items: - $ref: '#/definitions/service.ProxyRouteCustomHeaderInput' + $ref: '#/definitions/proxy_route.CustomHeaderInput' type: array domain: type: string @@ -380,10 +2098,8 @@ definitions: type: string origin_url: type: string - pow_config: - type: string - pow_enabled: - type: boolean + pages_project_id: + type: integer redirect_http: type: boolean remark: @@ -405,55 +2121,573 @@ definitions: type: string type: array type: object - service.RelayHeartbeatPayload: + proxy_route.View: properties: - frp_version: + basic_auth_enabled: + type: boolean + basic_auth_password: type: string - frps_client_count: - type: integer - frps_connections: - type: integer - frps_proxies: + basic_auth_username: + type: string + cache_enabled: + type: boolean + cache_policy: + type: string + cache_rule_list: items: - $ref: '#/definitions/service.RelayProxyStat' + type: string type: array - frps_proxy_count: + cache_rules: + type: string + cert_id: type: integer - health_events: + cert_ids: items: - $ref: '#/definitions/service.AgentNodeHealthEvent' + type: integer type: array - ip: + created_at: type: string - name: + custom_header_list: + items: + $ref: '#/definitions/proxy_route.CustomHeaderInput' + type: array + custom_headers: type: string - profile: - $ref: '#/definitions/service.AgentNodeSystemProfile' - relay_status: + domain: type: string - snapshot: - $ref: '#/definitions/service.AgentNodeMetricSnapshot' - version: + domain_cert_ids: + items: + type: integer + type: array + domain_count: + type: integer + domains: + items: + type: string + type: array + enable_https: + type: boolean + enabled: + type: boolean + id: + type: integer + limit_conn_per_ip: + type: integer + limit_conn_per_server: + type: integer + limit_rate: + type: string + origin_host: + type: string + origin_id: + type: integer + origin_url: + type: string + pages_project_id: + type: integer + primary_domain: + type: string + redirect_http: + type: boolean + remark: + type: string + site_name: + type: string + tunnel_id: + type: integer + tunnel_node_id: + type: integer + tunnel_target_addr: + type: string + tunnel_target_protocol: + type: string + updated_at: + type: string + upstream_list: + items: + type: string + type: array + upstream_type: + type: string + upstreams: type: string type: object - service.RelayProxyStat: + push.Config: properties: - client_addr: + channel: + description: 渠道名称,例如 "lark", "custom", "email" 等,唯一标识 type: string - client_version: + ext: + additionalProperties: {} + description: 预留拓展 JSON 配置 + type: object + key: + description: AppID 或 SMTP 用户名 type: string - last_close_time: + secret: + description: 签名密钥或 SMTP 密码/Token type: string - last_start_time: + url: + description: Webhook 地址或 SMTP 地址 type: string + type: object + push.CreateChannelRequest: + properties: + description: + type: string + enabled: + type: boolean name: type: string - status: + other: + type: string + token: type: string type: type: string + url: + type: string + required: + - name + - type type: object - service.TLSApplyInput: + push.CreateEventRequest: + properties: + channels: + items: + type: string + type: array + enabled: + type: boolean + event_key: + type: string + targets: + items: + type: string + type: array + task_type: + description: 关联的异步任务类型 + type: string + template: + type: string + type: object + push.Definition: + properties: + description: + description: short description + type: string + fields: + description: form fields + items: + $ref: '#/definitions/push.Field' + type: array + name: + description: display name + type: string + type: + description: channel type (e.g., custom, lark, email) + type: string + type: object + push.EventMetadata: + properties: + default_template: + $ref: '#/definitions/push.NotificationMessage' + description: + type: string + key: + type: string + name: + type: string + type: object + push.Field: + properties: + description: + description: field explanation/help text + type: string + key: + description: unique key for the field (e.g. url, token, other) + type: string + label: + description: human readable label (e.g. "Webhook 地址") + type: string + placeholder: + description: input placeholder + type: string + required: + description: whether this field is required + type: boolean + type: + description: 'input type: "text" | "password" | "textarea"' + type: string + type: object + push.NotificationMessage: + properties: + content: + type: string + ext: + additionalProperties: {} + type: object + level: + type: string + title: + type: string + type: object + push.TestChannelRequest: + properties: + name: + type: string + other: + type: string + target: + type: string + token: + type: string + type: + type: string + url: + type: string + type: object + push.TestPushRequest: + properties: + config: + $ref: '#/definitions/push.Config' + target: + type: string + required: + - config + type: object + push.UpdateChannelRequest: + properties: + description: + type: string + enabled: + type: boolean + other: + type: string + token: + type: string + type: + type: string + url: + type: string + required: + - type + type: object + push.UpdateEventRequest: + properties: + channels: + items: + type: string + type: array + enabled: + type: boolean + targets: + items: + type: string + type: array + template: + type: string + required: + - template + type: object + push.pushHistoriesResponse: + properties: + results: + items: + $ref: '#/definitions/model.PushHistory' + type: array + total: + type: integer + type: object + response.Any: + properties: + data: {} + error_msg: + example: "" + type: string + type: object + status.DatabaseInfoResponse: + properties: + name: + type: string + type: + type: string + version: + type: string + type: object + status.SystemStatusResponse: + properties: + alloc: + type: string + buck_hash_sys: + type: string + frees: + type: integer + gc_sys: + type: string + heap_alloc: + type: string + heap_idle: + type: string + heap_inuse: + type: string + heap_objects: + type: integer + heap_released: + type: string + heap_sys: + type: string + last_gc_time: + type: string + last_pause: + type: string + lookups: + type: integer + mallocs: + type: integer + mcache_inuse: + type: string + mcache_sys: + type: string + mspan_inuse: + type: string + mspan_sys: + type: string + next_gc: + type: string + num_gc: + type: integer + num_goroutine: + type: integer + other_sys: + type: string + pause_total_ns: + type: string + stack_inuse: + type: string + stack_sys: + type: string + sys: + type: string + total_alloc: + type: string + uptime: + type: string + type: object + system_config.CreateSystemConfigRequest: + properties: + description: + maxLength: 255 + type: string + key: + maxLength: 64 + type: string + type: + enum: + - system + - business + type: string + value: + type: string + visibility: + enum: + - 0 + - 1 + type: integer + required: + - key + - type + - value + type: object + system_config.TestSMTPRequest: + properties: + smtp_host: + maxLength: 255 + type: string + smtp_password: + maxLength: 255 + type: string + smtp_port: + type: integer + smtp_username: + maxLength: 255 + type: string + to: + type: string + required: + - smtp_host + - smtp_password + - smtp_port + - smtp_username + - to + type: object + system_config.TestSMTPResponse: + properties: + error: + type: string + log: + type: string + success: + type: boolean + type: object + system_config.UpdateSystemConfigRequest: + properties: + description: + maxLength: 255 + type: string + value: + type: string + visibility: + enum: + - 0 + - 1 + type: integer + required: + - value + type: object + task.CreateScheduleRequest: + properties: + cron: + type: string + is_active: + type: boolean + name: + type: string + payload: + type: string + task_type: + type: string + required: + - cron + - is_active + - name + - task_type + type: object + task.DispatchTaskRequest: + properties: + end_time: + type: string + payload: + type: string + start_time: + type: string + task_type: + type: string + user_id: + type: integer + required: + - task_type + type: object + task.TaskMeta: + properties: + asynq_task: + type: string + description: + type: string + max_retry: + type: integer + name: + type: string + params: + items: + $ref: '#/definitions/task.TaskParam' + type: array + queue: + type: string + retryable: + description: 是否支持手动重试 + type: boolean + supports_time: + type: boolean + type: + type: string + type: object + task.TaskParam: + properties: + description: + description: 描述 + type: string + label: + description: 显示名称 + type: string + name: + description: 参数键名 + type: string + placeholder: + description: 占位符 + type: string + required: + description: 是否必填 + type: boolean + type: + description: 类型:string, text, number, boolean + type: string + type: object + task.UpdateScheduleRequest: + properties: + cron: + type: string + is_active: + type: boolean + name: + type: string + payload: + type: string + task_type: + type: string + required: + - cron + - is_active + - name + - task_type + type: object + template.CreateTemplateRequest: + properties: + content: + type: string + description: + maxLength: 255 + type: string + key: + maxLength: 80 + type: string + name: + maxLength: 100 + type: string + subject: + maxLength: 255 + type: string + type: + maxLength: 20 + type: string + required: + - content + - key + - name + - type + type: object + template.UpdateTemplateRequest: + properties: + content: + type: string + description: + maxLength: 255 + type: string + name: + maxLength: 100 + type: string + subject: + maxLength: 255 + type: string + type: + maxLength: 20 + type: string + required: + - content + - name + - type + type: object + tls.ApplyInput: properties: acme_account_id: type: integer @@ -480,7 +2714,46 @@ definitions: skip_dns: type: boolean type: object - service.TLSCertificateInput: + tls.CertificateContent: + properties: + acme_account_id: + type: integer + apply_message: + type: string + apply_status: + type: string + auto_renew: + type: boolean + cert_pem: + type: string + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + id: + type: integer + key_algorithm: + type: string + key_pem: + type: string + name: + type: string + other_domains: + type: string + primary_domain: + type: string + provider: + type: string + remark: + type: string + skip_dns: + type: boolean + type: object + tls.CertificateInput: properties: cert_pem: type: string @@ -491,171 +2764,3214 @@ definitions: remark: type: string type: object + tls.DNSAccountInput: + properties: + authorization: + type: string + name: + type: string + type: + type: string + type: object + tls.ManagedDomainInput: + properties: + cert_id: + type: integer + domain: + type: string + enabled: + type: boolean + remark: + type: string + type: object + tls.ManagedDomainMatchCandidate: + properties: + certificate_id: + type: integer + certificate_name: + type: string + domain: + type: string + managed_domain_id: + type: integer + match_type: + type: string + type: object + tls.ManagedDomainMatchResult: + properties: + candidate: + $ref: '#/definitions/tls.ManagedDomainMatchCandidate' + candidates: + items: + $ref: '#/definitions/tls.ManagedDomainMatchCandidate' + type: array + domain: + type: string + matched: + type: boolean + type: object + updater.Status: + properties: + asset_name: + type: string + build_time: + type: string + can_upgrade: + type: boolean + current_version: + type: string + latest_version: + type: string + platform: + type: string + prerelease: + type: boolean + published_at: + type: string + release_name: + type: string + release_notes: + type: string + release_url: + type: string + update_available: + type: boolean + upstream_repository: + type: string + type: object + user.changePasswordRequest: + properties: + new_password: + type: string + old_password: + type: string + type: object + user.createTokenRequest: + properties: + is_admin: + type: boolean + name: + type: string + type: object + user.createUserRequest: + properties: + email: + maxLength: 255 + type: string + is_active: + type: boolean + is_admin: + type: boolean + nickname: + maxLength: 64 + type: string + password: + maxLength: 64 + minLength: 8 + type: string + username: + maxLength: 64 + minLength: 3 + type: string + required: + - email + - password + - username + type: object + user.listUsersResponse: + properties: + total: + type: integer + users: + items: + $ref: '#/definitions/user.user' + type: array + type: object + user.loginRequest: + properties: + code: + type: string + password: + type: string + username: + type: string + type: object + user.registerRequest: + properties: + code: + type: string + display_name: + type: string + email: + type: string + nickname: + type: string + password: + type: string + username: + type: string + type: object + user.sendEmailCodeRequest: + properties: + email: + type: string + scene: + type: string + required: + - email + - scene + type: object + user.tokenResponse: + properties: + record: + $ref: '#/definitions/model.AccessToken' + token: + type: string + type: object + user.updateProfileRequest: + properties: + avatar_url: + type: string + bio: + type: string + email: + type: string + gender: + type: string + location: + type: string + nickname: + type: string + phone: + type: string + website: + type: string + type: object + user.updateUserStatusRequest: + properties: + is_active: + type: boolean + type: object + user.user: + properties: + avatar_url: + type: string + bio: + type: string + created_at: + type: string + email: + type: string + gender: + type: string + id: + example: "0" + type: string + is_active: + type: boolean + is_admin: + type: boolean + last_login_at: + type: string + location: + type: string + nickname: + type: string + phone: + type: string + updated_at: + type: string + username: + type: string + website: + type: string + type: object + waf.IDsRequest: + properties: + ids: + items: + type: integer + type: array + type: object + waf.IPGroupAutoTestInput: + properties: + auto_config: + items: + type: integer + type: array + type: object + waf.IPGroupAutoTestResult: + properties: + lookback_minutes: + type: integer + matched_count: + type: integer + matched_ips: + items: + type: string + type: array + rule_count: + type: integer + tested_at: + type: string + type: object + waf.IPGroupExtIPView: + properties: + captured_at: + type: string + ip: + type: string + type: object + waf.IPGroupInput: + properties: + auto_config: + items: + type: integer + type: array + enabled: + type: boolean + ip_list: + items: + type: string + type: array + name: + type: string + remark: + type: string + subscription_format: + type: string + subscription_mapping_rule: + type: string + subscription_url: + type: string + sync_interval_minutes: + type: integer + type: + type: string + type: object + waf.IPGroupSyncResult: + properties: + group: + $ref: '#/definitions/waf.IPGroupView' + ip_count: + type: integer + message: + type: string + next_sync_at: + type: string + status: + type: string + synced_at: + type: string + type: object + waf.IPGroupView: + properties: + auto_config: + items: + type: integer + type: array + created_at: + type: string + enabled: + type: boolean + ext_ips: + items: + $ref: '#/definitions/waf.IPGroupExtIPView' + type: array + id: + type: integer + ip_list: + items: + type: string + type: array + last_sync_message: + type: string + last_sync_status: + type: string + last_synced_at: + type: string + name: + type: string + next_sync_at: + type: string + referenced_by_rule_count: + type: integer + remark: + type: string + subscription_format: + type: string + subscription_mapping_rule: + type: string + subscription_url: + type: string + sync_interval_minutes: + type: integer + type: + type: string + updated_at: + type: string + type: object + waf.PoWConfig: + properties: + algorithm: + type: string + blacklist: + $ref: '#/definitions/waf.PoWListConfig' + challenge_ttl: + type: integer + difficulty: + type: integer + session_ttl: + type: integer + whitelist: + $ref: '#/definitions/waf.PoWListConfig' + type: object + waf.PoWListConfig: + properties: + ip_cidrs: + items: + type: string + type: array + ips: + items: + type: string + type: array + path_regexes: + items: + type: string + type: array + paths: + items: + type: string + type: array + user_agents: + items: + type: string + type: array + type: object + waf.RuleGroupInput: + properties: + block_response_body: + type: string + block_status_code: + type: integer + country_blacklist: + items: + type: string + type: array + country_whitelist: + items: + type: string + type: array + enabled: + type: boolean + ip_blacklist: + items: + type: string + type: array + ip_blacklist_group_ids: + items: + type: integer + type: array + ip_whitelist: + items: + type: string + type: array + ip_whitelist_group_ids: + items: + type: integer + type: array + name: + type: string + pow_config: + items: + type: integer + type: array + pow_enabled: + type: boolean + region_blacklist: + items: + type: string + type: array + region_whitelist: + items: + type: string + type: array + remark: + type: string + type: object + waf.RuleGroupView: + properties: + applied_site_count: + type: integer + applied_site_ids: + items: + type: integer + type: array + block_response_body: + type: string + block_status_code: + type: integer + country_blacklist: + items: + type: string + type: array + country_whitelist: + items: + type: string + type: array + created_at: + type: string + enabled: + type: boolean + id: + type: integer + ip_blacklist: + items: + type: string + type: array + ip_blacklist_group_ids: + items: + type: integer + type: array + ip_whitelist: + items: + type: string + type: array + ip_whitelist_group_ids: + items: + type: integer + type: array + is_global: + type: boolean + name: + type: string + pow_config: + $ref: '#/definitions/waf.PoWConfig' + pow_enabled: + type: boolean + region_blacklist: + items: + type: string + type: array + region_whitelist: + items: + type: string + type: array + remark: + type: string + updated_at: + type: string + type: object + waf.SiteRuleGroupsView: + properties: + applied_ids: + items: + type: integer + type: array + applied_rule_groups: + items: + $ref: '#/definitions/waf.RuleGroupView' + type: array + global_rule_group: + $ref: '#/definitions/waf.RuleGroupView' + route_id: + type: integer + rule_groups: + items: + $ref: '#/definitions/waf.RuleGroupView' + type: array + type: object info: - contact: {} - description: OpenFlare Server 管理端与 Agent API 文档。 - title: OpenFlare Server API - version: "3.0" + contact: + name: OpenFlare + url: https://github.com/Rain-kl/OpenFlare + description: OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。 + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + title: OpenFlare API + version: 1.0.0 paths: - /api/access-logs/: - get: - parameters: - - description: Node ID - in: query - name: node_id - type: string - - description: Remote address - in: query - name: remote_addr - type: string - - description: Host - in: query - name: host - type: string - - description: Path - in: query - name: path - type: string - - description: Page index - in: query - name: p - type: integer - - description: Page size - in: query - name: page_size - type: integer - - description: Sort by - in: query - name: sort_by - type: string - - description: Sort order - in: query - name: sort_order - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: List access logs - tags: - - AccessLogs - /api/access-logs/cleanup: + /api/cap/challenge: post: consumes: - application/json + description: 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 + parameters: + - description: 可选范围限制参数 + in: body + name: request + schema: + $ref: '#/definitions/cap.challengeRequest' produces: - application/json responses: "200": - description: OK + description: 成功返回 PoW 难题 schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Cleanup access logs by retention days + $ref: '#/definitions/cap.ChallengeResponse' + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' + summary: 生成人机验证难题 tags: - - AccessLogs - /api/access-logs/folds: - get: + - cap + /api/cap/redeem: + post: + consumes: + - application/json + description: 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 parameters: - - description: Node ID - in: query - name: node_id - type: string - - description: Remote address - in: query - name: remote_addr - type: string - - description: Host - in: query - name: host - type: string - - description: Path - in: query - name: path - type: string - - description: Page index - in: query - name: p + - description: 难题 Token 与解答 solutions 数组 + in: body + name: request + required: true + schema: + $ref: '#/definitions/cap.redeemRequest' + produces: + - application/json + responses: + "200": + description: 核销成功,返回 X-Cap-Token + schema: + $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' + "400": + description: 参数错误或核销失败 + schema: + $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' + summary: 校验人机验证解答 + tags: + - cap + /api/health: + get: + description: 检查服务是否正常运行,可用于负载均衡存活探测 + produces: + - application/json + responses: + "200": + description: 服务正常 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + summary: 健康检查 + tags: + - health + /api/v1/admin/auth-sources: + get: + description: 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 认证源列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.AuthSource' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取认证源列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 + parameters: + - description: 创建认证源参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.AuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 创建成功,返回认证源信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.AuthSource' + type: object + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建认证源 + tags: + - admin + /api/v1/admin/auth-sources/{id}: + delete: + description: 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true type: integer - - description: Page size + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: ID 无效或删除失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除认证源 + tags: + - admin + put: + consumes: + - application/json + description: 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + - description: 更新认证源参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.AuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 更新成功,返回更新后的认证源信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.AuthSource' + type: object + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新认证源 + tags: + - admin + /api/v1/admin/auth-sources/{id}/toggle: + put: + consumes: + - application/json + description: 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + - description: 启用状态 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.ToggleAuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 切换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 验证失败或认证源不存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 切换认证源启用状态 + tags: + - admin + /api/v1/admin/cache/clear: + post: + description: 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据 + produces: + - application/json + responses: + "200": + description: 清理成功 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清空缓存 + tags: + - admin + /api/v1/admin/cache/config: + post: + consumes: + - application/json + description: 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新 + parameters: + - description: 缓存配置请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/cache.updateCacheConfigRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新缓存配置 + tags: + - admin + /api/v1/admin/cache/status: + get: + description: 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/diskcache.Status' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取缓存状态 + tags: + - admin + /api/v1/admin/db-export: + get: + description: SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限 + produces: + - application/octet-stream + responses: + "200": + description: 数据库文件 + schema: + type: file + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 导出失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 导出数据库 + tags: + - admin + /api/v1/admin/db-info: + get: + description: 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/status.DatabaseInfoResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取数据库信息 + tags: + - admin + /api/v1/admin/db-manage/overview: + get: + description: 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/db_manage.DBOverviewResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取数据库运行概览 + tags: + - admin + /api/v1/admin/db-manage/query: + post: + consumes: + - application/json + description: 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限 + parameters: + - description: SQL 请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/db_manage.ExecuteSQLRequest' + produces: + - application/json + responses: + "200": + description: 执行完毕 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/db_manage.ExecuteSQLResponse' + type: object + "400": + description: SQL 语句错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 执行 SQL 查询 + tags: + - admin + /api/v1/admin/db-manage/tables: + get: + description: 返回当前数据库的所有用户自定义表名称列表,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + type: string + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取数据库所有表名 + tags: + - admin + /api/v1/admin/logs: + get: + description: 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志 + parameters: + - default: 0 + description: 日志游标,0=获取最新 + in: query + name: cursor + type: integer + - default: 200 + description: 每页条数 + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: 日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/logs.logsResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取系统日志 + tags: + - admin + /api/v1/admin/logs/access: + get: + description: 分页并按照用户、接口路径、时间范围等维度检索 ClickHouse 用户访问日志列表(需要管理员权限,ClickHouse 未启用时报错) + parameters: + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 20 + description: 每页条数 in: query name: page_size type: integer - - description: Sort by + - description: 用户名模糊搜索 in: query - name: sort_by + name: username type: string - - description: Sort order + - description: 接口路径模糊搜索 in: query - name: sort_order + name: path type: string - - description: Fold minutes + - description: 起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS) in: query - name: fold_minutes + name: start_time + type: string + - description: 结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS) + in: query + name: end_time + type: string + produces: + - application/json + responses: + "200": + description: 访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/logs.accessLogsResponse' + type: object + "400": + description: ClickHouse 未启用或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取用户访问日志 + tags: + - admin + /api/v1/admin/logs/analytics: + get: + description: 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,ClickHouse 未启用时报错) + produces: + - application/json + responses: + "200": + description: 分析统计数据 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/logs.logsAnalyticsResponse' + type: object + "400": + description: ClickHouse 未启用 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志分析数据 + tags: + - admin + /api/v1/admin/logs/ws: + get: + description: 通过 WebSocket 实时推送系统日志,需要管理员权限 + responses: {} + summary: 系统日志实时推送 + tags: + - admin + /api/v1/admin/push/channels: + get: + description: 返回系统配置的所有消息通道列表,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 消息通道列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.PushChannel' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有消息通道 + tags: + - admin-push + post: + consumes: + - application/json + description: 新建一个消息通道配置,需要管理员权限 + parameters: + - description: 创建参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.CreateChannelRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushChannel' + type: object + security: + - SessionCookie: [] + summary: 创建消息通道 + tags: + - admin-push + /api/v1/admin/push/channels/{id}: + delete: + description: 根据ID删除消息通道,需要管理员权限 + parameters: + - description: 通道ID + format: int64 + in: path + name: id + required: true type: integer produces: - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除消息通道 + tags: + - admin-push + put: + consumes: + - application/json + description: 修改消息通道配置,需要管理员权限 + parameters: + - description: 通道ID + format: int64 + in: path + name: id + required: true + type: integer + - description: 更新参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.UpdateChannelRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushChannel' + type: object + security: + - SessionCookie: [] + summary: 更新消息通道 + tags: + - admin-push + /api/v1/admin/push/channels/definitions: + get: + description: 返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 通道配置定义列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/push.Definition' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有消息通道配置字段定义 + tags: + - admin-push + /api/v1/admin/push/channels/test: + post: + consumes: + - application/json + description: 触发一次临时的或现有的通道连通性推送测试,需要管理员权限 + parameters: + - description: 测试参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.TestChannelRequest' + produces: + - application/json + responses: + "200": + description: 测试触发成功 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试通道连通性 + tags: + - admin-push + /api/v1/admin/push/events: + get: + description: 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 通知事件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.PushEvent' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有通知事件 + tags: + - admin-push + post: + consumes: + - application/json + description: 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限 + parameters: + - description: 创建参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.CreateEventRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushEvent' + type: object + security: + - SessionCookie: [] + summary: 创建通知事件 + tags: + - admin-push + /api/v1/admin/push/events/{id}: + delete: + description: 删除数据库中的特定通知事件配置,需要管理员权限 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 删除通知事件配置 + tags: + - admin-push + put: + consumes: + - application/json + description: 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + - description: 更新参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.UpdateEventRequest' + produces: + - application/json + responses: + "200": + description: 修改成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 更新通知事件 + tags: + - admin-push + /api/v1/admin/push/events/{id}/toggle: + post: + description: 启用或禁用指定的通知事件 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 切换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 快捷切换通知事件启用状态 + tags: + - admin-push + /api/v1/admin/push/events/builtin: + get: + description: 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 内置通知事件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/push.EventMetadata' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有内置通知事件 + tags: + - admin-push + /api/v1/admin/push/histories: + get: + description: 返回分页的通知历史日志数据,需要管理员权限 + parameters: + - description: 当前页码 + in: query + name: page + type: integer + - description: 分页大小 + in: query + name: page_size + type: integer + - description: 过滤事件名称 + in: query + name: event_key + type: string + - description: 过滤发送状态 + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: 推送历史列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/push.pushHistoriesResponse' + type: object + security: + - SessionCookie: [] + summary: 分页获取通知推送历史 + tags: + - admin-push + /api/v1/admin/push/test: + post: + consumes: + - application/json + description: 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息 + parameters: + - description: 测试请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.TestPushRequest' + produces: + - application/json + responses: + "200": + description: 测试成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 测试推送通道发送 + tags: + - admin-push + /api/v1/admin/status: + get: + description: 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/status.SystemStatusResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取系统状态信息 + tags: + - admin + /api/v1/admin/system-configs: + get: + description: 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 + parameters: + - description: 配置类型(system/business) + in: query + name: type + type: string + produces: + - application/json + responses: + "200": + description: 系统配置列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.SystemConfig' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取系统配置列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限 + parameters: + - description: 创建请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.CreateSystemConfigRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误或配置键已存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建系统配置 + tags: + - admin + /api/v1/admin/system-configs/{key}: + get: + description: 根据配置键获取对应的系统配置详情,需要管理员权限 + parameters: + - description: 配置键 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: 系统配置详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.SystemConfig' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 配置不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取单个系统配置 + tags: + - admin + put: + consumes: + - application/json + description: 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限 + parameters: + - description: 配置键 + in: path + name: key + required: true + type: string + - description: 更新请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.UpdateSystemConfigRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 配置不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新系统配置 + tags: + - admin + /api/v1/admin/system-configs/smtp/test: + post: + consumes: + - application/json + description: 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码 + parameters: + - description: 测试请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.TestSMTPRequest' + produces: + - application/json + responses: + "200": + description: 测试执行完毕 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/system_config.TestSMTPResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试 SMTP 邮件发送 + tags: + - admin + /api/v1/admin/tasks/dispatch: + post: + consumes: + - application/json + description: 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限 + parameters: + - description: 任务请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.DispatchTaskRequest' + produces: + - application/json + responses: + "200": + description: 任务已入队 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 任务类型不存在或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 任务入队失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 下发异步任务 + tags: + - admin + /api/v1/admin/tasks/executions: + get: + description: 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限 + parameters: + - description: 状态筛选 (pending/running/succeeded/failed) + in: query + name: status + type: string + - description: 任务类型筛选 + in: query + name: task_type + type: string + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 20 + description: 每页条数 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: 任务执行记录列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: object + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 查询任务执行记录 + tags: + - admin + /api/v1/admin/tasks/executions/{id}: + get: + description: 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限 + parameters: + - description: 任务执行记录 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 任务执行详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TaskExecution' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 查询任务执行详情 + tags: + - admin + /api/v1/admin/tasks/executions/{id}/retry: + post: + description: 重新下发一条失败的任务,创建新的执行记录,需要管理员权限 + parameters: + - description: 任务执行记录 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 新任务的 TaskID + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 任务不支持重试或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 重试失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 重试失败任务 + tags: + - admin + /api/v1/admin/tasks/schedules: + get: + description: 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 定时任务列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.Schedule' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取定时任务列表 + tags: + - admin + post: + consumes: + - application/json + description: 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限 + parameters: + - description: 创建定时任务请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.CreateScheduleRequest' + produces: + - application/json + responses: + "200": + description: 创建成功的定时任务信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Schedule' + type: object + "400": + description: Cron 表达式无效、异步任务类型不存在或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 保存定时任务失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建定时任务 + tags: + - admin + /api/v1/admin/tasks/schedules/{id}: + delete: + description: 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限 + parameters: + - description: 定时任务 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 删除定时任务失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除定时任务 + tags: + - admin + put: + consumes: + - application/json + description: 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限 + parameters: + - description: 定时任务 ID + in: path + name: id + required: true + type: integer + - description: 修改定时任务请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.UpdateScheduleRequest' + produces: + - application/json + responses: + "200": + description: 修改后的定时任务信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Schedule' + type: object + "400": + description: Cron 表达式无效、参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 定时任务不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 修改定时任务失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 修改定时任务 + tags: + - admin + /api/v1/admin/tasks/types: + get: + description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 任务类型列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/task.TaskMeta' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取支持的任务类型 + tags: + - admin + /api/v1/admin/templates: + get: + description: 返回所有通知模板列表,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 模板列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.Template' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取模板列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限 + parameters: + - description: 创建请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/template.CreateTemplateRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误或模板标识符已存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建模板 + tags: + - admin + /api/v1/admin/templates/{key}: + delete: + description: 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限 + parameters: + - description: 模板标识符 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 不可删除系统模板 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 模板不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除模板 + tags: + - admin + get: + description: 根据模板标识符获取对应的模板详情,需要管理员权限 + parameters: + - description: 模板标识符 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: 模板详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Template' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 模板不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取单个模板 + tags: + - admin + put: + consumes: + - application/json + description: 根据模板标识符更新对应的模板内容,需要管理员权限 + parameters: + - description: 模板标识符 + in: path + name: key + required: true + type: string + - description: 更新请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/template.UpdateTemplateRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Template' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 模板不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新模板 + tags: + - admin + /api/v1/admin/update: + get: + description: 从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较 + produces: + - application/json + responses: + "200": + description: 更新状态 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/updater.Status' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 查询失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取应用更新状态 + tags: + - admin + /api/v1/admin/update/apply: + post: + description: 下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程 + produces: + - application/json + responses: + "200": + description: 升级已准备并即将重启 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 当前版本不可升级 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 升级准备失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 下载并应用应用更新 + tags: + - admin + /api/v1/admin/uploads: + get: + description: 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤 + parameters: + - description: 页码(默认 1) + in: query + name: page + type: integer + - description: 每页数量(默认 20,最大 100) + in: query + name: page_size + type: integer + - description: 文件名关键词(模糊匹配) + in: query + name: keyword + type: string + - description: 业务分类过滤 + in: query + name: type + type: string + - description: 扩展名过滤 + in: query + name: extension + type: string + - description: 上传用户 ID + format: int64 + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: 查询成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/handler.listFilesResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取文件列表 + tags: + - admin + /api/v1/admin/uploads/{id}: + delete: + description: 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无权操作 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除文件 + tags: + - admin + /api/v1/admin/uploads/download/{id}: + get: + description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 图片质量 (low, medium, high, origin),默认为 origin + in: query + name: quality + type: string + produces: + - application/octet-stream + responses: + "200": + description: 成功下载文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 下载单文件 + tags: + - admin + /api/v1/admin/uploads/download/batch: + post: + consumes: + - application/json + description: 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 + parameters: + - description: 包含文件 ID 数组 of string 的请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.batchDownloadRequest' + produces: + - application/octet-stream + responses: + "200": + description: 成功下载打包后的 ZIP + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 打包失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 批量打包下载 + tags: + - admin + /api/v1/admin/uploads/stats: + get: + description: 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/handler.fileStatsResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取文件统计数据 + tags: + - admin + /api/v1/admin/uploads/types: + get: + description: 返回数据库中所有已上传文件实际拥有的业务类型列表 + produces: + - application/json + responses: + "200": + description: 业务类型列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + type: string + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取文件业务类型列表 + tags: + - admin + /api/v1/admin/users: + get: + description: 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 + parameters: + - in: query + minimum: 1 + name: page + type: integer + - in: query + maximum: 100 + minimum: 1 + name: page_size + type: integer + - in: query + name: user_id + type: integer + - in: query + name: username + type: string + produces: + - application/json + responses: + "200": + description: 用户列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.listUsersResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取用户列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一个本地密码登录的新用户,需要管理员权限 + parameters: + - description: 创建用户参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.createUserRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.user' + type: object + "400": + description: 参数错误或用户名已存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建用户 + tags: + - admin + /api/v1/admin/users/{id}: + delete: + description: 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户 + parameters: + - description: 用户 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限、尝试删除管理员或当前用户 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除用户 + tags: + - admin + get: + description: 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段 + parameters: + - description: 用户 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 用户详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.user' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取用户详情 + tags: + - admin + /api/v1/admin/users/{id}/status: + put: + consumes: + - application/json + description: 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限 + parameters: + - description: 用户 ID + in: path + name: id + required: true + type: integer + - description: 状态参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateUserStatusRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限或尝试禁用管理员 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新用户状态 + tags: + - admin + /api/v1/config/public: + get: + consumes: + - application/json + description: 返回系统配置表中 visibility 为 1 的配置键值集合 + produces: + - application/json responses: "200": description: OK schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: List folded access logs + $ref: '#/definitions/response.Any' + summary: 获取公共配置 tags: - - AccessLogs - /api/access-logs/folds/ip-summary: + - config + /api/v1/custom/hello: get: + description: A sample business API for customization + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + summary: Sample Hello API + tags: + - custom + /api/v1/d/access-logs: + get: + description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: query name: node_id type: string - - description: Remote address + - description: 客户端 IP in: query name: remote_addr type: string - - description: Host + - description: 请求 Host in: query name: host type: string - - description: Path + - description: 请求路径 in: query name: path type: string - - description: Bucket started at + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/cleanup: + post: + consumes: + - application/json + description: 按保留天数清理过期访问日志记录,需要管理员权限 + parameters: + - description: 清理参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/observability.AccessLogCleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogCleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/folds: + get: + description: 按时间桶聚合访问日志并分页返回,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: 折叠时间窗口(分钟) + in: query + name: fold_minutes + type: integer + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 折叠访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.FoldedAccessLogList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出折叠访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/folds/ip-summary: + get: + description: 在指定时间桶内按 IP 聚合访问统计,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: 时间桶起始时间 in: query name: bucket_started_at - required: true type: string - - description: Fold minutes + - description: 折叠时间窗口(分钟) in: query name: fold_minutes - required: true type: integer - - description: Page index + - description: 页码 in: query name: p type: integer - - description: Page size + - description: 每页条数 in: query name: page_size type: integer - - description: Sort by + - description: 排序字段 in: query name: sort_by type: string - - description: Sort order + - description: 排序方向 in: query name: sort_order type: string @@ -663,43 +5979,64 @@ paths: - application/json responses: "200": - description: OK + description: 折叠 IP 汇总列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.FoldedAccessLogIPList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: List folded access log IP summaries + - SessionCookie: [] + summary: 列出折叠访问日志 IP 汇总 tags: - - AccessLogs - /api/access-logs/ip-summary: + - openflare-observability + /api/v1/d/access-logs/ip-summary: get: + description: 按 IP 聚合访问日志统计并分页返回,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: query name: node_id type: string - - description: Remote address + - description: 客户端 IP in: query name: remote_addr type: string - - description: Host + - description: 请求 Host in: query name: host type: string - - description: Page index + - description: 页码 in: query name: p type: integer - - description: Page size + - description: 每页条数 in: query name: page_size type: integer - - description: Sort by + - description: 排序字段 in: query name: sort_by type: string - - description: Sort order + - description: 排序方向 in: query name: sort_order type: string @@ -707,36 +6044,56 @@ paths: - application/json responses: "200": - description: OK + description: IP 汇总列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPSummaryList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: List access log IP summaries + - SessionCookie: [] + summary: 列出访问日志 IP 汇总 tags: - - AccessLogs - /api/access-logs/ip-summary/trend: + - openflare-observability + /api/v1/d/access-logs/ip-summary/trend: get: + description: 返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: query name: node_id type: string - - description: Remote address + - description: 客户端 IP in: query name: remote_addr - required: true type: string - - description: Host + - description: 请求 Host in: query name: host type: string - - description: Hours + - description: 统计时间范围(小时) in: query name: hours type: integer - - description: Bucket minutes + - description: 时间桶粒度(分钟) in: query name: bucket_minutes type: integer @@ -744,225 +6101,208 @@ paths: - application/json responses: "200": - description: OK + description: IP 访问趋势 schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Get access log IP trend - tags: - - AccessLogs - /api/acme-accounts/default: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Get default ACME account - tags: - - AcmeAccounts - /api/agent/apply-logs: - post: - consumes: - - application/json - parameters: - - description: Apply log payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.ApplyLogPayload' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPTrendView' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - AccessTokenAuth: [] - summary: Report agent apply result + - SessionCookie: [] + summary: 获取访问日志 IP 趋势 tags: - - Agent - /api/agent/config-versions/active: + - openflare-observability + /api/v1/d/acme-accounts/default: get: + description: 返回系统默认 ACME 账号配置,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 默认 ACME 账号 schema: - additionalProperties: true - type: object - security: - - AccessTokenAuth: [] - summary: Get active config for agent - tags: - - Agent - /api/agent/nodes/heartbeat: - post: - consumes: - - application/json - parameters: - - description: Agent heartbeat payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.AgentNodePayload' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.AcmeAccount' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - AccessTokenAuth: [] - summary: Report agent heartbeat + - SessionCookie: [] + summary: 获取默认 ACME 账号 tags: - - Agent - /api/agent/nodes/register: - post: - consumes: - - application/json - parameters: - - description: Agent node payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.AgentNodePayload' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - AccessTokenAuth: [] - summary: Register or discover agent node - tags: - - Agent - /api/agent/waf/ip-groups/sync: - post: - consumes: - - application/json - parameters: - - description: WAF IP group sync payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.AgentWAFIPGroupSyncInput' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - AccessTokenAuth: [] - summary: Sync WAF IP groups for agent - tags: - - Agent - /api/agent/ws: - get: - responses: {} - security: - - AccessTokenAuth: [] - summary: Upgrade agent connection to websocket - tags: - - Agent - /api/apply-logs/: + - openflare-tls + /api/v1/d/apply-logs: get: + description: 分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID 筛选 in: query name: node_id type: string + - description: 页码 + in: query + name: pageNo + type: integer + - description: 页码(别名) + in: query + name: page_no + type: integer + - description: 每页数量 + in: query + name: pageSize + type: integer + - description: 每页数量(别名) + in: query + name: page_size + type: integer produces: - application/json responses: "200": - description: OK + description: 下发日志列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/apply_log.ListResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: List apply logs + - SessionCookie: [] + summary: 获取配置下发日志 tags: - - ApplyLogs - /api/apply-logs/cleanup: + - openflare-apply-log + /api/v1/d/apply-logs/cleanup: post: consumes: - application/json - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Cleanup apply logs - tags: - - ApplyLogs - /api/config-versions/: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: List config versions - tags: - - ConfigVersions - /api/config-versions/{id}: - get: + description: 按保留天数清理历史下发记录,或删除全部记录,需要管理员权限 parameters: - - description: Version ID + - description: 清理参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/apply_log.CleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/apply_log.CleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理配置下发日志 + tags: + - openflare-apply-log + /api/v1/d/config-versions: + get: + description: 返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置版本列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.ConfigVersionSummary' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取配置版本列表 + tags: + - openflare-config-version + /api/v1/d/config-versions/{id}: + get: + description: 返回指定配置版本的完整快照与渲染内容,需要管理员权限 + parameters: + - description: 配置版本 ID in: path name: id required: true @@ -971,24 +6311,36 @@ paths: - application/json responses: "200": - description: OK + description: 配置版本详情 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或版本不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Get config version detail + - SessionCookie: [] + summary: 获取配置版本详情 tags: - - ConfigVersions - /api/config-versions/{id}/activate: + - openflare-config-version + /api/v1/d/config-versions/{id}/activate: post: + description: 将指定历史版本设为当前活跃配置,需要管理员权限 parameters: - - description: Version ID + - description: 配置版本 ID in: path name: id required: true @@ -997,169 +6349,324 @@ paths: - application/json responses: "200": - description: OK + description: 激活成功 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或版本不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Activate an existing config version + - SessionCookie: [] + summary: 激活配置版本 tags: - - ConfigVersions - /api/config-versions/active: + - openflare-config-version + /api/v1/d/config-versions/active: get: + description: 返回当前正在使用的配置版本,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 活跃配置版本 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限、不存在或无活跃版本 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Get active config version + - SessionCookie: [] + summary: 获取当前活跃配置版本 tags: - - ConfigVersions - /api/config-versions/cleanup: + - openflare-config-version + /api/v1/d/config-versions/cleanup: post: + consumes: + - application/json + description: 删除超出保留数量的非活跃配置版本,需要管理员权限 parameters: - - description: Cleanup request + - description: 清理参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/config_version.CleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.CleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理历史配置版本 + tags: + - openflare-config-version + /api/v1/d/config-versions/diff: + get: + description: 对比当前草稿配置与活跃版本之间的差异,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置差异 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.ConfigDiffResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 对比草稿与活跃配置 + tags: + - openflare-config-version + /api/v1/d/config-versions/preview: + get: + description: 渲染并返回当前草稿配置的预览结果,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置预览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.ConfigPreviewResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 预览当前草稿配置 + tags: + - openflare-config-version + /api/v1/d/config-versions/publish: + post: + description: 将当前草稿配置发布为新版本,需要管理员权限 + parameters: + - description: 是否强制发布 + in: query + name: force + type: boolean + produces: + - application/json + responses: + "200": + description: 发布成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 发布配置版本 + tags: + - openflare-config-version + /api/v1/d/dashboard/overview: + get: + description: 聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 仪表盘概览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/dashboard.OverviewPayload' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取仪表盘概览 + tags: + - openflare-dashboard + /api/v1/d/dns-accounts: + get: + description: 返回全部 DNS 提供商账号,需要管理员权限 + produces: + - application/json + responses: + "200": + description: DNS 账号列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.DNSAccount' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 DNS 账号 + tags: + - openflare-tls + post: + consumes: + - application/json + description: 创建新的 DNS 提供商账号,需要管理员权限 + parameters: + - description: DNS 账号参数 in: body name: request required: true schema: - $ref: '#/definitions/controller.CleanupConfigVersionRequest' + $ref: '#/definitions/tls.DNSAccountInput' produces: - application/json responses: "200": - description: OK + description: 创建成功的 DNS 账号 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.DNSAccount' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Cleanup old config versions - tags: - - ConfigVersions - /api/config-versions/diff: - get: - produces: - - application/json - responses: - "200": - description: OK + $ref: '#/definitions/response.Any' + "401": + description: 未登录 schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Diff current draft against active version - tags: - - ConfigVersions - /api/config-versions/preview: - get: - produces: - - application/json - responses: - "200": - description: OK + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Preview config rendering + - SessionCookie: [] + summary: 创建 DNS 账号 tags: - - ConfigVersions - /api/config-versions/publish: + - openflare-tls + /api/v1/d/dns-accounts/{id}/delete: post: - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Publish a new config version - tags: - - ConfigVersions - /api/dashboard/overview: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Get dashboard overview - tags: - - Dashboard - /api/dns-accounts/: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: List DNS accounts - tags: - - DnsAccounts - post: - consumes: - - application/json + description: 按 ID 删除 DNS 提供商账号,需要管理员权限 parameters: - - description: DNS account payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/controller.DnsAccountInput' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Create DNS account - tags: - - DnsAccounts - /api/dns-accounts/{id}/delete: - post: - parameters: - - description: DNS Account ID + - description: DNS 账号 ID in: path name: id required: true @@ -1168,167 +6675,175 @@ paths: - application/json responses: "200": - description: OK + description: 删除成功 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Delete DNS account + - SessionCookie: [] + summary: 删除 DNS 账号 tags: - - DnsAccounts - /api/dns-accounts/{id}/update: + - openflare-tls + /api/v1/d/dns-accounts/{id}/update: post: consumes: - application/json + description: 按 ID 更新 DNS 提供商账号,需要管理员权限 parameters: - - description: DNS Account ID + - description: DNS 账号 ID in: path name: id required: true type: integer - - description: DNS account payload + - description: DNS 账号参数 in: body - name: payload + name: request required: true schema: - $ref: '#/definitions/controller.DnsAccountInput' + $ref: '#/definitions/tls.DNSAccountInput' produces: - application/json responses: "200": - description: OK + description: 更新后的 DNS 账号 schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Update DNS account - tags: - - DnsAccounts - /api/flared/apply-log: - post: - consumes: - - application/json - parameters: - - description: Apply log payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.ApplyLogPayload' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - TunnelTokenAuth: [] - summary: Report OpenFlared apply result - tags: - - Flared - /api/flared/config/active: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - TunnelTokenAuth: [] - summary: Get active tunnel config for OpenFlared - tags: - - Flared - /api/flared/heartbeat: - post: - consumes: - - application/json - parameters: - - description: Flared heartbeat payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.FlaredHeartbeatPayload' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.DNSAccount' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - TunnelTokenAuth: [] - summary: Report OpenFlared heartbeat + - SessionCookie: [] + summary: 更新 DNS 账号 tags: - - Flared - /api/flared/ws: - get: - responses: {} - security: - - TunnelTokenAuth: [] - summary: Upgrade OpenFlared connection to websocket - tags: - - Flared - /api/managed-domains/: + - openflare-tls + /api/v1/d/managed-domains: get: + description: 返回全部托管域名及关联证书,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 托管域名列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.ManagedDomain' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: List managed domains + - SessionCookie: [] + summary: 列出托管域名 tags: - - ManagedDomains + - openflare-tls post: consumes: - application/json + description: 创建新的托管域名记录,需要管理员权限 parameters: - - description: Managed domain payload + - description: 托管域名参数 in: body - name: payload + name: request required: true schema: - $ref: '#/definitions/service.ManagedDomainInput' + $ref: '#/definitions/tls.ManagedDomainInput' produces: - application/json responses: "200": - description: OK + description: 创建成功的托管域名 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ManagedDomain' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Create managed domain + - SessionCookie: [] + summary: 创建托管域名 tags: - - ManagedDomains - /api/managed-domains/{id}/delete: + - openflare-tls + /api/v1/d/managed-domains/{id}/delete: post: + description: 按 ID 删除托管域名,需要管理员权限 parameters: - - description: Managed domain ID + - description: 托管域名 ID in: path name: id required: true @@ -1337,58 +6852,93 @@ paths: - application/json responses: "200": - description: OK + description: 删除成功 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Delete managed domain + - SessionCookie: [] + summary: 删除托管域名 tags: - - ManagedDomains - /api/managed-domains/{id}/update: + - openflare-tls + /api/v1/d/managed-domains/{id}/update: post: consumes: - application/json + description: 按 ID 更新托管域名,需要管理员权限 parameters: - - description: Managed domain ID + - description: 托管域名 ID in: path name: id required: true type: integer - - description: Managed domain payload + - description: 托管域名参数 in: body - name: payload + name: request required: true schema: - $ref: '#/definitions/service.ManagedDomainInput' + $ref: '#/definitions/tls.ManagedDomainInput' produces: - application/json responses: "200": - description: OK + description: 更新后的托管域名 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ManagedDomain' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Update managed domain + - SessionCookie: [] + summary: 更新托管域名 tags: - - ManagedDomains - /api/managed-domains/match: + - openflare-tls + /api/v1/d/managed-domains/match: get: + description: 按域名查询可用的证书匹配候选,需要管理员权限 parameters: - - description: Domain + - description: 域名 in: query name: domain required: true @@ -1397,67 +6947,119 @@ paths: - application/json responses: "200": - description: OK + description: 证书匹配结果 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/tls.ManagedDomainMatchResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Match certificate for domain + - SessionCookie: [] + summary: 匹配托管域名证书 tags: - - ManagedDomains - /api/nodes/: + - openflare-tls + /api/v1/d/nodes: get: + description: 返回所有节点及最新配置下发记录,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 节点列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/node.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: List nodes + - SessionCookie: [] + summary: 获取节点列表 tags: - - Nodes + - openflare-node post: consumes: - application/json + description: 创建新的边缘节点记录,需要管理员权限 parameters: - - description: Node payload + - description: 节点参数 in: body - name: payload + name: body required: true schema: - $ref: '#/definitions/service.NodeInput' + $ref: '#/definitions/node.Input' produces: - application/json responses: "200": - description: OK + description: 创建成功 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Create node + - SessionCookie: [] + summary: 创建节点 tags: - - Nodes - /api/nodes/{id}/agent-release: + - openflare-node + /api/v1/d/nodes/{id}/agent-release: get: + description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: path name: id required: true type: integer - - description: stable or preview + - description: 发布渠道 in: query name: channel type: string @@ -1465,24 +7067,81 @@ paths: - application/json responses: "200": - description: OK + description: Agent 发布信息 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.AgentReleaseInfo' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Check latest agent release for node + - SessionCookie: [] + summary: 获取 Agent 发布信息 tags: - - Nodes - /api/nodes/{id}/agent-update: + - openflare-node + /api/v1/d/nodes/{id}/agent-update: post: + consumes: + - application/json + description: 向指定节点下发 Agent 自更新指令,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 更新参数(可选) + in: body + name: body + schema: + $ref: '#/definitions/node.AgentUpdateInput' + produces: + - application/json + responses: + "200": + description: 更新请求已下发 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 请求 Agent 更新 + tags: + - openflare-node + /api/v1/d/nodes/{id}/delete: + post: + description: 删除指定节点记录,需要管理员权限 + parameters: + - description: 节点 ID in: path name: id required: true @@ -1491,24 +7150,36 @@ paths: - application/json responses: "200": - description: OK + description: 删除成功 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Request agent self-update on node + - SessionCookie: [] + summary: 删除节点 tags: - - Nodes - /api/nodes/{id}/delete: + - openflare-node + /api/v1/d/nodes/{id}/force-sync: post: + description: 向指定节点下发强制同步当前活跃配置的指令,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: path name: id required: true @@ -1517,59 +7188,45 @@ paths: - application/json responses: "200": - description: OK + description: 同步请求已下发 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Delete node + - SessionCookie: [] + summary: 请求强制同步配置 tags: - - Nodes - /api/nodes/{id}/force-sync: - post: - parameters: - - description: Node ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Request force sync config on node - tags: - - Nodes - /api/nodes/{id}/observability: + - openflare-node + /api/v1/d/nodes/{id}/observability: get: + description: 返回指定节点的指标、健康事件与流量分析数据,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: path name: id required: true type: integer - - description: Lookback window in hours + - description: 统计时间范围(小时) in: query name: hours type: integer - - description: Max records per section + - description: 返回记录数量上限 in: query name: limit type: integer @@ -1577,24 +7234,36 @@ paths: - application/json responses: "200": - description: OK + description: 可观测性数据 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.ObservabilityView' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Get node observability details + - SessionCookie: [] + summary: 获取节点可观测性数据 tags: - - Nodes - /api/nodes/{id}/observability/cleanup: + - openflare-node + /api/v1/d/nodes/{id}/observability/cleanup: post: + description: 清理指定节点的历史健康事件记录,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: path name: id required: true @@ -1603,24 +7272,36 @@ paths: - application/json responses: "200": - description: OK + description: 清理结果 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.HealthEventCleanupResult' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Cleanup node health events + - SessionCookie: [] + summary: 清理节点健康事件 tags: - - Nodes - /api/nodes/{id}/openresty-restart: + - openflare-node + /api/v1/d/nodes/{id}/openresty-restart: post: + description: 向指定节点下发 OpenResty 重启指令,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: path name: id required: true @@ -1629,421 +7310,736 @@ paths: - application/json responses: "200": - description: OK + description: 重启请求已下发 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Request openresty restart on node + - SessionCookie: [] + summary: 请求重启 OpenResty tags: - - Nodes - /api/nodes/{id}/update: + - openflare-node + /api/v1/d/nodes/{id}/update: post: consumes: - application/json + description: 更新指定节点的配置信息,需要管理员权限 parameters: - - description: Node ID + - description: 节点 ID in: path name: id required: true type: integer - - description: Node payload + - description: 节点参数 in: body - name: payload + name: body required: true schema: - $ref: '#/definitions/service.NodeInput' + $ref: '#/definitions/node.Input' produces: - application/json responses: "200": - description: OK + description: 更新成功 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Update node + - SessionCookie: [] + summary: 更新节点 tags: - - Nodes - /api/nodes/bootstrap-token: + - openflare-node + /api/v1/d/nodes/bootstrap-token: get: + description: 返回全局节点发现引导令牌,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 引导令牌 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.BootstrapView' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Get global discovery token + - SessionCookie: [] + summary: 获取引导令牌 tags: - - Nodes - /api/nodes/bootstrap-token/rotate: + - openflare-node + /api/v1/d/nodes/bootstrap-token/rotate: post: + description: 重新生成全局节点发现引导令牌,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 新引导令牌 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.BootstrapView' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Rotate global discovery token + - SessionCookie: [] + summary: 轮换引导令牌 tags: - - Nodes - /api/option/: + - openflare-node + /api/v1/d/notice: get: + description: 返回 OpenFlare 控制台公告文本,无需登录 produces: - application/json responses: "200": - description: OK + description: 系统公告 schema: - additionalProperties: true - type: object - summary: List editable options - tags: - - Options - /api/option/database/cleanup: - post: - consumes: - - application/json - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Cleanup observability tables - tags: - - Options - /api/option/geoip/lookup: - post: - consumes: - - application/json - parameters: - - description: GeoIP lookup payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/controller.geoIPLookupRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object - summary: Test GeoIP lookup + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 获取系统公告 tags: - - Options - /api/option/update: - post: - consumes: - - application/json - parameters: - - description: Option payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/model.Option' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - summary: Update option - tags: - - Options - /api/option/update-batch: - post: - consumes: - - application/json - parameters: - - description: Batch option payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/controller.optionBatchPayload' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - summary: Batch update options - tags: - - Options - /api/proxy-routes/: + - openflare-option + /api/v1/d/option: get: + description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 配置项列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.OpenFlareOption' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: List proxy routes + - SessionCookie: [] + summary: 列出 OpenFlare 配置项 tags: - - ProxyRoutes + - openflare-option + /api/v1/d/option/database/cleanup: post: consumes: - application/json + description: 按目标与保留天数清理可观测性相关数据表,需要管理员权限 parameters: - - description: Proxy route payload + - description: 清理参数 in: body - name: payload - required: true + name: request schema: - $ref: '#/definitions/service.ProxyRouteInput' + $ref: '#/definitions/option.databaseCleanupInput' produces: - application/json responses: "200": - description: OK + description: 清理结果 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/option.databaseCleanupResult' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Create proxy route + - SessionCookie: [] + summary: 清理可观测性数据库 tags: - - ProxyRoutes - /api/proxy-routes/{id}: + - openflare-option + /api/v1/d/option/geoip/lookup: + post: + consumes: + - application/json + description: 按提供商与 IP 查询地理位置信息,需要管理员权限 + parameters: + - description: 查询参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/option.geoIPLookupRequest' + produces: + - application/json + responses: + "200": + description: GeoIP 查询结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/option.geoIPLookupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: GeoIP 地址查询 + tags: + - openflare-option + /api/v1/d/option/update: + post: + consumes: + - application/json + description: 更新单个 OpenFlare 配置项,需要管理员权限 + parameters: + - description: 配置项 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.OpenFlareOption' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/option/update-batch: + post: + consumes: + - application/json + description: 批量更新多个 OpenFlare 配置项,需要管理员权限 + parameters: + - description: 批量配置项 + in: body + name: request + required: true + schema: + $ref: '#/definitions/option.optionBatchPayload' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 批量更新 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/origins: get: - parameters: - - description: Route ID - in: path - name: id - required: true - type: integer + description: 返回所有源站及关联代理规则数量,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 源站列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/origin.View' + type: array + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Get proxy route detail + - SessionCookie: [] + summary: 获取源站列表 tags: - - ProxyRoutes - /api/proxy-routes/{id}/delete: - post: - parameters: - - description: Route ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Delete proxy route - tags: - - ProxyRoutes - /api/proxy-routes/{id}/update: + - openflare-origin post: consumes: - application/json + description: 创建新的上游源站记录,需要管理员权限 parameters: - - description: Route ID - in: path - name: id - required: true - type: integer - - description: Proxy route payload + - description: 源站参数 in: body - name: payload + name: body required: true schema: - $ref: '#/definitions/service.ProxyRouteInput' + $ref: '#/definitions/origin.Input' produces: - application/json responses: "200": - description: OK + description: 创建成功 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.View' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Update proxy route + - SessionCookie: [] + summary: 创建源站 tags: - - ProxyRoutes - /api/relay/heartbeat: - post: - consumes: - - application/json - parameters: - - description: Relay heartbeat payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.RelayHeartbeatPayload' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - AccessTokenAuth: [] - summary: Report relay heartbeat - tags: - - Relay - /api/relay/ws: + - openflare-origin + /api/v1/d/origins/{id}: get: - responses: {} + description: 返回指定源站信息及关联代理规则摘要,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 源站详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.DetailView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' security: - - AccessTokenAuth: [] - summary: Upgrade relay connection to websocket + - SessionCookie: [] + summary: 获取源站详情 tags: - - Relay - /api/status: + - openflare-origin + /api/v1/d/origins/{id}/delete: + post: + description: 删除指定源站记录,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除源站 + tags: + - openflare-origin + /api/v1/d/origins/{id}/update: + post: + consumes: + - application/json + description: 更新指定源站的配置信息,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + - description: 源站参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/origin.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新源站 + tags: + - openflare-origin + /api/v1/d/pages: get: + description: 返回全部 OpenFlare Pages 项目,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: Pages 项目列表 schema: - additionalProperties: true - type: object - summary: Get server status + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 项目 tags: - - Public - /api/tls-certificates/: + - openflare-pages + post: + consumes: + - application/json + description: 创建新的 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.Input' + produces: + - application/json + responses: + "200": + description: 创建成功的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/{id}: get: - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: List TLS certificates - tags: - - TLSCertificates - post: - consumes: - - application/json + description: 按 ID 返回 Pages 项目详情,需要管理员权限 parameters: - - description: TLS certificate payload - in: body - name: payload + - description: 项目 ID + in: path + name: id required: true - schema: - $ref: '#/definitions/service.TLSCertificateInput' + type: integer produces: - application/json responses: "200": - description: OK + description: Pages 项目详情 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Create TLS certificate from PEM + - SessionCookie: [] + summary: 获取 Pages 项目详情 tags: - - TLSCertificates - /api/tls-certificates/{id}: + - openflare-pages + /api/v1/d/pages/{id}/delete: + post: + description: 按 ID 删除 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments: get: + description: 返回指定项目的全部部署记录,需要管理员权限 parameters: - - description: Certificate ID + - description: 项目 ID in: path name: id required: true @@ -2052,249 +8048,1003 @@ paths: - application/json responses: "200": - description: OK + description: 部署列表 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.DeploymentView' + type: array + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Get TLS certificate detail + - SessionCookie: [] + summary: 列出 Pages 部署 tags: - - TLSCertificates - /api/tls-certificates/{id}/content: - get: - parameters: - - description: Certificate ID - in: path - name: id - required: true - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Get TLS certificate PEM content - tags: - - TLSCertificates - /api/tls-certificates/{id}/convert-acme: + - openflare-pages + /api/v1/d/pages/{id}/deployments/{deployment_id}/activate: post: - consumes: - - application/json + description: 将指定部署设为项目当前生效版本,需要管理员权限 parameters: - - description: Certificate ID + - description: 项目 ID in: path name: id required: true type: integer - - description: TLS apply payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.TLSApplyInput' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Convert uploaded TLS certificate to ACME managed certificate - tags: - - TLSCertificates - /api/tls-certificates/{id}/delete: - post: - parameters: - - description: Certificate ID + - description: 部署 ID in: path - name: id + name: deployment_id required: true type: integer produces: - application/json responses: "200": - description: OK + description: 激活后的项目 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Delete TLS certificate + - SessionCookie: [] + summary: 激活 Pages 部署 tags: - - TLSCertificates - /api/tls-certificates/{id}/renew: + - openflare-pages + /api/v1/d/pages/{id}/deployments/{deployment_id}/delete: post: + description: 删除指定项目的部署记录,需要管理员权限 parameters: - - description: Certificate ID + - description: 项目 ID in: path name: id required: true type: integer + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer produces: - application/json responses: "200": - description: OK + description: 删除成功 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Renew TLS certificate + - SessionCookie: [] + summary: 删除 Pages 部署 tags: - - TLSCertificates - /api/tls-certificates/{id}/update: - post: - consumes: - - application/json - parameters: - - description: Certificate ID - in: path - name: id - required: true - type: integer - - description: TLS certificate payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.TLSCertificateInput' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Update TLS certificate from PEM - tags: - - TLSCertificates - /api/tls-certificates/{id}/update-acme: - post: - consumes: - - application/json - parameters: - - description: Certificate ID - in: path - name: id - required: true - type: integer - - description: TLS apply payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.TLSApplyInput' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Update ACME TLS certificate - tags: - - TLSCertificates - /api/tls-certificates/apply: - post: - consumes: - - application/json - parameters: - - description: TLS apply payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/service.TLSApplyInput' - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "400": - description: Bad Request - schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Apply TLS certificate via ACME - tags: - - TLSCertificates - /api/tls-certificates/import-file: + - openflare-pages + /api/v1/d/pages/{id}/deployments/upload: post: consumes: - multipart/form-data + description: 为指定项目上传 ZIP 部署包,需要管理员权限 parameters: - - description: Certificate name + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署包 ZIP 文件 + in: formData + name: package + required: true + type: file + produces: + - application/json + responses: + "200": + description: 部署记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.DeploymentView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 上传 Pages 部署包 + tags: + - openflare-pages + /api/v1/d/pages/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 项目参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.Input' + produces: + - application/json + responses: + "200": + description: 更新后的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/deployments/{deployment_id}/files: + get: + description: 返回指定部署包含的文件清单,需要管理员权限 + parameters: + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 部署文件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.DeploymentFileView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 部署文件 + tags: + - openflare-pages + /api/v1/d/proxy-routes: + get: + description: 返回所有代理规则配置,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 代理规则列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/proxy_route.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取代理规则列表 + tags: + - openflare-proxy-route + post: + consumes: + - application/json + description: 创建新的反向代理规则,需要管理员权限 + parameters: + - description: 代理规则参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/proxy_route.Input' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建代理规则 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}: + get: + description: 返回指定代理规则的完整配置,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 代理规则详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取代理规则详情 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}/delete: + post: + description: 删除指定代理规则,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除代理规则 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}/update: + post: + consumes: + - application/json + description: 更新指定代理规则的配置,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + - description: 代理规则参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/proxy_route.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新代理规则 + tags: + - openflare-proxy-route + /api/v1/d/status: + get: + description: 返回版本、认证源与系统公开配置,无需登录 + produces: + - application/json + responses: + "200": + description: 公开状态 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/option.statusView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 获取 OpenFlare 公开状态 + tags: + - openflare-option + /api/v1/d/tls-certificates: + get: + description: 返回全部 TLS 证书(不含 PEM),需要管理员权限 + produces: + - application/json + responses: + "200": + description: 证书列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.TLSCertificate' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 TLS 证书 + tags: + - openflare-tls + post: + consumes: + - application/json + description: 从 PEM 文本创建 TLS 证书,需要管理员权限 + parameters: + - description: 证书参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.CertificateInput' + produces: + - application/json + responses: + "200": + description: 创建成功的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}: + get: + description: 按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 证书详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 TLS 证书详情 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/content: + get: + description: 按 ID 返回证书与私钥 PEM 内容,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 证书 PEM 内容 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/tls.CertificateContent' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 TLS 证书 PEM 内容 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/convert-acme: + post: + consumes: + - application/json + description: 将已上传证书转换为 ACME 自动续期模式,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 转换后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 将证书转为 ACME 管理 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/delete: + post: + description: 按 ID 删除 TLS 证书,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/renew: + post: + description: 手动触发 ACME 证书续期,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 续期后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 续期 ACME 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 TLS 证书 PEM 信息,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: 证书参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.CertificateInput' + produces: + - application/json + responses: + "200": + description: 更新后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/update-acme: + post: + consumes: + - application/json + description: 按 ID 更新 ACME 证书申请配置,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 更新后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 ACME 证书配置 + tags: + - openflare-tls + /api/v1/d/tls-certificates/apply: + post: + consumes: + - application/json + description: 通过 ACME 申请新的 TLS 证书,需要管理员权限 + parameters: + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 申请中的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 申请 ACME 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/import-file: + post: + consumes: + - multipart/form-data + description: 上传证书与私钥文件创建 TLS 证书,需要管理员权限 + parameters: + - description: 证书名称 in: formData name: name - required: true type: string - - description: Remark + - description: 备注 in: formData name: remark type: string - - description: Certificate file + - description: 证书文件 in: formData name: cert_file required: true type: file - - description: Private key file + - description: 私钥文件 in: formData name: key_file required: true @@ -2303,96 +9053,1576 @@ paths: - application/json responses: "200": - description: OK + description: 导入成功的证书 schema: - additionalProperties: true - type: object + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object "400": - description: Bad Request + description: 参数错误 schema: - additionalProperties: true - type: object - security: - - OpenFlareTokenAuth: [] - summary: Import TLS certificate from files - tags: - - TLSCertificates - /api/update/latest-release: - get: - produces: - - application/json - responses: - "200": - description: OK + $ref: '#/definitions/response.Any' + "401": + description: 未登录 schema: - additionalProperties: true - type: object + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' security: - - OpenFlareTokenAuth: [] - summary: Get latest GitHub release + - SessionCookie: [] + summary: 从文件导入 TLS 证书 tags: - - Update - /api/update/logs/ws: - get: - responses: {} - summary: Stream server upgrade logs over websocket - tags: - - Update - /api/update/manual-upgrade: + - openflare-tls + /api/v1/d/uptimekuma/sync: post: consumes: - application/json + description: 将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 同步成功 schema: - additionalProperties: true - type: object - summary: Confirm upgrade with previously uploaded server binary + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 Uptime Kuma tags: - - Update - /api/update/manual-upload: + - openflare-option + /api/v1/d/waf/ip-groups: + get: + description: 返回全部 WAF IP 组,需要管理员权限 + produces: + - application/json + responses: + "200": + description: IP 组列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/waf.IPGroupView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 WAF IP 组 + tags: + - openflare-waf + post: + consumes: + - application/json + description: 创建新的 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupInput' + produces: + - application/json + responses: + "200": + description: 创建成功的 IP 组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}: + get: + description: 按 ID 返回 WAF IP 组详情,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: IP 组详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 WAF IP 组详情 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/delete: + post: + description: 按 ID 删除 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/sync: + post: + description: 手动触发 WAF IP 组外部 IP 同步,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 同步结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupSyncResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + - description: IP 组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupInput' + produces: + - application/json + responses: + "200": + description: 更新后的 IP 组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/test: + post: + consumes: + - application/json + description: 根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限 + parameters: + - description: 自动配置参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupAutoTestInput' + produces: + - application/json + responses: + "200": + description: 测试结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupAutoTestResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试 WAF IP 组自动配置 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups: + get: + description: 返回全部 WAF 规则组,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 规则组列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/waf.RuleGroupView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 WAF 规则组 + tags: + - openflare-waf + post: + consumes: + - application/json + description: 创建新的 WAF 规则组,需要管理员权限 + parameters: + - description: 规则组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.RuleGroupInput' + produces: + - application/json + responses: + "200": + description: 创建成功的规则组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 WAF 规则组 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}: + get: + description: 按 ID 返回 WAF 规则组详情,需要管理员权限 + parameters: + - description: 规则组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 规则组详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 WAF 规则组详情 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/delete: + post: + description: 按 ID 删除 WAF 规则组,需要管理员权限 + parameters: + - description: 规则组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 WAF 规则组 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/sites: + post: + consumes: + - application/json + description: 替换 WAF 规则组关联的代理站点列表,需要管理员权限 + parameters: + - description: 规则组 ID + in: path + name: id + required: true + type: integer + - description: 站点 ID 列表 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IDsRequest' + produces: + - application/json + responses: + "200": + description: 更新后的规则组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 替换规则组站点绑定 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 WAF 规则组,需要管理员权限 + parameters: + - description: 规则组 ID + in: path + name: id + required: true + type: integer + - description: 规则组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.RuleGroupInput' + produces: + - application/json + responses: + "200": + description: 更新后的规则组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 WAF 规则组 + tags: + - openflare-waf + /api/v1/d/waf/sites/{route_id}/rule-groups: + get: + description: 返回代理站点关联的 WAF 规则组绑定,需要管理员权限 + parameters: + - description: 代理路由 ID + in: path + name: route_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 站点规则组绑定 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.SiteRuleGroupsView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取站点 WAF 规则组 + tags: + - openflare-waf + post: + consumes: + - application/json + description: 替换代理站点关联的 WAF 规则组列表,需要管理员权限 + parameters: + - description: 代理路由 ID + in: path + name: route_id + required: true + type: integer + - description: 规则组 ID 列表 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IDsRequest' + produces: + - application/json + responses: + "200": + description: 更新后的站点规则组绑定 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.SiteRuleGroupsView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 替换站点 WAF 规则组 + tags: + - openflare-waf + /api/v1/oauth/{source}/authorize: + get: + description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 + parameters: + - description: 认证源名称 + in: path + name: source + required: true + type: string + - description: 授权目的:login(登录)或 bind(绑定账号),默认 login + in: query + name: purpose + type: string + produces: + - application/json + responses: + "200": + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未启用 + schema: + $ref: '#/definitions/response.Any' + "500": + description: Redis 异常或构造 URL 失败 + schema: + $ref: '#/definitions/response.Any' + summary: 发起指定认证源授权 + tags: + - oauth + /api/v1/oauth/callback: + post: + consumes: + - application/json + description: 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。 + parameters: + - description: 回调请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/oauth.CallbackRequest' + produces: + - application/json + responses: + "200": + description: 登录或绑定成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.OAuthCallbackResult' + type: object + "400": + description: state 无效、参数错误或认证源错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 绑定场景未登录 + schema: + $ref: '#/definitions/response.Any' + "500": + description: OAuth 认证失败或内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: OAuth 回调处理 + tags: + - oauth + /api/v1/oauth/external-accounts: + get: + description: 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录 + produces: + - application/json + responses: + "200": + description: 外部帐号列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.ExternalAccountView' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取外部帐号列表 + tags: + - oauth + /api/v1/oauth/external-accounts/{id}/delete: + post: + description: 解除当前登录用户与指定外部帐号的绑定关系,需要登录 + parameters: + - description: 外部帐号绑定记录 ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 解除绑定成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: ID 无效或解除失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 解除外部帐号绑定 + tags: + - oauth + /api/v1/oauth/login: + get: + description: 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。 + parameters: + - description: 认证源名称,为空使用第一个启用的认证源 + in: query + name: source + type: string + produces: + - application/json + responses: + "200": + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未配置 + schema: + $ref: '#/definitions/response.Any' + "500": + description: Redis 异常 or 构造 URL 失败 + schema: + $ref: '#/definitions/response.Any' + summary: 获取登录授权地址 + tags: + - oauth + /api/v1/oauth/logout: + get: + description: 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。 + produces: + - application/json + responses: + "200": + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 退出登录 + tags: + - oauth + /api/v1/oauth/sources: + get: + description: 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用 + produces: + - application/json + responses: + "200": + description: 登录源列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/oauth.AuthSourceView' + type: array + type: object + summary: 获取可用登录源 + tags: + - oauth + /api/v1/oauth/user-info: + get: + description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth + /api/v1/upload: post: consumes: - multipart/form-data + description: 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重 + parameters: + - description: 要上传的文件 + in: formData + name: file + required: true + type: file + - description: '业务分类 (例如: avatar, attachment, doc,默认为 generic)' + in: formData + name: type + type: string + - description: 额外的 JSON 格式元数据 + in: formData + name: metadata + type: string produces: - application/json responses: "200": - description: OK + description: 上传成功 schema: - additionalProperties: true - type: object - summary: Upload server binary and inspect version before upgrade + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Upload' + type: object + "400": + description: 请求参数错误或文件受限 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 上传文件 tags: - - Update - /api/update/upgrade: + - upload + /api/v1/upload/{id}: + delete: + description: 将当前用户本人的文件状态置为 deleted(软删除) + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无权操作 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除我的文件 + tags: + - upload + put: + consumes: + - application/json + description: 更新当前用户本人的文件名或访问权限模式 (AccessMode) + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 更新字段 + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.updateMyFileRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Upload' + type: object + "403": + description: 无权操作 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新我的文件信息 + tags: + - upload + /api/v1/upload/my: + get: + description: 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤 + parameters: + - description: 页码(默认 1) + in: query + name: page + type: integer + - description: 每页数量(默认 20,最大 100) + in: query + name: page_size + type: integer + - description: 文件名关键词(模糊匹配) + in: query + name: keyword + type: string + - description: 业务分类过滤 + in: query + name: type + type: string + - description: 扩展名过滤 + in: query + name: extension + type: string + produces: + - application/json + responses: + "200": + description: 查询成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/handler.listMyFilesResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取我的文件列表 + tags: + - upload + /api/v1/user-info: + get: + description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth + /api/v1/user/access-tokens: + get: + description: 返回当前登录用户的所有 active access tokens(脱敏后) + produces: + - application/json + responses: + "200": + description: 令牌列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.AccessToken' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前用户的 AccessToken 列表 + tags: + - user post: + consumes: + - application/json + description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。 + parameters: + - description: 令牌名称 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.createTokenRequest' produces: - application/json responses: "200": - description: OK + description: 新建令牌成功 schema: - additionalProperties: true - type: object - summary: Upgrade server binary from latest GitHub release + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.tokenResponse' + type: object + "400": + description: 参数错误或超限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建一个新的 AccessToken tags: - - Update -schemes: -- http -- https + - user + /api/v1/user/access-tokens/{id}: + delete: + description: 撤销并删除一个属于当前用户的 API 访问令牌 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除一个 AccessToken + tags: + - user + /api/v1/user/access-tokens/{id}/rotate: + post: + description: 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 令牌轮换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.tokenResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 轮换一个 AccessToken + tags: + - user + /api/v1/user/change-password: + post: + consumes: + - application/json + description: 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。 + parameters: + - description: 修改密码请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.changePasswordRequest' + produces: + - application/json + responses: + "200": + description: 修改密码成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 原密码错误或新密码不符合要求 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 请先登录 + schema: + $ref: '#/definitions/response.Any' + summary: 修改用户密码 + tags: + - user + /api/v1/user/login: + post: + consumes: + - application/json + description: 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 + parameters: + - description: 登录请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.loginRequest' + produces: + - application/json + responses: + "200": + description: 登录成功,返回用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 用户名或密码错误、帐号已禁用等 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 用户密码登录 + tags: + - user + /api/v1/user/logout: + get: + description: 清除用户登录 Session,完成退出 + produces: + - application/json + responses: + "200": + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 用户退出登录 + tags: + - user + /api/v1/user/profile: + put: + consumes: + - application/json + description: 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。 + parameters: + - description: 更新请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateProfileRequest' + produces: + - application/json + responses: + "200": + description: 修改成功,返回更新后的用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 邮箱已被占用或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + summary: 修改当前登录用户的个人资料 + tags: + - user + /api/v1/user/register: + post: + consumes: + - application/json + description: 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。 + parameters: + - description: 注册请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.registerRequest' + produces: + - application/json + responses: + "200": + description: 注册并登录成功,返回用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 参数错误、用户名已存在或注册已关闭 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 用户注册 + tags: + - user + /api/v1/user/self: + get: + description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth + /api/v1/user/send-email-code: + post: + consumes: + - application/json + description: 向指定邮箱发送验证码(用于注册场景) + parameters: + - description: 发送验证码请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.sendEmailCodeRequest' + produces: + - application/json + responses: + "200": + description: 发送成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + summary: 发送邮箱验证码 + tags: + - user + /f/{id}: + get: + description: 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 图片质量 (low, medium, high, origin),默认为 origin + in: query + name: quality + type: string + produces: + - application/octet-stream + responses: + "200": + description: 成功获取文件内容 + schema: + type: file + "400": + description: 文件 ID 格式错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件未找到 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 获取已上传文件 + tags: + - upload + /robots.txt: + get: + description: 根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容 + produces: + - text/plain + responses: + "200": + description: robots.txt 内容 + schema: + type: string + summary: 获取 robots.txt + tags: + - config securityDefinitions: - AccessTokenAuth: - description: Agent API 使用节点专属 Agent Token 或全局 Discovery Token - in: header - name: X-Agent-Token - type: apiKey - OpenFlareTokenAuth: - description: 管理端 API 使用登录后返回的用户 Token - in: header - name: OPENFLARE_TOKEN + SessionCookie: + in: cookie + name: session type: apiKey swagger: "2.0" diff --git a/Wavelet/frontend/.env.example b/openflare-server/frontend/.env.example similarity index 100% rename from Wavelet/frontend/.env.example rename to openflare-server/frontend/.env.example diff --git a/Wavelet/frontend/README.md b/openflare-server/frontend/README.md similarity index 100% rename from Wavelet/frontend/README.md rename to openflare-server/frontend/README.md diff --git a/Wavelet/frontend/README_zh.md b/openflare-server/frontend/README_zh.md similarity index 100% rename from Wavelet/frontend/README_zh.md rename to openflare-server/frontend/README_zh.md diff --git a/Wavelet/frontend/app/(auth)/login/page.tsx b/openflare-server/frontend/app/(auth)/login/page.tsx similarity index 100% rename from Wavelet/frontend/app/(auth)/login/page.tsx rename to openflare-server/frontend/app/(auth)/login/page.tsx diff --git a/Wavelet/frontend/app/(auth)/register/page.tsx b/openflare-server/frontend/app/(auth)/register/page.tsx similarity index 100% rename from Wavelet/frontend/app/(auth)/register/page.tsx rename to openflare-server/frontend/app/(auth)/register/page.tsx diff --git a/Wavelet/frontend/app/(docs)/docs/api/page.tsx b/openflare-server/frontend/app/(docs)/docs/api/page.tsx similarity index 100% rename from Wavelet/frontend/app/(docs)/docs/api/page.tsx rename to openflare-server/frontend/app/(docs)/docs/api/page.tsx diff --git a/Wavelet/frontend/app/(docs)/docs/how-to-use/page.tsx b/openflare-server/frontend/app/(docs)/docs/how-to-use/page.tsx similarity index 100% rename from Wavelet/frontend/app/(docs)/docs/how-to-use/page.tsx rename to openflare-server/frontend/app/(docs)/docs/how-to-use/page.tsx diff --git a/Wavelet/frontend/app/(docs)/docs/privacy-policy/page.tsx b/openflare-server/frontend/app/(docs)/docs/privacy-policy/page.tsx similarity index 100% rename from Wavelet/frontend/app/(docs)/docs/privacy-policy/page.tsx rename to openflare-server/frontend/app/(docs)/docs/privacy-policy/page.tsx diff --git a/Wavelet/frontend/app/(docs)/docs/terms-of-service/page.tsx b/openflare-server/frontend/app/(docs)/docs/terms-of-service/page.tsx similarity index 100% rename from Wavelet/frontend/app/(docs)/docs/terms-of-service/page.tsx rename to openflare-server/frontend/app/(docs)/docs/terms-of-service/page.tsx diff --git a/Wavelet/frontend/app/(main)/access-logs/components/access-log-filters.tsx b/openflare-server/frontend/app/(main)/access-logs/components/access-log-filters.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/access-logs/components/access-log-filters.tsx rename to openflare-server/frontend/app/(main)/access-logs/components/access-log-filters.tsx diff --git a/Wavelet/frontend/app/(main)/access-logs/components/access-log-utils.ts b/openflare-server/frontend/app/(main)/access-logs/components/access-log-utils.ts similarity index 100% rename from Wavelet/frontend/app/(main)/access-logs/components/access-log-utils.ts rename to openflare-server/frontend/app/(main)/access-logs/components/access-log-utils.ts diff --git a/Wavelet/frontend/app/(main)/access-logs/components/cleanup-dialog.tsx b/openflare-server/frontend/app/(main)/access-logs/components/cleanup-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/access-logs/components/cleanup-dialog.tsx rename to openflare-server/frontend/app/(main)/access-logs/components/cleanup-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/access-logs/page.tsx b/openflare-server/frontend/app/(main)/access-logs/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/access-logs/page.tsx rename to openflare-server/frontend/app/(main)/access-logs/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/database/components/cache-manager.tsx b/openflare-server/frontend/app/(main)/admin/database/components/cache-manager.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/database/components/cache-manager.tsx rename to openflare-server/frontend/app/(main)/admin/database/components/cache-manager.tsx diff --git a/Wavelet/frontend/app/(main)/admin/database/components/sql-console.tsx b/openflare-server/frontend/app/(main)/admin/database/components/sql-console.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/database/components/sql-console.tsx rename to openflare-server/frontend/app/(main)/admin/database/components/sql-console.tsx diff --git a/Wavelet/frontend/app/(main)/admin/database/components/table-browser.tsx b/openflare-server/frontend/app/(main)/admin/database/components/table-browser.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/database/components/table-browser.tsx rename to openflare-server/frontend/app/(main)/admin/database/components/table-browser.tsx diff --git a/Wavelet/frontend/app/(main)/admin/database/page-client.tsx b/openflare-server/frontend/app/(main)/admin/database/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/database/page-client.tsx rename to openflare-server/frontend/app/(main)/admin/database/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/admin/database/page.tsx b/openflare-server/frontend/app/(main)/admin/database/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/database/page.tsx rename to openflare-server/frontend/app/(main)/admin/database/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/demo/page.tsx b/openflare-server/frontend/app/(main)/admin/demo/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/demo/page.tsx rename to openflare-server/frontend/app/(main)/admin/demo/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx b/openflare-server/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx rename to openflare-server/frontend/app/(main)/admin/demo/tabs/controls-tab.tsx diff --git a/Wavelet/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx b/openflare-server/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx rename to openflare-server/frontend/app/(main)/admin/demo/tabs/dashboard-tab.tsx diff --git a/Wavelet/frontend/app/(main)/admin/demo/tabs/table-tab.tsx b/openflare-server/frontend/app/(main)/admin/demo/tabs/table-tab.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/demo/tabs/table-tab.tsx rename to openflare-server/frontend/app/(main)/admin/demo/tabs/table-tab.tsx diff --git a/Wavelet/frontend/app/(main)/admin/files/components/file-list.tsx b/openflare-server/frontend/app/(main)/admin/files/components/file-list.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/files/components/file-list.tsx rename to openflare-server/frontend/app/(main)/admin/files/components/file-list.tsx diff --git a/Wavelet/frontend/app/(main)/admin/files/components/file-stats.tsx b/openflare-server/frontend/app/(main)/admin/files/components/file-stats.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/files/components/file-stats.tsx rename to openflare-server/frontend/app/(main)/admin/files/components/file-stats.tsx diff --git a/Wavelet/frontend/app/(main)/admin/files/components/storage-config-tab.tsx b/openflare-server/frontend/app/(main)/admin/files/components/storage-config-tab.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/files/components/storage-config-tab.tsx rename to openflare-server/frontend/app/(main)/admin/files/components/storage-config-tab.tsx diff --git a/Wavelet/frontend/app/(main)/admin/files/page.tsx b/openflare-server/frontend/app/(main)/admin/files/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/files/page.tsx rename to openflare-server/frontend/app/(main)/admin/files/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/layout.tsx b/openflare-server/frontend/app/(main)/admin/layout.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/layout.tsx rename to openflare-server/frontend/app/(main)/admin/layout.tsx diff --git a/Wavelet/frontend/app/(main)/admin/push/components/events-tab.tsx b/openflare-server/frontend/app/(main)/admin/push/components/events-tab.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/push/components/events-tab.tsx rename to openflare-server/frontend/app/(main)/admin/push/components/events-tab.tsx diff --git a/Wavelet/frontend/app/(main)/admin/push/components/histories-tab.tsx b/openflare-server/frontend/app/(main)/admin/push/components/histories-tab.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/push/components/histories-tab.tsx rename to openflare-server/frontend/app/(main)/admin/push/components/histories-tab.tsx diff --git a/Wavelet/frontend/app/(main)/admin/push/components/settings-tab.tsx b/openflare-server/frontend/app/(main)/admin/push/components/settings-tab.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/push/components/settings-tab.tsx rename to openflare-server/frontend/app/(main)/admin/push/components/settings-tab.tsx diff --git a/Wavelet/frontend/app/(main)/admin/push/page.tsx b/openflare-server/frontend/app/(main)/admin/push/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/push/page.tsx rename to openflare-server/frontend/app/(main)/admin/push/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts b/openflare-server/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts similarity index 100% rename from Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts rename to openflare-server/frontend/app/(main)/admin/settings/components/openflare-ops-utils.ts diff --git a/Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops.tsx b/openflare-server/frontend/app/(main)/admin/settings/components/openflare-ops.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/settings/components/openflare-ops.tsx rename to openflare-server/frontend/app/(main)/admin/settings/components/openflare-ops.tsx diff --git a/Wavelet/frontend/app/(main)/admin/settings/components/system-status.tsx b/openflare-server/frontend/app/(main)/admin/settings/components/system-status.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/settings/components/system-status.tsx rename to openflare-server/frontend/app/(main)/admin/settings/components/system-status.tsx diff --git a/Wavelet/frontend/app/(main)/admin/settings/components/uptimekuma-site-modal.tsx b/openflare-server/frontend/app/(main)/admin/settings/components/uptimekuma-site-modal.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/settings/components/uptimekuma-site-modal.tsx rename to openflare-server/frontend/app/(main)/admin/settings/components/uptimekuma-site-modal.tsx diff --git a/Wavelet/frontend/app/(main)/admin/settings/page-client.tsx b/openflare-server/frontend/app/(main)/admin/settings/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/settings/page-client.tsx rename to openflare-server/frontend/app/(main)/admin/settings/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/admin/settings/page.tsx b/openflare-server/frontend/app/(main)/admin/settings/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/settings/page.tsx rename to openflare-server/frontend/app/(main)/admin/settings/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/system/page.tsx b/openflare-server/frontend/app/(main)/admin/system/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/system/page.tsx rename to openflare-server/frontend/app/(main)/admin/system/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/tasks/components/task-executions.tsx b/openflare-server/frontend/app/(main)/admin/tasks/components/task-executions.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/tasks/components/task-executions.tsx rename to openflare-server/frontend/app/(main)/admin/tasks/components/task-executions.tsx diff --git a/Wavelet/frontend/app/(main)/admin/tasks/components/task-manager.tsx b/openflare-server/frontend/app/(main)/admin/tasks/components/task-manager.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/tasks/components/task-manager.tsx rename to openflare-server/frontend/app/(main)/admin/tasks/components/task-manager.tsx diff --git a/Wavelet/frontend/app/(main)/admin/tasks/components/task-schedules.tsx b/openflare-server/frontend/app/(main)/admin/tasks/components/task-schedules.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/tasks/components/task-schedules.tsx rename to openflare-server/frontend/app/(main)/admin/tasks/components/task-schedules.tsx diff --git a/Wavelet/frontend/app/(main)/admin/tasks/page.tsx b/openflare-server/frontend/app/(main)/admin/tasks/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/tasks/page.tsx rename to openflare-server/frontend/app/(main)/admin/tasks/page.tsx diff --git a/Wavelet/frontend/app/(main)/admin/users/components/create-user-modal.tsx b/openflare-server/frontend/app/(main)/admin/users/components/create-user-modal.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/users/components/create-user-modal.tsx rename to openflare-server/frontend/app/(main)/admin/users/components/create-user-modal.tsx diff --git a/Wavelet/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx b/openflare-server/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx rename to openflare-server/frontend/app/(main)/admin/users/components/user-detail-sheet.tsx diff --git a/Wavelet/frontend/app/(main)/admin/users/components/user-filter-bar.tsx b/openflare-server/frontend/app/(main)/admin/users/components/user-filter-bar.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/users/components/user-filter-bar.tsx rename to openflare-server/frontend/app/(main)/admin/users/components/user-filter-bar.tsx diff --git a/Wavelet/frontend/app/(main)/admin/users/page.tsx b/openflare-server/frontend/app/(main)/admin/users/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/admin/users/page.tsx rename to openflare-server/frontend/app/(main)/admin/users/page.tsx diff --git a/Wavelet/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx b/openflare-server/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx rename to openflare-server/frontend/app/(main)/apply-logs/components/log-detail-sheet.tsx diff --git a/Wavelet/frontend/app/(main)/apply-logs/page-client.tsx b/openflare-server/frontend/app/(main)/apply-logs/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/apply-logs/page-client.tsx rename to openflare-server/frontend/app/(main)/apply-logs/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/apply-logs/page.tsx b/openflare-server/frontend/app/(main)/apply-logs/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/apply-logs/page.tsx rename to openflare-server/frontend/app/(main)/apply-logs/page.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx b/openflare-server/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/capacity-trend-chart.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/dashboard-utils.ts b/openflare-server/frontend/app/(main)/components/dashboard/dashboard-utils.ts similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/dashboard-utils.ts rename to openflare-server/frontend/app/(main)/components/dashboard/dashboard-utils.ts diff --git a/Wavelet/frontend/app/(main)/components/dashboard/data/world-geo.json b/openflare-server/frontend/app/(main)/components/dashboard/data/world-geo.json similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/data/world-geo.json rename to openflare-server/frontend/app/(main)/components/dashboard/data/world-geo.json diff --git a/Wavelet/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx b/openflare-server/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/distribution-rank-charts.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/geo-distribution-list.tsx b/openflare-server/frontend/app/(main)/components/dashboard/geo-distribution-list.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/geo-distribution-list.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/geo-distribution-list.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx b/openflare-server/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/network-disk-trend-chart.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/node-health-table.tsx b/openflare-server/frontend/app/(main)/components/dashboard/node-health-table.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/node-health-table.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/node-health-table.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/node-rank-panel.tsx b/openflare-server/frontend/app/(main)/components/dashboard/node-rank-panel.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/node-rank-panel.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/node-rank-panel.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/stat-cards.tsx b/openflare-server/frontend/app/(main)/components/dashboard/stat-cards.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/stat-cards.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/stat-cards.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx b/openflare-server/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/traffic-trend-chart.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/world-stage-map.tsx b/openflare-server/frontend/app/(main)/components/dashboard/world-stage-map.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/world-stage-map.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/world-stage-map.tsx diff --git a/Wavelet/frontend/app/(main)/components/dashboard/world-stage.tsx b/openflare-server/frontend/app/(main)/components/dashboard/world-stage.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/dashboard/world-stage.tsx rename to openflare-server/frontend/app/(main)/components/dashboard/world-stage.tsx diff --git a/Wavelet/frontend/app/(main)/components/placeholder-page.tsx b/openflare-server/frontend/app/(main)/components/placeholder-page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/placeholder-page.tsx rename to openflare-server/frontend/app/(main)/components/placeholder-page.tsx diff --git a/Wavelet/frontend/app/(main)/components/version-upgrade-dialog.tsx b/openflare-server/frontend/app/(main)/components/version-upgrade-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/components/version-upgrade-dialog.tsx rename to openflare-server/frontend/app/(main)/components/version-upgrade-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/config-versions/components/cleanup-dialog.tsx b/openflare-server/frontend/app/(main)/config-versions/components/cleanup-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/config-versions/components/cleanup-dialog.tsx rename to openflare-server/frontend/app/(main)/config-versions/components/cleanup-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/config-versions/components/diff-dialog.tsx b/openflare-server/frontend/app/(main)/config-versions/components/diff-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/config-versions/components/diff-dialog.tsx rename to openflare-server/frontend/app/(main)/config-versions/components/diff-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/config-versions/components/preview-sheet.tsx b/openflare-server/frontend/app/(main)/config-versions/components/preview-sheet.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/config-versions/components/preview-sheet.tsx rename to openflare-server/frontend/app/(main)/config-versions/components/preview-sheet.tsx diff --git a/Wavelet/frontend/app/(main)/config-versions/components/version-snapshot-sheet.tsx b/openflare-server/frontend/app/(main)/config-versions/components/version-snapshot-sheet.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/config-versions/components/version-snapshot-sheet.tsx rename to openflare-server/frontend/app/(main)/config-versions/components/version-snapshot-sheet.tsx diff --git a/Wavelet/frontend/app/(main)/config-versions/page.tsx b/openflare-server/frontend/app/(main)/config-versions/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/config-versions/page.tsx rename to openflare-server/frontend/app/(main)/config-versions/page.tsx diff --git a/Wavelet/frontend/app/(main)/files/page.tsx b/openflare-server/frontend/app/(main)/files/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/files/page.tsx rename to openflare-server/frontend/app/(main)/files/page.tsx diff --git a/Wavelet/frontend/app/(main)/home/page.tsx b/openflare-server/frontend/app/(main)/home/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/home/page.tsx rename to openflare-server/frontend/app/(main)/home/page.tsx diff --git a/Wavelet/frontend/app/(main)/layout.tsx b/openflare-server/frontend/app/(main)/layout.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/layout.tsx rename to openflare-server/frontend/app/(main)/layout.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/agent-update-dialog.tsx b/openflare-server/frontend/app/(main)/nodes/components/agent-update-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/agent-update-dialog.tsx rename to openflare-server/frontend/app/(main)/nodes/components/agent-update-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx b/openflare-server/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx rename to openflare-server/frontend/app/(main)/nodes/components/disk-io-trend-chart.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/distribution-list.tsx b/openflare-server/frontend/app/(main)/nodes/components/distribution-list.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/distribution-list.tsx rename to openflare-server/frontend/app/(main)/nodes/components/distribution-list.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx b/openflare-server/frontend/app/(main)/nodes/components/edge-node-detail.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/edge-node-detail.tsx rename to openflare-server/frontend/app/(main)/nodes/components/edge-node-detail.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/install-command.tsx b/openflare-server/frontend/app/(main)/nodes/components/install-command.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/install-command.tsx rename to openflare-server/frontend/app/(main)/nodes/components/install-command.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/network-trend-chart.tsx b/openflare-server/frontend/app/(main)/nodes/components/network-trend-chart.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/network-trend-chart.tsx rename to openflare-server/frontend/app/(main)/nodes/components/network-trend-chart.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-detail-primitives.tsx b/openflare-server/frontend/app/(main)/nodes/components/node-detail-primitives.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/node-detail-primitives.tsx rename to openflare-server/frontend/app/(main)/nodes/components/node-detail-primitives.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-detail-shell.tsx b/openflare-server/frontend/app/(main)/nodes/components/node-detail-shell.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/node-detail-shell.tsx rename to openflare-server/frontend/app/(main)/nodes/components/node-detail-shell.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-editor-dialog.tsx b/openflare-server/frontend/app/(main)/nodes/components/node-editor-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/node-editor-dialog.tsx rename to openflare-server/frontend/app/(main)/nodes/components/node-editor-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-observability.tsx b/openflare-server/frontend/app/(main)/nodes/components/node-observability.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/node-observability.tsx rename to openflare-server/frontend/app/(main)/nodes/components/node-observability.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-status-badge.tsx b/openflare-server/frontend/app/(main)/nodes/components/node-status-badge.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/node-status-badge.tsx rename to openflare-server/frontend/app/(main)/nodes/components/node-status-badge.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-type-filter.tsx b/openflare-server/frontend/app/(main)/nodes/components/node-type-filter.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/node-type-filter.tsx rename to openflare-server/frontend/app/(main)/nodes/components/node-type-filter.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/node-utils.ts b/openflare-server/frontend/app/(main)/nodes/components/node-utils.ts similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/node-utils.ts rename to openflare-server/frontend/app/(main)/nodes/components/node-utils.ts diff --git a/Wavelet/frontend/app/(main)/nodes/components/nodes-table.tsx b/openflare-server/frontend/app/(main)/nodes/components/nodes-table.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/nodes-table.tsx rename to openflare-server/frontend/app/(main)/nodes/components/nodes-table.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx b/openflare-server/frontend/app/(main)/nodes/components/relay-node-detail.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/relay-node-detail.tsx rename to openflare-server/frontend/app/(main)/nodes/components/relay-node-detail.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx b/openflare-server/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx rename to openflare-server/frontend/app/(main)/nodes/components/tunnel-node-detail.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/detail/page-client.tsx b/openflare-server/frontend/app/(main)/nodes/detail/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/detail/page-client.tsx rename to openflare-server/frontend/app/(main)/nodes/detail/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/detail/page.tsx b/openflare-server/frontend/app/(main)/nodes/detail/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/detail/page.tsx rename to openflare-server/frontend/app/(main)/nodes/detail/page.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/page-client.tsx b/openflare-server/frontend/app/(main)/nodes/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/page-client.tsx rename to openflare-server/frontend/app/(main)/nodes/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/nodes/page.tsx b/openflare-server/frontend/app/(main)/nodes/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/nodes/page.tsx rename to openflare-server/frontend/app/(main)/nodes/page.tsx diff --git a/Wavelet/frontend/app/(main)/origins/components/origin-editor-dialog.tsx b/openflare-server/frontend/app/(main)/origins/components/origin-editor-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/origins/components/origin-editor-dialog.tsx rename to openflare-server/frontend/app/(main)/origins/components/origin-editor-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/origins/detail/page-client.tsx b/openflare-server/frontend/app/(main)/origins/detail/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/origins/detail/page-client.tsx rename to openflare-server/frontend/app/(main)/origins/detail/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/origins/detail/page.tsx b/openflare-server/frontend/app/(main)/origins/detail/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/origins/detail/page.tsx rename to openflare-server/frontend/app/(main)/origins/detail/page.tsx diff --git a/Wavelet/frontend/app/(main)/origins/page.tsx b/openflare-server/frontend/app/(main)/origins/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/origins/page.tsx rename to openflare-server/frontend/app/(main)/origins/page.tsx diff --git a/Wavelet/frontend/app/(main)/page.tsx b/openflare-server/frontend/app/(main)/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/page.tsx rename to openflare-server/frontend/app/(main)/page.tsx diff --git a/Wavelet/frontend/app/(main)/pages/components/deployment-upload-dialog.tsx b/openflare-server/frontend/app/(main)/pages/components/deployment-upload-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/pages/components/deployment-upload-dialog.tsx rename to openflare-server/frontend/app/(main)/pages/components/deployment-upload-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/pages/components/pages-utils.ts b/openflare-server/frontend/app/(main)/pages/components/pages-utils.ts similarity index 100% rename from Wavelet/frontend/app/(main)/pages/components/pages-utils.ts rename to openflare-server/frontend/app/(main)/pages/components/pages-utils.ts diff --git a/Wavelet/frontend/app/(main)/pages/components/project-editor-dialog.tsx b/openflare-server/frontend/app/(main)/pages/components/project-editor-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/pages/components/project-editor-dialog.tsx rename to openflare-server/frontend/app/(main)/pages/components/project-editor-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/pages/components/project-list-item.tsx b/openflare-server/frontend/app/(main)/pages/components/project-list-item.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/pages/components/project-list-item.tsx rename to openflare-server/frontend/app/(main)/pages/components/project-list-item.tsx diff --git a/Wavelet/frontend/app/(main)/pages/detail/page-client.tsx b/openflare-server/frontend/app/(main)/pages/detail/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/pages/detail/page-client.tsx rename to openflare-server/frontend/app/(main)/pages/detail/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/pages/detail/page.tsx b/openflare-server/frontend/app/(main)/pages/detail/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/pages/detail/page.tsx rename to openflare-server/frontend/app/(main)/pages/detail/page.tsx diff --git a/Wavelet/frontend/app/(main)/pages/page.tsx b/openflare-server/frontend/app/(main)/pages/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/pages/page.tsx rename to openflare-server/frontend/app/(main)/pages/page.tsx diff --git a/Wavelet/frontend/app/(main)/performance/components/performance-utils.ts b/openflare-server/frontend/app/(main)/performance/components/performance-utils.ts similarity index 100% rename from Wavelet/frontend/app/(main)/performance/components/performance-utils.ts rename to openflare-server/frontend/app/(main)/performance/components/performance-utils.ts diff --git a/Wavelet/frontend/app/(main)/performance/page.tsx b/openflare-server/frontend/app/(main)/performance/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/performance/page.tsx rename to openflare-server/frontend/app/(main)/performance/page.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/components/helpers.ts b/openflare-server/frontend/app/(main)/proxy-routes/components/helpers.ts similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/components/helpers.ts rename to openflare-server/frontend/app/(main)/proxy-routes/components/helpers.ts diff --git a/Wavelet/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx b/openflare-server/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/components/proxy-route-create-sheet.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/auth-section.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/auth-section.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/auth-section.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/auth-section.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/cache-section.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/domain-list-input.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/domain-section.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/domain-section.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/domain-section.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/domain-section.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/proxy-section.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/proxy-section.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/proxy-section.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/proxy-section.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/section-shell.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/section-shell.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/section-shell.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/section-shell.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/components/waf-section.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/helpers.ts b/openflare-server/frontend/app/(main)/proxy-routes/detail/helpers.ts similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/helpers.ts rename to openflare-server/frontend/app/(main)/proxy-routes/detail/helpers.ts diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/hooks/use-route-section-save.ts b/openflare-server/frontend/app/(main)/proxy-routes/detail/hooks/use-route-section-save.ts similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/hooks/use-route-section-save.ts rename to openflare-server/frontend/app/(main)/proxy-routes/detail/hooks/use-route-section-save.ts diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/page-client.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/page-client.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/detail/page.tsx b/openflare-server/frontend/app/(main)/proxy-routes/detail/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/detail/page.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/detail/page.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/page-client.tsx b/openflare-server/frontend/app/(main)/proxy-routes/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/page-client.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/proxy-routes/page.tsx b/openflare-server/frontend/app/(main)/proxy-routes/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/proxy-routes/page.tsx rename to openflare-server/frontend/app/(main)/proxy-routes/page.tsx diff --git a/Wavelet/frontend/app/(main)/settings/access-token/page.tsx b/openflare-server/frontend/app/(main)/settings/access-token/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/settings/access-token/page.tsx rename to openflare-server/frontend/app/(main)/settings/access-token/page.tsx diff --git a/Wavelet/frontend/app/(main)/settings/appearance/page.tsx b/openflare-server/frontend/app/(main)/settings/appearance/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/settings/appearance/page.tsx rename to openflare-server/frontend/app/(main)/settings/appearance/page.tsx diff --git a/Wavelet/frontend/app/(main)/settings/layout.tsx b/openflare-server/frontend/app/(main)/settings/layout.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/settings/layout.tsx rename to openflare-server/frontend/app/(main)/settings/layout.tsx diff --git a/Wavelet/frontend/app/(main)/settings/notifications/page.tsx b/openflare-server/frontend/app/(main)/settings/notifications/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/settings/notifications/page.tsx rename to openflare-server/frontend/app/(main)/settings/notifications/page.tsx diff --git a/Wavelet/frontend/app/(main)/settings/page.tsx b/openflare-server/frontend/app/(main)/settings/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/settings/page.tsx rename to openflare-server/frontend/app/(main)/settings/page.tsx diff --git a/Wavelet/frontend/app/(main)/settings/profile/page.tsx b/openflare-server/frontend/app/(main)/settings/profile/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/settings/profile/page.tsx rename to openflare-server/frontend/app/(main)/settings/profile/page.tsx diff --git a/Wavelet/frontend/app/(main)/settings/security/page.tsx b/openflare-server/frontend/app/(main)/settings/security/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/settings/security/page.tsx rename to openflare-server/frontend/app/(main)/settings/security/page.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/helpers.ts b/openflare-server/frontend/app/(main)/waf/components/helpers.ts similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/helpers.ts rename to openflare-server/frontend/app/(main)/waf/components/helpers.ts diff --git a/Wavelet/frontend/app/(main)/waf/components/ip-group-dialog.tsx b/openflare-server/frontend/app/(main)/waf/components/ip-group-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/ip-group-dialog.tsx rename to openflare-server/frontend/app/(main)/waf/components/ip-group-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/ip-group-test-dialog.tsx b/openflare-server/frontend/app/(main)/waf/components/ip-group-test-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/ip-group-test-dialog.tsx rename to openflare-server/frontend/app/(main)/waf/components/ip-group-test-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx b/openflare-server/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx rename to openflare-server/frontend/app/(main)/waf/components/ip-group-view-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/ip-groups-table.tsx b/openflare-server/frontend/app/(main)/waf/components/ip-groups-table.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/ip-groups-table.tsx rename to openflare-server/frontend/app/(main)/waf/components/ip-groups-table.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/pow-config-panel.tsx b/openflare-server/frontend/app/(main)/waf/components/pow-config-panel.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/pow-config-panel.tsx rename to openflare-server/frontend/app/(main)/waf/components/pow-config-panel.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/rule-entry-dialog.tsx b/openflare-server/frontend/app/(main)/waf/components/rule-entry-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/rule-entry-dialog.tsx rename to openflare-server/frontend/app/(main)/waf/components/rule-entry-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/rule-group-sheet.tsx b/openflare-server/frontend/app/(main)/waf/components/rule-group-sheet.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/rule-group-sheet.tsx rename to openflare-server/frontend/app/(main)/waf/components/rule-group-sheet.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/rule-groups-table.tsx b/openflare-server/frontend/app/(main)/waf/components/rule-groups-table.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/rule-groups-table.tsx rename to openflare-server/frontend/app/(main)/waf/components/rule-groups-table.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/rule-list-section.tsx b/openflare-server/frontend/app/(main)/waf/components/rule-list-section.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/rule-list-section.tsx rename to openflare-server/frontend/app/(main)/waf/components/rule-list-section.tsx diff --git a/Wavelet/frontend/app/(main)/waf/components/site-binding-sheet.tsx b/openflare-server/frontend/app/(main)/waf/components/site-binding-sheet.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/components/site-binding-sheet.tsx rename to openflare-server/frontend/app/(main)/waf/components/site-binding-sheet.tsx diff --git a/Wavelet/frontend/app/(main)/waf/ip-groups/page.tsx b/openflare-server/frontend/app/(main)/waf/ip-groups/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/ip-groups/page.tsx rename to openflare-server/frontend/app/(main)/waf/ip-groups/page.tsx diff --git a/Wavelet/frontend/app/(main)/waf/page.tsx b/openflare-server/frontend/app/(main)/waf/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/waf/page.tsx rename to openflare-server/frontend/app/(main)/waf/page.tsx diff --git a/Wavelet/frontend/app/(main)/websites/certificates/page.tsx b/openflare-server/frontend/app/(main)/websites/certificates/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/certificates/page.tsx rename to openflare-server/frontend/app/(main)/websites/certificates/page.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/certificate-apply-dialog.tsx b/openflare-server/frontend/app/(main)/websites/components/certificate-apply-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/certificate-apply-dialog.tsx rename to openflare-server/frontend/app/(main)/websites/components/certificate-apply-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/certificate-detail-dialog.tsx b/openflare-server/frontend/app/(main)/websites/components/certificate-detail-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/certificate-detail-dialog.tsx rename to openflare-server/frontend/app/(main)/websites/components/certificate-detail-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/certificate-editor-dialog.tsx b/openflare-server/frontend/app/(main)/websites/components/certificate-editor-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/certificate-editor-dialog.tsx rename to openflare-server/frontend/app/(main)/websites/components/certificate-editor-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/certificate-import-dialog.tsx b/openflare-server/frontend/app/(main)/websites/components/certificate-import-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/certificate-import-dialog.tsx rename to openflare-server/frontend/app/(main)/websites/components/certificate-import-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/dns-account-create-dialog.tsx b/openflare-server/frontend/app/(main)/websites/components/dns-account-create-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/dns-account-create-dialog.tsx rename to openflare-server/frontend/app/(main)/websites/components/dns-account-create-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/schemas.ts b/openflare-server/frontend/app/(main)/websites/components/schemas.ts similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/schemas.ts rename to openflare-server/frontend/app/(main)/websites/components/schemas.ts diff --git a/Wavelet/frontend/app/(main)/websites/components/status-badge.tsx b/openflare-server/frontend/app/(main)/websites/components/status-badge.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/status-badge.tsx rename to openflare-server/frontend/app/(main)/websites/components/status-badge.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/website-editor-dialog.tsx b/openflare-server/frontend/app/(main)/websites/components/website-editor-dialog.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/website-editor-dialog.tsx rename to openflare-server/frontend/app/(main)/websites/components/website-editor-dialog.tsx diff --git a/Wavelet/frontend/app/(main)/websites/components/website-utils.ts b/openflare-server/frontend/app/(main)/websites/components/website-utils.ts similarity index 100% rename from Wavelet/frontend/app/(main)/websites/components/website-utils.ts rename to openflare-server/frontend/app/(main)/websites/components/website-utils.ts diff --git a/Wavelet/frontend/app/(main)/websites/detail/page-client.tsx b/openflare-server/frontend/app/(main)/websites/detail/page-client.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/detail/page-client.tsx rename to openflare-server/frontend/app/(main)/websites/detail/page-client.tsx diff --git a/Wavelet/frontend/app/(main)/websites/detail/page.tsx b/openflare-server/frontend/app/(main)/websites/detail/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/detail/page.tsx rename to openflare-server/frontend/app/(main)/websites/detail/page.tsx diff --git a/Wavelet/frontend/app/(main)/websites/dns-accounts/page.tsx b/openflare-server/frontend/app/(main)/websites/dns-accounts/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/dns-accounts/page.tsx rename to openflare-server/frontend/app/(main)/websites/dns-accounts/page.tsx diff --git a/Wavelet/frontend/app/(main)/websites/page.tsx b/openflare-server/frontend/app/(main)/websites/page.tsx similarity index 100% rename from Wavelet/frontend/app/(main)/websites/page.tsx rename to openflare-server/frontend/app/(main)/websites/page.tsx diff --git a/Wavelet/frontend/app/globals.css b/openflare-server/frontend/app/globals.css similarity index 100% rename from Wavelet/frontend/app/globals.css rename to openflare-server/frontend/app/globals.css diff --git a/Wavelet/frontend/app/icon.tsx b/openflare-server/frontend/app/icon.tsx similarity index 100% rename from Wavelet/frontend/app/icon.tsx rename to openflare-server/frontend/app/icon.tsx diff --git a/Wavelet/frontend/app/layout.tsx b/openflare-server/frontend/app/layout.tsx similarity index 100% rename from Wavelet/frontend/app/layout.tsx rename to openflare-server/frontend/app/layout.tsx diff --git a/Wavelet/frontend/app/not-found.tsx b/openflare-server/frontend/app/not-found.tsx similarity index 100% rename from Wavelet/frontend/app/not-found.tsx rename to openflare-server/frontend/app/not-found.tsx diff --git a/Wavelet/frontend/components.json b/openflare-server/frontend/components.json similarity index 100% rename from Wavelet/frontend/components.json rename to openflare-server/frontend/components.json diff --git a/Wavelet/frontend/components/animate-ui/components/buttons/button.tsx b/openflare-server/frontend/components/animate-ui/components/buttons/button.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/components/buttons/button.tsx rename to openflare-server/frontend/components/animate-ui/components/buttons/button.tsx diff --git a/Wavelet/frontend/components/animate-ui/components/buttons/ripple.tsx b/openflare-server/frontend/components/animate-ui/components/buttons/ripple.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/components/buttons/ripple.tsx rename to openflare-server/frontend/components/animate-ui/components/buttons/ripple.tsx diff --git a/Wavelet/frontend/components/animate-ui/icons/check.tsx b/openflare-server/frontend/components/animate-ui/icons/check.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/icons/check.tsx rename to openflare-server/frontend/components/animate-ui/icons/check.tsx diff --git a/Wavelet/frontend/components/animate-ui/icons/chevron-down.tsx b/openflare-server/frontend/components/animate-ui/icons/chevron-down.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/icons/chevron-down.tsx rename to openflare-server/frontend/components/animate-ui/icons/chevron-down.tsx diff --git a/Wavelet/frontend/components/animate-ui/icons/chevron-left.tsx b/openflare-server/frontend/components/animate-ui/icons/chevron-left.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/icons/chevron-left.tsx rename to openflare-server/frontend/components/animate-ui/icons/chevron-left.tsx diff --git a/Wavelet/frontend/components/animate-ui/icons/chevron-right.tsx b/openflare-server/frontend/components/animate-ui/icons/chevron-right.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/icons/chevron-right.tsx rename to openflare-server/frontend/components/animate-ui/icons/chevron-right.tsx diff --git a/Wavelet/frontend/components/animate-ui/icons/circle-check.tsx b/openflare-server/frontend/components/animate-ui/icons/circle-check.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/icons/circle-check.tsx rename to openflare-server/frontend/components/animate-ui/icons/circle-check.tsx diff --git a/Wavelet/frontend/components/animate-ui/icons/icon.tsx b/openflare-server/frontend/components/animate-ui/icons/icon.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/icons/icon.tsx rename to openflare-server/frontend/components/animate-ui/icons/icon.tsx diff --git a/Wavelet/frontend/components/animate-ui/icons/send.tsx b/openflare-server/frontend/components/animate-ui/icons/send.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/icons/send.tsx rename to openflare-server/frontend/components/animate-ui/icons/send.tsx diff --git a/Wavelet/frontend/components/animate-ui/primitives/animate/slot.tsx b/openflare-server/frontend/components/animate-ui/primitives/animate/slot.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/primitives/animate/slot.tsx rename to openflare-server/frontend/components/animate-ui/primitives/animate/slot.tsx diff --git a/Wavelet/frontend/components/animate-ui/primitives/buttons/button.tsx b/openflare-server/frontend/components/animate-ui/primitives/buttons/button.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/primitives/buttons/button.tsx rename to openflare-server/frontend/components/animate-ui/primitives/buttons/button.tsx diff --git a/Wavelet/frontend/components/animate-ui/primitives/buttons/ripple.tsx b/openflare-server/frontend/components/animate-ui/primitives/buttons/ripple.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/primitives/buttons/ripple.tsx rename to openflare-server/frontend/components/animate-ui/primitives/buttons/ripple.tsx diff --git a/Wavelet/frontend/components/animate-ui/primitives/radix/collapsible.tsx b/openflare-server/frontend/components/animate-ui/primitives/radix/collapsible.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/primitives/radix/collapsible.tsx rename to openflare-server/frontend/components/animate-ui/primitives/radix/collapsible.tsx diff --git a/Wavelet/frontend/components/animate-ui/primitives/texts/counting-number.tsx b/openflare-server/frontend/components/animate-ui/primitives/texts/counting-number.tsx similarity index 100% rename from Wavelet/frontend/components/animate-ui/primitives/texts/counting-number.tsx rename to openflare-server/frontend/components/animate-ui/primitives/texts/counting-number.tsx diff --git a/Wavelet/frontend/components/auth/auth-shell.tsx b/openflare-server/frontend/components/auth/auth-shell.tsx similarity index 100% rename from Wavelet/frontend/components/auth/auth-shell.tsx rename to openflare-server/frontend/components/auth/auth-shell.tsx diff --git a/Wavelet/frontend/components/auth/cap-widget.tsx b/openflare-server/frontend/components/auth/cap-widget.tsx similarity index 100% rename from Wavelet/frontend/components/auth/cap-widget.tsx rename to openflare-server/frontend/components/auth/cap-widget.tsx diff --git a/Wavelet/frontend/components/auth/login-form.tsx b/openflare-server/frontend/components/auth/login-form.tsx similarity index 100% rename from Wavelet/frontend/components/auth/login-form.tsx rename to openflare-server/frontend/components/auth/login-form.tsx diff --git a/Wavelet/frontend/components/auth/login-page.tsx b/openflare-server/frontend/components/auth/login-page.tsx similarity index 100% rename from Wavelet/frontend/components/auth/login-page.tsx rename to openflare-server/frontend/components/auth/login-page.tsx diff --git a/Wavelet/frontend/components/auth/otp-form.tsx b/openflare-server/frontend/components/auth/otp-form.tsx similarity index 100% rename from Wavelet/frontend/components/auth/otp-form.tsx rename to openflare-server/frontend/components/auth/otp-form.tsx diff --git a/Wavelet/frontend/components/auth/register-form.tsx b/openflare-server/frontend/components/auth/register-form.tsx similarity index 100% rename from Wavelet/frontend/components/auth/register-form.tsx rename to openflare-server/frontend/components/auth/register-form.tsx diff --git a/Wavelet/frontend/components/auth/register-page.tsx b/openflare-server/frontend/components/auth/register-page.tsx similarity index 100% rename from Wavelet/frontend/components/auth/register-page.tsx rename to openflare-server/frontend/components/auth/register-page.tsx diff --git a/Wavelet/frontend/components/auth/require-auth.tsx b/openflare-server/frontend/components/auth/require-auth.tsx similarity index 100% rename from Wavelet/frontend/components/auth/require-auth.tsx rename to openflare-server/frontend/components/auth/require-auth.tsx diff --git a/Wavelet/frontend/components/common/docs/api.tsx b/openflare-server/frontend/components/common/docs/api.tsx similarity index 100% rename from Wavelet/frontend/components/common/docs/api.tsx rename to openflare-server/frontend/components/common/docs/api.tsx diff --git a/Wavelet/frontend/components/common/docs/how-to-use.tsx b/openflare-server/frontend/components/common/docs/how-to-use.tsx similarity index 100% rename from Wavelet/frontend/components/common/docs/how-to-use.tsx rename to openflare-server/frontend/components/common/docs/how-to-use.tsx diff --git a/Wavelet/frontend/components/common/docs/legal-page-layout.tsx b/openflare-server/frontend/components/common/docs/legal-page-layout.tsx similarity index 100% rename from Wavelet/frontend/components/common/docs/legal-page-layout.tsx rename to openflare-server/frontend/components/common/docs/legal-page-layout.tsx diff --git a/Wavelet/frontend/components/common/docs/privacy.tsx b/openflare-server/frontend/components/common/docs/privacy.tsx similarity index 100% rename from Wavelet/frontend/components/common/docs/privacy.tsx rename to openflare-server/frontend/components/common/docs/privacy.tsx diff --git a/Wavelet/frontend/components/common/docs/terms.tsx b/openflare-server/frontend/components/common/docs/terms.tsx similarity index 100% rename from Wavelet/frontend/components/common/docs/terms.tsx rename to openflare-server/frontend/components/common/docs/terms.tsx diff --git a/Wavelet/frontend/components/common/docs/types.ts b/openflare-server/frontend/components/common/docs/types.ts similarity index 100% rename from Wavelet/frontend/components/common/docs/types.ts rename to openflare-server/frontend/components/common/docs/types.ts diff --git a/Wavelet/frontend/components/common/file-image-preview.tsx b/openflare-server/frontend/components/common/file-image-preview.tsx similarity index 100% rename from Wavelet/frontend/components/common/file-image-preview.tsx rename to openflare-server/frontend/components/common/file-image-preview.tsx diff --git a/Wavelet/frontend/components/common/general/manage-pannel.tsx b/openflare-server/frontend/components/common/general/manage-pannel.tsx similarity index 100% rename from Wavelet/frontend/components/common/general/manage-pannel.tsx rename to openflare-server/frontend/components/common/general/manage-pannel.tsx diff --git a/Wavelet/frontend/components/common/general/password-dialog.tsx b/openflare-server/frontend/components/common/general/password-dialog.tsx similarity index 100% rename from Wavelet/frontend/components/common/general/password-dialog.tsx rename to openflare-server/frontend/components/common/general/password-dialog.tsx diff --git a/Wavelet/frontend/components/common/home/home-main.tsx b/openflare-server/frontend/components/common/home/home-main.tsx similarity index 100% rename from Wavelet/frontend/components/common/home/home-main.tsx rename to openflare-server/frontend/components/common/home/home-main.tsx diff --git a/Wavelet/frontend/components/common/settings/access-token.tsx b/openflare-server/frontend/components/common/settings/access-token.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/access-token.tsx rename to openflare-server/frontend/components/common/settings/access-token.tsx diff --git a/Wavelet/frontend/components/common/settings/appearance.tsx b/openflare-server/frontend/components/common/settings/appearance.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/appearance.tsx rename to openflare-server/frontend/components/common/settings/appearance.tsx diff --git a/Wavelet/frontend/components/common/settings/auth-source-modal.tsx b/openflare-server/frontend/components/common/settings/auth-source-modal.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/auth-source-modal.tsx rename to openflare-server/frontend/components/common/settings/auth-source-modal.tsx diff --git a/Wavelet/frontend/components/common/settings/info-tab.tsx b/openflare-server/frontend/components/common/settings/info-tab.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/info-tab.tsx rename to openflare-server/frontend/components/common/settings/info-tab.tsx diff --git a/Wavelet/frontend/components/common/settings/notifications.tsx b/openflare-server/frontend/components/common/settings/notifications.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/notifications.tsx rename to openflare-server/frontend/components/common/settings/notifications.tsx diff --git a/Wavelet/frontend/components/common/settings/operation-tab.tsx b/openflare-server/frontend/components/common/settings/operation-tab.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/operation-tab.tsx rename to openflare-server/frontend/components/common/settings/operation-tab.tsx diff --git a/Wavelet/frontend/components/common/settings/other-tab.tsx b/openflare-server/frontend/components/common/settings/other-tab.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/other-tab.tsx rename to openflare-server/frontend/components/common/settings/other-tab.tsx diff --git a/Wavelet/frontend/components/common/settings/profile.tsx b/openflare-server/frontend/components/common/settings/profile.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/profile.tsx rename to openflare-server/frontend/components/common/settings/profile.tsx diff --git a/Wavelet/frontend/components/common/settings/security-tab.tsx b/openflare-server/frontend/components/common/settings/security-tab.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/security-tab.tsx rename to openflare-server/frontend/components/common/settings/security-tab.tsx diff --git a/Wavelet/frontend/components/common/settings/system-tab.tsx b/openflare-server/frontend/components/common/settings/system-tab.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/system-tab.tsx rename to openflare-server/frontend/components/common/settings/system-tab.tsx diff --git a/Wavelet/frontend/components/common/settings/templates.tsx b/openflare-server/frontend/components/common/settings/templates.tsx similarity index 100% rename from Wavelet/frontend/components/common/settings/templates.tsx rename to openflare-server/frontend/components/common/settings/templates.tsx diff --git a/Wavelet/frontend/components/common/user/file-manager.tsx b/openflare-server/frontend/components/common/user/file-manager.tsx similarity index 100% rename from Wavelet/frontend/components/common/user/file-manager.tsx rename to openflare-server/frontend/components/common/user/file-manager.tsx diff --git a/Wavelet/frontend/components/data/rank-chart.tsx b/openflare-server/frontend/components/data/rank-chart.tsx similarity index 100% rename from Wavelet/frontend/components/data/rank-chart.tsx rename to openflare-server/frontend/components/data/rank-chart.tsx diff --git a/Wavelet/frontend/components/data/trend-chart.tsx b/openflare-server/frontend/components/data/trend-chart.tsx similarity index 100% rename from Wavelet/frontend/components/data/trend-chart.tsx rename to openflare-server/frontend/components/data/trend-chart.tsx diff --git a/Wavelet/frontend/components/home/developer-section.tsx b/openflare-server/frontend/components/home/developer-section.tsx similarity index 100% rename from Wavelet/frontend/components/home/developer-section.tsx rename to openflare-server/frontend/components/home/developer-section.tsx diff --git a/Wavelet/frontend/components/home/footer-section.tsx b/openflare-server/frontend/components/home/footer-section.tsx similarity index 100% rename from Wavelet/frontend/components/home/footer-section.tsx rename to openflare-server/frontend/components/home/footer-section.tsx diff --git a/Wavelet/frontend/components/home/hero-section.tsx b/openflare-server/frontend/components/home/hero-section.tsx similarity index 100% rename from Wavelet/frontend/components/home/hero-section.tsx rename to openflare-server/frontend/components/home/hero-section.tsx diff --git a/Wavelet/frontend/components/layout/avater-style/Christmas.tsx b/openflare-server/frontend/components/layout/avater-style/Christmas.tsx similarity index 100% rename from Wavelet/frontend/components/layout/avater-style/Christmas.tsx rename to openflare-server/frontend/components/layout/avater-style/Christmas.tsx diff --git a/Wavelet/frontend/components/layout/avater-style/NewYear.tsx b/openflare-server/frontend/components/layout/avater-style/NewYear.tsx similarity index 100% rename from Wavelet/frontend/components/layout/avater-style/NewYear.tsx rename to openflare-server/frontend/components/layout/avater-style/NewYear.tsx diff --git a/Wavelet/frontend/components/layout/avater-style/registry.ts b/openflare-server/frontend/components/layout/avater-style/registry.ts similarity index 100% rename from Wavelet/frontend/components/layout/avater-style/registry.ts rename to openflare-server/frontend/components/layout/avater-style/registry.ts diff --git a/Wavelet/frontend/components/layout/avater-style/types.ts b/openflare-server/frontend/components/layout/avater-style/types.ts similarity index 100% rename from Wavelet/frontend/components/layout/avater-style/types.ts rename to openflare-server/frontend/components/layout/avater-style/types.ts diff --git a/Wavelet/frontend/components/layout/empty.tsx b/openflare-server/frontend/components/layout/empty.tsx similarity index 100% rename from Wavelet/frontend/components/layout/empty.tsx rename to openflare-server/frontend/components/layout/empty.tsx diff --git a/Wavelet/frontend/components/layout/error.tsx b/openflare-server/frontend/components/layout/error.tsx similarity index 100% rename from Wavelet/frontend/components/layout/error.tsx rename to openflare-server/frontend/components/layout/error.tsx diff --git a/Wavelet/frontend/components/layout/header.tsx b/openflare-server/frontend/components/layout/header.tsx similarity index 100% rename from Wavelet/frontend/components/layout/header.tsx rename to openflare-server/frontend/components/layout/header.tsx diff --git a/Wavelet/frontend/components/layout/loading.tsx b/openflare-server/frontend/components/layout/loading.tsx similarity index 100% rename from Wavelet/frontend/components/layout/loading.tsx rename to openflare-server/frontend/components/layout/loading.tsx diff --git a/Wavelet/frontend/components/layout/openflare-sidebar-menu.tsx b/openflare-server/frontend/components/layout/openflare-sidebar-menu.tsx similarity index 100% rename from Wavelet/frontend/components/layout/openflare-sidebar-menu.tsx rename to openflare-server/frontend/components/layout/openflare-sidebar-menu.tsx diff --git a/Wavelet/frontend/components/layout/robots-meta.tsx b/openflare-server/frontend/components/layout/robots-meta.tsx similarity index 100% rename from Wavelet/frontend/components/layout/robots-meta.tsx rename to openflare-server/frontend/components/layout/robots-meta.tsx diff --git a/Wavelet/frontend/components/layout/search-dialog.tsx b/openflare-server/frontend/components/layout/search-dialog.tsx similarity index 100% rename from Wavelet/frontend/components/layout/search-dialog.tsx rename to openflare-server/frontend/components/layout/search-dialog.tsx diff --git a/Wavelet/frontend/components/layout/sidebar.tsx b/openflare-server/frontend/components/layout/sidebar.tsx similarity index 100% rename from Wavelet/frontend/components/layout/sidebar.tsx rename to openflare-server/frontend/components/layout/sidebar.tsx diff --git a/Wavelet/frontend/components/layout/theme-provider.tsx b/openflare-server/frontend/components/layout/theme-provider.tsx similarity index 100% rename from Wavelet/frontend/components/layout/theme-provider.tsx rename to openflare-server/frontend/components/layout/theme-provider.tsx diff --git a/Wavelet/frontend/components/layout/transition.tsx b/openflare-server/frontend/components/layout/transition.tsx similarity index 100% rename from Wavelet/frontend/components/layout/transition.tsx rename to openflare-server/frontend/components/layout/transition.tsx diff --git a/Wavelet/frontend/components/providers/auth-provider.tsx b/openflare-server/frontend/components/providers/auth-provider.tsx similarity index 100% rename from Wavelet/frontend/components/providers/auth-provider.tsx rename to openflare-server/frontend/components/providers/auth-provider.tsx diff --git a/Wavelet/frontend/components/providers/query-provider.tsx b/openflare-server/frontend/components/providers/query-provider.tsx similarity index 100% rename from Wavelet/frontend/components/providers/query-provider.tsx rename to openflare-server/frontend/components/providers/query-provider.tsx diff --git a/Wavelet/frontend/components/providers/title-updater.tsx b/openflare-server/frontend/components/providers/title-updater.tsx similarity index 100% rename from Wavelet/frontend/components/providers/title-updater.tsx rename to openflare-server/frontend/components/providers/title-updater.tsx diff --git a/Wavelet/frontend/components/ui/accordion.tsx b/openflare-server/frontend/components/ui/accordion.tsx similarity index 100% rename from Wavelet/frontend/components/ui/accordion.tsx rename to openflare-server/frontend/components/ui/accordion.tsx diff --git a/Wavelet/frontend/components/ui/alert-dialog.tsx b/openflare-server/frontend/components/ui/alert-dialog.tsx similarity index 100% rename from Wavelet/frontend/components/ui/alert-dialog.tsx rename to openflare-server/frontend/components/ui/alert-dialog.tsx diff --git a/Wavelet/frontend/components/ui/aurora-background.tsx b/openflare-server/frontend/components/ui/aurora-background.tsx similarity index 100% rename from Wavelet/frontend/components/ui/aurora-background.tsx rename to openflare-server/frontend/components/ui/aurora-background.tsx diff --git a/Wavelet/frontend/components/ui/avatar.tsx b/openflare-server/frontend/components/ui/avatar.tsx similarity index 100% rename from Wavelet/frontend/components/ui/avatar.tsx rename to openflare-server/frontend/components/ui/avatar.tsx diff --git a/Wavelet/frontend/components/ui/badge.tsx b/openflare-server/frontend/components/ui/badge.tsx similarity index 100% rename from Wavelet/frontend/components/ui/badge.tsx rename to openflare-server/frontend/components/ui/badge.tsx diff --git a/Wavelet/frontend/components/ui/breadcrumb.tsx b/openflare-server/frontend/components/ui/breadcrumb.tsx similarity index 100% rename from Wavelet/frontend/components/ui/breadcrumb.tsx rename to openflare-server/frontend/components/ui/breadcrumb.tsx diff --git a/Wavelet/frontend/components/ui/button.tsx b/openflare-server/frontend/components/ui/button.tsx similarity index 100% rename from Wavelet/frontend/components/ui/button.tsx rename to openflare-server/frontend/components/ui/button.tsx diff --git a/Wavelet/frontend/components/ui/calendar.tsx b/openflare-server/frontend/components/ui/calendar.tsx similarity index 100% rename from Wavelet/frontend/components/ui/calendar.tsx rename to openflare-server/frontend/components/ui/calendar.tsx diff --git a/Wavelet/frontend/components/ui/card.tsx b/openflare-server/frontend/components/ui/card.tsx similarity index 100% rename from Wavelet/frontend/components/ui/card.tsx rename to openflare-server/frontend/components/ui/card.tsx diff --git a/Wavelet/frontend/components/ui/carousel.tsx b/openflare-server/frontend/components/ui/carousel.tsx similarity index 100% rename from Wavelet/frontend/components/ui/carousel.tsx rename to openflare-server/frontend/components/ui/carousel.tsx diff --git a/Wavelet/frontend/components/ui/chart.tsx b/openflare-server/frontend/components/ui/chart.tsx similarity index 100% rename from Wavelet/frontend/components/ui/chart.tsx rename to openflare-server/frontend/components/ui/chart.tsx diff --git a/Wavelet/frontend/components/ui/checkbox.tsx b/openflare-server/frontend/components/ui/checkbox.tsx similarity index 100% rename from Wavelet/frontend/components/ui/checkbox.tsx rename to openflare-server/frontend/components/ui/checkbox.tsx diff --git a/Wavelet/frontend/components/ui/code-block.tsx b/openflare-server/frontend/components/ui/code-block.tsx similarity index 100% rename from Wavelet/frontend/components/ui/code-block.tsx rename to openflare-server/frontend/components/ui/code-block.tsx diff --git a/Wavelet/frontend/components/ui/collapsible.tsx b/openflare-server/frontend/components/ui/collapsible.tsx similarity index 100% rename from Wavelet/frontend/components/ui/collapsible.tsx rename to openflare-server/frontend/components/ui/collapsible.tsx diff --git a/Wavelet/frontend/components/ui/command.tsx b/openflare-server/frontend/components/ui/command.tsx similarity index 100% rename from Wavelet/frontend/components/ui/command.tsx rename to openflare-server/frontend/components/ui/command.tsx diff --git a/Wavelet/frontend/components/ui/dialog.tsx b/openflare-server/frontend/components/ui/dialog.tsx similarity index 100% rename from Wavelet/frontend/components/ui/dialog.tsx rename to openflare-server/frontend/components/ui/dialog.tsx diff --git a/Wavelet/frontend/components/ui/docs-table.tsx b/openflare-server/frontend/components/ui/docs-table.tsx similarity index 100% rename from Wavelet/frontend/components/ui/docs-table.tsx rename to openflare-server/frontend/components/ui/docs-table.tsx diff --git a/Wavelet/frontend/components/ui/drawer.tsx b/openflare-server/frontend/components/ui/drawer.tsx similarity index 100% rename from Wavelet/frontend/components/ui/drawer.tsx rename to openflare-server/frontend/components/ui/drawer.tsx diff --git a/Wavelet/frontend/components/ui/dropdown-menu.tsx b/openflare-server/frontend/components/ui/dropdown-menu.tsx similarity index 100% rename from Wavelet/frontend/components/ui/dropdown-menu.tsx rename to openflare-server/frontend/components/ui/dropdown-menu.tsx diff --git a/Wavelet/frontend/components/ui/field.tsx b/openflare-server/frontend/components/ui/field.tsx similarity index 100% rename from Wavelet/frontend/components/ui/field.tsx rename to openflare-server/frontend/components/ui/field.tsx diff --git a/Wavelet/frontend/components/ui/fireworks-effect.tsx b/openflare-server/frontend/components/ui/fireworks-effect.tsx similarity index 100% rename from Wavelet/frontend/components/ui/fireworks-effect.tsx rename to openflare-server/frontend/components/ui/fireworks-effect.tsx diff --git a/Wavelet/frontend/components/ui/form.tsx b/openflare-server/frontend/components/ui/form.tsx similarity index 100% rename from Wavelet/frontend/components/ui/form.tsx rename to openflare-server/frontend/components/ui/form.tsx diff --git a/Wavelet/frontend/components/ui/image-crop.tsx b/openflare-server/frontend/components/ui/image-crop.tsx similarity index 100% rename from Wavelet/frontend/components/ui/image-crop.tsx rename to openflare-server/frontend/components/ui/image-crop.tsx diff --git a/Wavelet/frontend/components/ui/input-otp.tsx b/openflare-server/frontend/components/ui/input-otp.tsx similarity index 100% rename from Wavelet/frontend/components/ui/input-otp.tsx rename to openflare-server/frontend/components/ui/input-otp.tsx diff --git a/Wavelet/frontend/components/ui/input.tsx b/openflare-server/frontend/components/ui/input.tsx similarity index 100% rename from Wavelet/frontend/components/ui/input.tsx rename to openflare-server/frontend/components/ui/input.tsx diff --git a/Wavelet/frontend/components/ui/kbd.tsx b/openflare-server/frontend/components/ui/kbd.tsx similarity index 100% rename from Wavelet/frontend/components/ui/kbd.tsx rename to openflare-server/frontend/components/ui/kbd.tsx diff --git a/Wavelet/frontend/components/ui/label.tsx b/openflare-server/frontend/components/ui/label.tsx similarity index 100% rename from Wavelet/frontend/components/ui/label.tsx rename to openflare-server/frontend/components/ui/label.tsx diff --git a/Wavelet/frontend/components/ui/pagination.tsx b/openflare-server/frontend/components/ui/pagination.tsx similarity index 100% rename from Wavelet/frontend/components/ui/pagination.tsx rename to openflare-server/frontend/components/ui/pagination.tsx diff --git a/Wavelet/frontend/components/ui/popover.tsx b/openflare-server/frontend/components/ui/popover.tsx similarity index 100% rename from Wavelet/frontend/components/ui/popover.tsx rename to openflare-server/frontend/components/ui/popover.tsx diff --git a/Wavelet/frontend/components/ui/progress.tsx b/openflare-server/frontend/components/ui/progress.tsx similarity index 100% rename from Wavelet/frontend/components/ui/progress.tsx rename to openflare-server/frontend/components/ui/progress.tsx diff --git a/Wavelet/frontend/components/ui/scroll-area.tsx b/openflare-server/frontend/components/ui/scroll-area.tsx similarity index 100% rename from Wavelet/frontend/components/ui/scroll-area.tsx rename to openflare-server/frontend/components/ui/scroll-area.tsx diff --git a/Wavelet/frontend/components/ui/select.tsx b/openflare-server/frontend/components/ui/select.tsx similarity index 100% rename from Wavelet/frontend/components/ui/select.tsx rename to openflare-server/frontend/components/ui/select.tsx diff --git a/Wavelet/frontend/components/ui/separator.tsx b/openflare-server/frontend/components/ui/separator.tsx similarity index 100% rename from Wavelet/frontend/components/ui/separator.tsx rename to openflare-server/frontend/components/ui/separator.tsx diff --git a/Wavelet/frontend/components/ui/sheet.tsx b/openflare-server/frontend/components/ui/sheet.tsx similarity index 100% rename from Wavelet/frontend/components/ui/sheet.tsx rename to openflare-server/frontend/components/ui/sheet.tsx diff --git a/Wavelet/frontend/components/ui/sidebar.tsx b/openflare-server/frontend/components/ui/sidebar.tsx similarity index 100% rename from Wavelet/frontend/components/ui/sidebar.tsx rename to openflare-server/frontend/components/ui/sidebar.tsx diff --git a/Wavelet/frontend/components/ui/skeleton.tsx b/openflare-server/frontend/components/ui/skeleton.tsx similarity index 100% rename from Wavelet/frontend/components/ui/skeleton.tsx rename to openflare-server/frontend/components/ui/skeleton.tsx diff --git a/Wavelet/frontend/components/ui/slider.tsx b/openflare-server/frontend/components/ui/slider.tsx similarity index 100% rename from Wavelet/frontend/components/ui/slider.tsx rename to openflare-server/frontend/components/ui/slider.tsx diff --git a/Wavelet/frontend/components/ui/snow-effect.tsx b/openflare-server/frontend/components/ui/snow-effect.tsx similarity index 100% rename from Wavelet/frontend/components/ui/snow-effect.tsx rename to openflare-server/frontend/components/ui/snow-effect.tsx diff --git a/Wavelet/frontend/components/ui/sonner.tsx b/openflare-server/frontend/components/ui/sonner.tsx similarity index 100% rename from Wavelet/frontend/components/ui/sonner.tsx rename to openflare-server/frontend/components/ui/sonner.tsx diff --git a/Wavelet/frontend/components/ui/spinner.tsx b/openflare-server/frontend/components/ui/spinner.tsx similarity index 100% rename from Wavelet/frontend/components/ui/spinner.tsx rename to openflare-server/frontend/components/ui/spinner.tsx diff --git a/Wavelet/frontend/components/ui/switch.tsx b/openflare-server/frontend/components/ui/switch.tsx similarity index 100% rename from Wavelet/frontend/components/ui/switch.tsx rename to openflare-server/frontend/components/ui/switch.tsx diff --git a/Wavelet/frontend/components/ui/table.tsx b/openflare-server/frontend/components/ui/table.tsx similarity index 100% rename from Wavelet/frontend/components/ui/table.tsx rename to openflare-server/frontend/components/ui/table.tsx diff --git a/Wavelet/frontend/components/ui/tabs.tsx b/openflare-server/frontend/components/ui/tabs.tsx similarity index 100% rename from Wavelet/frontend/components/ui/tabs.tsx rename to openflare-server/frontend/components/ui/tabs.tsx diff --git a/Wavelet/frontend/components/ui/textarea.tsx b/openflare-server/frontend/components/ui/textarea.tsx similarity index 100% rename from Wavelet/frontend/components/ui/textarea.tsx rename to openflare-server/frontend/components/ui/textarea.tsx diff --git a/Wavelet/frontend/components/ui/toggle-group.tsx b/openflare-server/frontend/components/ui/toggle-group.tsx similarity index 100% rename from Wavelet/frontend/components/ui/toggle-group.tsx rename to openflare-server/frontend/components/ui/toggle-group.tsx diff --git a/Wavelet/frontend/components/ui/toggle.tsx b/openflare-server/frontend/components/ui/toggle.tsx similarity index 100% rename from Wavelet/frontend/components/ui/toggle.tsx rename to openflare-server/frontend/components/ui/toggle.tsx diff --git a/Wavelet/frontend/components/ui/tooltip.tsx b/openflare-server/frontend/components/ui/tooltip.tsx similarity index 100% rename from Wavelet/frontend/components/ui/tooltip.tsx rename to openflare-server/frontend/components/ui/tooltip.tsx diff --git a/Wavelet/frontend/contexts/admin-context.tsx b/openflare-server/frontend/contexts/admin-context.tsx similarity index 100% rename from Wavelet/frontend/contexts/admin-context.tsx rename to openflare-server/frontend/contexts/admin-context.tsx diff --git a/Wavelet/frontend/contexts/admin-users-context.tsx b/openflare-server/frontend/contexts/admin-users-context.tsx similarity index 100% rename from Wavelet/frontend/contexts/admin-users-context.tsx rename to openflare-server/frontend/contexts/admin-users-context.tsx diff --git a/Wavelet/frontend/contexts/bell-ring-context.tsx b/openflare-server/frontend/contexts/bell-ring-context.tsx similarity index 100% rename from Wavelet/frontend/contexts/bell-ring-context.tsx rename to openflare-server/frontend/contexts/bell-ring-context.tsx diff --git a/Wavelet/frontend/contexts/notification-settings-context.tsx b/openflare-server/frontend/contexts/notification-settings-context.tsx similarity index 100% rename from Wavelet/frontend/contexts/notification-settings-context.tsx rename to openflare-server/frontend/contexts/notification-settings-context.tsx diff --git a/Wavelet/frontend/contexts/user-context.tsx b/openflare-server/frontend/contexts/user-context.tsx similarity index 100% rename from Wavelet/frontend/contexts/user-context.tsx rename to openflare-server/frontend/contexts/user-context.tsx diff --git a/Wavelet/frontend/entrypoint.sh b/openflare-server/frontend/entrypoint.sh similarity index 100% rename from Wavelet/frontend/entrypoint.sh rename to openflare-server/frontend/entrypoint.sh diff --git a/Wavelet/frontend/eslint.config.mjs b/openflare-server/frontend/eslint.config.mjs similarity index 100% rename from Wavelet/frontend/eslint.config.mjs rename to openflare-server/frontend/eslint.config.mjs diff --git a/Wavelet/frontend/hooks/use-auth-redirect.ts b/openflare-server/frontend/hooks/use-auth-redirect.ts similarity index 100% rename from Wavelet/frontend/hooks/use-auth-redirect.ts rename to openflare-server/frontend/hooks/use-auth-redirect.ts diff --git a/Wavelet/frontend/hooks/use-controlled-state.tsx b/openflare-server/frontend/hooks/use-controlled-state.tsx similarity index 100% rename from Wavelet/frontend/hooks/use-controlled-state.tsx rename to openflare-server/frontend/hooks/use-controlled-state.tsx diff --git a/Wavelet/frontend/hooks/use-is-in-view.tsx b/openflare-server/frontend/hooks/use-is-in-view.tsx similarity index 100% rename from Wavelet/frontend/hooks/use-is-in-view.tsx rename to openflare-server/frontend/hooks/use-is-in-view.tsx diff --git a/Wavelet/frontend/hooks/use-mobile.ts b/openflare-server/frontend/hooks/use-mobile.ts similarity index 100% rename from Wavelet/frontend/hooks/use-mobile.ts rename to openflare-server/frontend/hooks/use-mobile.ts diff --git a/Wavelet/frontend/hooks/use-public-config.ts b/openflare-server/frontend/hooks/use-public-config.ts similarity index 100% rename from Wavelet/frontend/hooks/use-public-config.ts rename to openflare-server/frontend/hooks/use-public-config.ts diff --git a/Wavelet/frontend/lib/app-info.ts b/openflare-server/frontend/lib/app-info.ts similarity index 100% rename from Wavelet/frontend/lib/app-info.ts rename to openflare-server/frontend/lib/app-info.ts diff --git a/Wavelet/frontend/lib/cap-solver.ts b/openflare-server/frontend/lib/cap-solver.ts similarity index 100% rename from Wavelet/frontend/lib/cap-solver.ts rename to openflare-server/frontend/lib/cap-solver.ts diff --git a/Wavelet/frontend/lib/hooks/use-openflare-server-upgrade.ts b/openflare-server/frontend/lib/hooks/use-openflare-server-upgrade.ts similarity index 100% rename from Wavelet/frontend/lib/hooks/use-openflare-server-upgrade.ts rename to openflare-server/frontend/lib/hooks/use-openflare-server-upgrade.ts diff --git a/Wavelet/frontend/lib/navigation/openflare-nav.ts b/openflare-server/frontend/lib/navigation/openflare-nav.ts similarity index 100% rename from Wavelet/frontend/lib/navigation/openflare-nav.ts rename to openflare-server/frontend/lib/navigation/openflare-nav.ts diff --git a/Wavelet/frontend/lib/services/admin/cache.service.ts b/openflare-server/frontend/lib/services/admin/cache.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/cache.service.ts rename to openflare-server/frontend/lib/services/admin/cache.service.ts diff --git a/Wavelet/frontend/lib/services/admin/index.ts b/openflare-server/frontend/lib/services/admin/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/index.ts rename to openflare-server/frontend/lib/services/admin/index.ts diff --git a/Wavelet/frontend/lib/services/admin/log.service.ts b/openflare-server/frontend/lib/services/admin/log.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/log.service.ts rename to openflare-server/frontend/lib/services/admin/log.service.ts diff --git a/Wavelet/frontend/lib/services/admin/status.service.ts b/openflare-server/frontend/lib/services/admin/status.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/status.service.ts rename to openflare-server/frontend/lib/services/admin/status.service.ts diff --git a/Wavelet/frontend/lib/services/admin/system-config.service.ts b/openflare-server/frontend/lib/services/admin/system-config.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/system-config.service.ts rename to openflare-server/frontend/lib/services/admin/system-config.service.ts diff --git a/Wavelet/frontend/lib/services/admin/task.service.ts b/openflare-server/frontend/lib/services/admin/task.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/task.service.ts rename to openflare-server/frontend/lib/services/admin/task.service.ts diff --git a/Wavelet/frontend/lib/services/admin/template.service.ts b/openflare-server/frontend/lib/services/admin/template.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/template.service.ts rename to openflare-server/frontend/lib/services/admin/template.service.ts diff --git a/Wavelet/frontend/lib/services/admin/types.ts b/openflare-server/frontend/lib/services/admin/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/types.ts rename to openflare-server/frontend/lib/services/admin/types.ts diff --git a/Wavelet/frontend/lib/services/admin/user.service.ts b/openflare-server/frontend/lib/services/admin/user.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/admin/user.service.ts rename to openflare-server/frontend/lib/services/admin/user.service.ts diff --git a/Wavelet/frontend/lib/services/auth/auth.service.ts b/openflare-server/frontend/lib/services/auth/auth.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/auth/auth.service.ts rename to openflare-server/frontend/lib/services/auth/auth.service.ts diff --git a/Wavelet/frontend/lib/services/auth/index.ts b/openflare-server/frontend/lib/services/auth/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/auth/index.ts rename to openflare-server/frontend/lib/services/auth/index.ts diff --git a/Wavelet/frontend/lib/services/auth/types.ts b/openflare-server/frontend/lib/services/auth/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/auth/types.ts rename to openflare-server/frontend/lib/services/auth/types.ts diff --git a/Wavelet/frontend/lib/services/config/config.service.ts b/openflare-server/frontend/lib/services/config/config.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/config/config.service.ts rename to openflare-server/frontend/lib/services/config/config.service.ts diff --git a/Wavelet/frontend/lib/services/config/index.ts b/openflare-server/frontend/lib/services/config/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/config/index.ts rename to openflare-server/frontend/lib/services/config/index.ts diff --git a/Wavelet/frontend/lib/services/config/types.ts b/openflare-server/frontend/lib/services/config/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/config/types.ts rename to openflare-server/frontend/lib/services/config/types.ts diff --git a/Wavelet/frontend/lib/services/core/api-client.ts b/openflare-server/frontend/lib/services/core/api-client.ts similarity index 100% rename from Wavelet/frontend/lib/services/core/api-client.ts rename to openflare-server/frontend/lib/services/core/api-client.ts diff --git a/Wavelet/frontend/lib/services/core/base.service.ts b/openflare-server/frontend/lib/services/core/base.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/core/base.service.ts rename to openflare-server/frontend/lib/services/core/base.service.ts diff --git a/Wavelet/frontend/lib/services/core/config.ts b/openflare-server/frontend/lib/services/core/config.ts similarity index 100% rename from Wavelet/frontend/lib/services/core/config.ts rename to openflare-server/frontend/lib/services/core/config.ts diff --git a/Wavelet/frontend/lib/services/core/errors.ts b/openflare-server/frontend/lib/services/core/errors.ts similarity index 100% rename from Wavelet/frontend/lib/services/core/errors.ts rename to openflare-server/frontend/lib/services/core/errors.ts diff --git a/Wavelet/frontend/lib/services/core/index.ts b/openflare-server/frontend/lib/services/core/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/core/index.ts rename to openflare-server/frontend/lib/services/core/index.ts diff --git a/Wavelet/frontend/lib/services/core/types.ts b/openflare-server/frontend/lib/services/core/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/core/types.ts rename to openflare-server/frontend/lib/services/core/types.ts diff --git a/Wavelet/frontend/lib/services/db-manage/db-manage.service.ts b/openflare-server/frontend/lib/services/db-manage/db-manage.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/db-manage/db-manage.service.ts rename to openflare-server/frontend/lib/services/db-manage/db-manage.service.ts diff --git a/Wavelet/frontend/lib/services/db-manage/index.ts b/openflare-server/frontend/lib/services/db-manage/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/db-manage/index.ts rename to openflare-server/frontend/lib/services/db-manage/index.ts diff --git a/Wavelet/frontend/lib/services/db-manage/types.ts b/openflare-server/frontend/lib/services/db-manage/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/db-manage/types.ts rename to openflare-server/frontend/lib/services/db-manage/types.ts diff --git a/Wavelet/frontend/lib/services/index.ts b/openflare-server/frontend/lib/services/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/index.ts rename to openflare-server/frontend/lib/services/index.ts diff --git a/Wavelet/frontend/lib/services/openflare/access-log.service.ts b/openflare-server/frontend/lib/services/openflare/access-log.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/access-log.service.ts rename to openflare-server/frontend/lib/services/openflare/access-log.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/apply-log.service.ts b/openflare-server/frontend/lib/services/openflare/apply-log.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/apply-log.service.ts rename to openflare-server/frontend/lib/services/openflare/apply-log.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/base.service.ts b/openflare-server/frontend/lib/services/openflare/base.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/base.service.ts rename to openflare-server/frontend/lib/services/openflare/base.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/config-version.service.ts b/openflare-server/frontend/lib/services/openflare/config-version.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/config-version.service.ts rename to openflare-server/frontend/lib/services/openflare/config-version.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/dashboard.service.ts b/openflare-server/frontend/lib/services/openflare/dashboard.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/dashboard.service.ts rename to openflare-server/frontend/lib/services/openflare/dashboard.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/dns-account.service.ts b/openflare-server/frontend/lib/services/openflare/dns-account.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/dns-account.service.ts rename to openflare-server/frontend/lib/services/openflare/dns-account.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/index.ts b/openflare-server/frontend/lib/services/openflare/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/index.ts rename to openflare-server/frontend/lib/services/openflare/index.ts diff --git a/Wavelet/frontend/lib/services/openflare/node.service.ts b/openflare-server/frontend/lib/services/openflare/node.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/node.service.ts rename to openflare-server/frontend/lib/services/openflare/node.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/option.service.ts b/openflare-server/frontend/lib/services/openflare/option.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/option.service.ts rename to openflare-server/frontend/lib/services/openflare/option.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/origin.service.ts b/openflare-server/frontend/lib/services/openflare/origin.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/origin.service.ts rename to openflare-server/frontend/lib/services/openflare/origin.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/pages.service.ts b/openflare-server/frontend/lib/services/openflare/pages.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/pages.service.ts rename to openflare-server/frontend/lib/services/openflare/pages.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/proxy-route.service.ts b/openflare-server/frontend/lib/services/openflare/proxy-route.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/proxy-route.service.ts rename to openflare-server/frontend/lib/services/openflare/proxy-route.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/status.service.ts b/openflare-server/frontend/lib/services/openflare/status.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/status.service.ts rename to openflare-server/frontend/lib/services/openflare/status.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/tls-certificate.service.ts b/openflare-server/frontend/lib/services/openflare/tls-certificate.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/tls-certificate.service.ts rename to openflare-server/frontend/lib/services/openflare/tls-certificate.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/types.ts b/openflare-server/frontend/lib/services/openflare/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/types.ts rename to openflare-server/frontend/lib/services/openflare/types.ts diff --git a/Wavelet/frontend/lib/services/openflare/uptimekuma.service.ts b/openflare-server/frontend/lib/services/openflare/uptimekuma.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/uptimekuma.service.ts rename to openflare-server/frontend/lib/services/openflare/uptimekuma.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/waf.service.ts b/openflare-server/frontend/lib/services/openflare/waf.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/waf.service.ts rename to openflare-server/frontend/lib/services/openflare/waf.service.ts diff --git a/Wavelet/frontend/lib/services/openflare/website.service.ts b/openflare-server/frontend/lib/services/openflare/website.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/openflare/website.service.ts rename to openflare-server/frontend/lib/services/openflare/website.service.ts diff --git a/Wavelet/frontend/lib/services/push/index.ts b/openflare-server/frontend/lib/services/push/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/push/index.ts rename to openflare-server/frontend/lib/services/push/index.ts diff --git a/Wavelet/frontend/lib/services/push/push.service.ts b/openflare-server/frontend/lib/services/push/push.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/push/push.service.ts rename to openflare-server/frontend/lib/services/push/push.service.ts diff --git a/Wavelet/frontend/lib/services/push/types.ts b/openflare-server/frontend/lib/services/push/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/push/types.ts rename to openflare-server/frontend/lib/services/push/types.ts diff --git a/Wavelet/frontend/lib/services/user/index.ts b/openflare-server/frontend/lib/services/user/index.ts similarity index 100% rename from Wavelet/frontend/lib/services/user/index.ts rename to openflare-server/frontend/lib/services/user/index.ts diff --git a/Wavelet/frontend/lib/services/user/types.ts b/openflare-server/frontend/lib/services/user/types.ts similarity index 100% rename from Wavelet/frontend/lib/services/user/types.ts rename to openflare-server/frontend/lib/services/user/types.ts diff --git a/Wavelet/frontend/lib/services/user/user.service.ts b/openflare-server/frontend/lib/services/user/user.service.ts similarity index 100% rename from Wavelet/frontend/lib/services/user/user.service.ts rename to openflare-server/frontend/lib/services/user/user.service.ts diff --git a/Wavelet/frontend/lib/task-param-utils.ts b/openflare-server/frontend/lib/task-param-utils.ts similarity index 100% rename from Wavelet/frontend/lib/task-param-utils.ts rename to openflare-server/frontend/lib/task-param-utils.ts diff --git a/Wavelet/frontend/lib/theme/config.ts b/openflare-server/frontend/lib/theme/config.ts similarity index 100% rename from Wavelet/frontend/lib/theme/config.ts rename to openflare-server/frontend/lib/theme/config.ts diff --git a/Wavelet/frontend/lib/theme/context.tsx b/openflare-server/frontend/lib/theme/context.tsx similarity index 100% rename from Wavelet/frontend/lib/theme/context.tsx rename to openflare-server/frontend/lib/theme/context.tsx diff --git a/Wavelet/frontend/lib/theme/index.ts b/openflare-server/frontend/lib/theme/index.ts similarity index 100% rename from Wavelet/frontend/lib/theme/index.ts rename to openflare-server/frontend/lib/theme/index.ts diff --git a/Wavelet/frontend/lib/theme/parser.ts b/openflare-server/frontend/lib/theme/parser.ts similarity index 100% rename from Wavelet/frontend/lib/theme/parser.ts rename to openflare-server/frontend/lib/theme/parser.ts diff --git a/Wavelet/frontend/lib/theme/storage.ts b/openflare-server/frontend/lib/theme/storage.ts similarity index 100% rename from Wavelet/frontend/lib/theme/storage.ts rename to openflare-server/frontend/lib/theme/storage.ts diff --git a/Wavelet/frontend/lib/theme/themes.json b/openflare-server/frontend/lib/theme/themes.json similarity index 100% rename from Wavelet/frontend/lib/theme/themes.json rename to openflare-server/frontend/lib/theme/themes.json diff --git a/Wavelet/frontend/lib/theme/types.ts b/openflare-server/frontend/lib/theme/types.ts similarity index 100% rename from Wavelet/frontend/lib/theme/types.ts rename to openflare-server/frontend/lib/theme/types.ts diff --git a/Wavelet/frontend/lib/utils.ts b/openflare-server/frontend/lib/utils.ts similarity index 100% rename from Wavelet/frontend/lib/utils.ts rename to openflare-server/frontend/lib/utils.ts diff --git a/Wavelet/frontend/lib/utils/error-handling.ts b/openflare-server/frontend/lib/utils/error-handling.ts similarity index 100% rename from Wavelet/frontend/lib/utils/error-handling.ts rename to openflare-server/frontend/lib/utils/error-handling.ts diff --git a/Wavelet/frontend/lib/utils/get-strict-context.tsx b/openflare-server/frontend/lib/utils/get-strict-context.tsx similarity index 100% rename from Wavelet/frontend/lib/utils/get-strict-context.tsx rename to openflare-server/frontend/lib/utils/get-strict-context.tsx diff --git a/Wavelet/frontend/lib/utils/metrics.ts b/openflare-server/frontend/lib/utils/metrics.ts similarity index 100% rename from Wavelet/frontend/lib/utils/metrics.ts rename to openflare-server/frontend/lib/utils/metrics.ts diff --git a/Wavelet/frontend/lib/utils/search-data.ts b/openflare-server/frontend/lib/utils/search-data.ts similarity index 100% rename from Wavelet/frontend/lib/utils/search-data.ts rename to openflare-server/frontend/lib/utils/search-data.ts diff --git a/Wavelet/frontend/next.config.ts b/openflare-server/frontend/next.config.ts similarity index 100% rename from Wavelet/frontend/next.config.ts rename to openflare-server/frontend/next.config.ts diff --git a/Wavelet/frontend/package.json b/openflare-server/frontend/package.json similarity index 100% rename from Wavelet/frontend/package.json rename to openflare-server/frontend/package.json diff --git a/Wavelet/frontend/pnpm-lock.yaml b/openflare-server/frontend/pnpm-lock.yaml similarity index 100% rename from Wavelet/frontend/pnpm-lock.yaml rename to openflare-server/frontend/pnpm-lock.yaml diff --git a/Wavelet/frontend/pnpm-workspace.yaml b/openflare-server/frontend/pnpm-workspace.yaml similarity index 100% rename from Wavelet/frontend/pnpm-workspace.yaml rename to openflare-server/frontend/pnpm-workspace.yaml diff --git a/Wavelet/frontend/postcss.config.mjs b/openflare-server/frontend/postcss.config.mjs similarity index 100% rename from Wavelet/frontend/postcss.config.mjs rename to openflare-server/frontend/postcss.config.mjs diff --git a/Wavelet/frontend/proxy.ts b/openflare-server/frontend/proxy.ts similarity index 100% rename from Wavelet/frontend/proxy.ts rename to openflare-server/frontend/proxy.ts diff --git a/Wavelet/frontend/public/icon.png b/openflare-server/frontend/public/icon.png similarity index 100% rename from Wavelet/frontend/public/icon.png rename to openflare-server/frontend/public/icon.png diff --git a/Wavelet/frontend/public/style/amethyst-haze.css b/openflare-server/frontend/public/style/amethyst-haze.css similarity index 100% rename from Wavelet/frontend/public/style/amethyst-haze.css rename to openflare-server/frontend/public/style/amethyst-haze.css diff --git a/Wavelet/frontend/public/style/bold-tech.css b/openflare-server/frontend/public/style/bold-tech.css similarity index 100% rename from Wavelet/frontend/public/style/bold-tech.css rename to openflare-server/frontend/public/style/bold-tech.css diff --git a/Wavelet/frontend/public/style/bubblegum.css b/openflare-server/frontend/public/style/bubblegum.css similarity index 100% rename from Wavelet/frontend/public/style/bubblegum.css rename to openflare-server/frontend/public/style/bubblegum.css diff --git a/Wavelet/frontend/public/style/caffeine.css b/openflare-server/frontend/public/style/caffeine.css similarity index 100% rename from Wavelet/frontend/public/style/caffeine.css rename to openflare-server/frontend/public/style/caffeine.css diff --git a/Wavelet/frontend/public/style/candyland.css b/openflare-server/frontend/public/style/candyland.css similarity index 100% rename from Wavelet/frontend/public/style/candyland.css rename to openflare-server/frontend/public/style/candyland.css diff --git a/Wavelet/frontend/public/style/catppuccin.css b/openflare-server/frontend/public/style/catppuccin.css similarity index 100% rename from Wavelet/frontend/public/style/catppuccin.css rename to openflare-server/frontend/public/style/catppuccin.css diff --git a/Wavelet/frontend/public/style/claude.css b/openflare-server/frontend/public/style/claude.css similarity index 100% rename from Wavelet/frontend/public/style/claude.css rename to openflare-server/frontend/public/style/claude.css diff --git a/Wavelet/frontend/public/style/claymorphism.css b/openflare-server/frontend/public/style/claymorphism.css similarity index 100% rename from Wavelet/frontend/public/style/claymorphism.css rename to openflare-server/frontend/public/style/claymorphism.css diff --git a/Wavelet/frontend/public/style/clean-slate.css b/openflare-server/frontend/public/style/clean-slate.css similarity index 100% rename from Wavelet/frontend/public/style/clean-slate.css rename to openflare-server/frontend/public/style/clean-slate.css diff --git a/Wavelet/frontend/public/style/cosmic-night.css b/openflare-server/frontend/public/style/cosmic-night.css similarity index 100% rename from Wavelet/frontend/public/style/cosmic-night.css rename to openflare-server/frontend/public/style/cosmic-night.css diff --git a/Wavelet/frontend/public/style/cyberpunk.css b/openflare-server/frontend/public/style/cyberpunk.css similarity index 100% rename from Wavelet/frontend/public/style/cyberpunk.css rename to openflare-server/frontend/public/style/cyberpunk.css diff --git a/Wavelet/frontend/public/style/darkmatter.css b/openflare-server/frontend/public/style/darkmatter.css similarity index 100% rename from Wavelet/frontend/public/style/darkmatter.css rename to openflare-server/frontend/public/style/darkmatter.css diff --git a/Wavelet/frontend/public/style/default.css b/openflare-server/frontend/public/style/default.css similarity index 100% rename from Wavelet/frontend/public/style/default.css rename to openflare-server/frontend/public/style/default.css diff --git a/Wavelet/frontend/public/style/doom-64.css b/openflare-server/frontend/public/style/doom-64.css similarity index 100% rename from Wavelet/frontend/public/style/doom-64.css rename to openflare-server/frontend/public/style/doom-64.css diff --git a/Wavelet/frontend/public/style/elegant-luxury.css b/openflare-server/frontend/public/style/elegant-luxury.css similarity index 100% rename from Wavelet/frontend/public/style/elegant-luxury.css rename to openflare-server/frontend/public/style/elegant-luxury.css diff --git a/Wavelet/frontend/public/style/graphite.css b/openflare-server/frontend/public/style/graphite.css similarity index 100% rename from Wavelet/frontend/public/style/graphite.css rename to openflare-server/frontend/public/style/graphite.css diff --git a/Wavelet/frontend/public/style/kodama-grove.css b/openflare-server/frontend/public/style/kodama-grove.css similarity index 100% rename from Wavelet/frontend/public/style/kodama-grove.css rename to openflare-server/frontend/public/style/kodama-grove.css diff --git a/Wavelet/frontend/public/style/midnight-bloom.css b/openflare-server/frontend/public/style/midnight-bloom.css similarity index 100% rename from Wavelet/frontend/public/style/midnight-bloom.css rename to openflare-server/frontend/public/style/midnight-bloom.css diff --git a/Wavelet/frontend/public/style/mocha-mousse.css b/openflare-server/frontend/public/style/mocha-mousse.css similarity index 100% rename from Wavelet/frontend/public/style/mocha-mousse.css rename to openflare-server/frontend/public/style/mocha-mousse.css diff --git a/Wavelet/frontend/public/style/nature.css b/openflare-server/frontend/public/style/nature.css similarity index 100% rename from Wavelet/frontend/public/style/nature.css rename to openflare-server/frontend/public/style/nature.css diff --git a/Wavelet/frontend/public/style/neo-brutalism.css b/openflare-server/frontend/public/style/neo-brutalism.css similarity index 100% rename from Wavelet/frontend/public/style/neo-brutalism.css rename to openflare-server/frontend/public/style/neo-brutalism.css diff --git a/Wavelet/frontend/public/style/northern-lights.css b/openflare-server/frontend/public/style/northern-lights.css similarity index 100% rename from Wavelet/frontend/public/style/northern-lights.css rename to openflare-server/frontend/public/style/northern-lights.css diff --git a/Wavelet/frontend/public/style/notebook.css b/openflare-server/frontend/public/style/notebook.css similarity index 100% rename from Wavelet/frontend/public/style/notebook.css rename to openflare-server/frontend/public/style/notebook.css diff --git a/Wavelet/frontend/public/style/ocean-breeze.css b/openflare-server/frontend/public/style/ocean-breeze.css similarity index 100% rename from Wavelet/frontend/public/style/ocean-breeze.css rename to openflare-server/frontend/public/style/ocean-breeze.css diff --git a/Wavelet/frontend/public/style/pastel-dreams.css b/openflare-server/frontend/public/style/pastel-dreams.css similarity index 100% rename from Wavelet/frontend/public/style/pastel-dreams.css rename to openflare-server/frontend/public/style/pastel-dreams.css diff --git a/Wavelet/frontend/public/style/perpetuity.css b/openflare-server/frontend/public/style/perpetuity.css similarity index 100% rename from Wavelet/frontend/public/style/perpetuity.css rename to openflare-server/frontend/public/style/perpetuity.css diff --git a/Wavelet/frontend/public/style/quantum-rose.css b/openflare-server/frontend/public/style/quantum-rose.css similarity index 100% rename from Wavelet/frontend/public/style/quantum-rose.css rename to openflare-server/frontend/public/style/quantum-rose.css diff --git a/Wavelet/frontend/public/style/retro-arcade.css b/openflare-server/frontend/public/style/retro-arcade.css similarity index 100% rename from Wavelet/frontend/public/style/retro-arcade.css rename to openflare-server/frontend/public/style/retro-arcade.css diff --git a/Wavelet/frontend/public/style/sage-garden.css b/openflare-server/frontend/public/style/sage-garden.css similarity index 100% rename from Wavelet/frontend/public/style/sage-garden.css rename to openflare-server/frontend/public/style/sage-garden.css diff --git a/Wavelet/frontend/public/style/soft-pop.css b/openflare-server/frontend/public/style/soft-pop.css similarity index 100% rename from Wavelet/frontend/public/style/soft-pop.css rename to openflare-server/frontend/public/style/soft-pop.css diff --git a/Wavelet/frontend/public/style/solar-dusk.css b/openflare-server/frontend/public/style/solar-dusk.css similarity index 100% rename from Wavelet/frontend/public/style/solar-dusk.css rename to openflare-server/frontend/public/style/solar-dusk.css diff --git a/Wavelet/frontend/public/style/starry-night.css b/openflare-server/frontend/public/style/starry-night.css similarity index 100% rename from Wavelet/frontend/public/style/starry-night.css rename to openflare-server/frontend/public/style/starry-night.css diff --git a/Wavelet/frontend/public/style/sunset-horizon.css b/openflare-server/frontend/public/style/sunset-horizon.css similarity index 100% rename from Wavelet/frontend/public/style/sunset-horizon.css rename to openflare-server/frontend/public/style/sunset-horizon.css diff --git a/Wavelet/frontend/public/style/supabase.css b/openflare-server/frontend/public/style/supabase.css similarity index 100% rename from Wavelet/frontend/public/style/supabase.css rename to openflare-server/frontend/public/style/supabase.css diff --git a/Wavelet/frontend/public/style/t3-chat.css b/openflare-server/frontend/public/style/t3-chat.css similarity index 100% rename from Wavelet/frontend/public/style/t3-chat.css rename to openflare-server/frontend/public/style/t3-chat.css diff --git a/Wavelet/frontend/public/style/tangerine.css b/openflare-server/frontend/public/style/tangerine.css similarity index 100% rename from Wavelet/frontend/public/style/tangerine.css rename to openflare-server/frontend/public/style/tangerine.css diff --git a/Wavelet/frontend/public/style/twitter.css b/openflare-server/frontend/public/style/twitter.css similarity index 100% rename from Wavelet/frontend/public/style/twitter.css rename to openflare-server/frontend/public/style/twitter.css diff --git a/Wavelet/frontend/public/style/vercel.css b/openflare-server/frontend/public/style/vercel.css similarity index 100% rename from Wavelet/frontend/public/style/vercel.css rename to openflare-server/frontend/public/style/vercel.css diff --git a/Wavelet/frontend/public/style/vintage-paper.css b/openflare-server/frontend/public/style/vintage-paper.css similarity index 100% rename from Wavelet/frontend/public/style/vintage-paper.css rename to openflare-server/frontend/public/style/vintage-paper.css diff --git a/Wavelet/frontend/public/style/violet-bloom.css b/openflare-server/frontend/public/style/violet-bloom.css similarity index 100% rename from Wavelet/frontend/public/style/violet-bloom.css rename to openflare-server/frontend/public/style/violet-bloom.css diff --git a/Wavelet/frontend/scripts/generate-themes.js b/openflare-server/frontend/scripts/generate-themes.js similarity index 100% rename from Wavelet/frontend/scripts/generate-themes.js rename to openflare-server/frontend/scripts/generate-themes.js diff --git a/Wavelet/frontend/tsconfig.json b/openflare-server/frontend/tsconfig.json similarity index 100% rename from Wavelet/frontend/tsconfig.json rename to openflare-server/frontend/tsconfig.json diff --git a/Wavelet/frontend/types/react-syntax-highlighter.d.ts b/openflare-server/frontend/types/react-syntax-highlighter.d.ts similarity index 100% rename from Wavelet/frontend/types/react-syntax-highlighter.d.ts rename to openflare-server/frontend/types/react-syntax-highlighter.d.ts diff --git a/Wavelet/go.mod b/openflare-server/go.mod similarity index 100% rename from Wavelet/go.mod rename to openflare-server/go.mod diff --git a/Wavelet/go.sum b/openflare-server/go.sum similarity index 100% rename from Wavelet/go.sum rename to openflare-server/go.sum diff --git a/Wavelet/internal/apps/admin/auth_source/routers.go b/openflare-server/internal/apps/admin/auth_source/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/auth_source/routers.go rename to openflare-server/internal/apps/admin/auth_source/routers.go diff --git a/Wavelet/internal/apps/admin/auth_source/routers_test.go b/openflare-server/internal/apps/admin/auth_source/routers_test.go similarity index 100% rename from Wavelet/internal/apps/admin/auth_source/routers_test.go rename to openflare-server/internal/apps/admin/auth_source/routers_test.go diff --git a/Wavelet/internal/apps/admin/cache/logics.go b/openflare-server/internal/apps/admin/cache/logics.go similarity index 100% rename from Wavelet/internal/apps/admin/cache/logics.go rename to openflare-server/internal/apps/admin/cache/logics.go diff --git a/Wavelet/internal/apps/admin/cache/routers.go b/openflare-server/internal/apps/admin/cache/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/cache/routers.go rename to openflare-server/internal/apps/admin/cache/routers.go diff --git a/Wavelet/internal/apps/admin/db_manage/routers.go b/openflare-server/internal/apps/admin/db_manage/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/db_manage/routers.go rename to openflare-server/internal/apps/admin/db_manage/routers.go diff --git a/Wavelet/internal/apps/admin/errs.go b/openflare-server/internal/apps/admin/errs.go similarity index 100% rename from Wavelet/internal/apps/admin/errs.go rename to openflare-server/internal/apps/admin/errs.go diff --git a/Wavelet/internal/apps/admin/middlewares.go b/openflare-server/internal/apps/admin/middlewares.go similarity index 100% rename from Wavelet/internal/apps/admin/middlewares.go rename to openflare-server/internal/apps/admin/middlewares.go diff --git a/Wavelet/internal/apps/admin/push/channels.go b/openflare-server/internal/apps/admin/push/channels.go similarity index 100% rename from Wavelet/internal/apps/admin/push/channels.go rename to openflare-server/internal/apps/admin/push/channels.go diff --git a/Wavelet/internal/apps/admin/push/channels_definition.go b/openflare-server/internal/apps/admin/push/channels_definition.go similarity index 100% rename from Wavelet/internal/apps/admin/push/channels_definition.go rename to openflare-server/internal/apps/admin/push/channels_definition.go diff --git a/Wavelet/internal/apps/admin/push/constants.go b/openflare-server/internal/apps/admin/push/constants.go similarity index 100% rename from Wavelet/internal/apps/admin/push/constants.go rename to openflare-server/internal/apps/admin/push/constants.go diff --git a/Wavelet/internal/apps/admin/push/custom_events/admin_login.go b/openflare-server/internal/apps/admin/push/custom_events/admin_login.go similarity index 100% rename from Wavelet/internal/apps/admin/push/custom_events/admin_login.go rename to openflare-server/internal/apps/admin/push/custom_events/admin_login.go diff --git a/Wavelet/internal/apps/admin/push/custom_events/admin_login_test.go b/openflare-server/internal/apps/admin/push/custom_events/admin_login_test.go similarity index 100% rename from Wavelet/internal/apps/admin/push/custom_events/admin_login_test.go rename to openflare-server/internal/apps/admin/push/custom_events/admin_login_test.go diff --git a/Wavelet/internal/apps/admin/push/custom_events/register.go b/openflare-server/internal/apps/admin/push/custom_events/register.go similarity index 100% rename from Wavelet/internal/apps/admin/push/custom_events/register.go rename to openflare-server/internal/apps/admin/push/custom_events/register.go diff --git a/Wavelet/internal/apps/admin/push/events.go b/openflare-server/internal/apps/admin/push/events.go similarity index 100% rename from Wavelet/internal/apps/admin/push/events.go rename to openflare-server/internal/apps/admin/push/events.go diff --git a/Wavelet/internal/apps/admin/push/logics.go b/openflare-server/internal/apps/admin/push/logics.go similarity index 100% rename from Wavelet/internal/apps/admin/push/logics.go rename to openflare-server/internal/apps/admin/push/logics.go diff --git a/Wavelet/internal/apps/admin/push/push_test.go b/openflare-server/internal/apps/admin/push/push_test.go similarity index 100% rename from Wavelet/internal/apps/admin/push/push_test.go rename to openflare-server/internal/apps/admin/push/push_test.go diff --git a/Wavelet/internal/apps/admin/push/routers.go b/openflare-server/internal/apps/admin/push/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/push/routers.go rename to openflare-server/internal/apps/admin/push/routers.go diff --git a/Wavelet/internal/apps/admin/push/task_listener.go b/openflare-server/internal/apps/admin/push/task_listener.go similarity index 100% rename from Wavelet/internal/apps/admin/push/task_listener.go rename to openflare-server/internal/apps/admin/push/task_listener.go diff --git a/Wavelet/internal/apps/admin/push/tasks.go b/openflare-server/internal/apps/admin/push/tasks.go similarity index 100% rename from Wavelet/internal/apps/admin/push/tasks.go rename to openflare-server/internal/apps/admin/push/tasks.go diff --git a/Wavelet/internal/apps/admin/status/routers.go b/openflare-server/internal/apps/admin/status/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/status/routers.go rename to openflare-server/internal/apps/admin/status/routers.go diff --git a/Wavelet/internal/apps/admin/system_config/errs.go b/openflare-server/internal/apps/admin/system_config/errs.go similarity index 100% rename from Wavelet/internal/apps/admin/system_config/errs.go rename to openflare-server/internal/apps/admin/system_config/errs.go diff --git a/Wavelet/internal/apps/admin/system_config/logics.go b/openflare-server/internal/apps/admin/system_config/logics.go similarity index 100% rename from Wavelet/internal/apps/admin/system_config/logics.go rename to openflare-server/internal/apps/admin/system_config/logics.go diff --git a/Wavelet/internal/apps/admin/system_config/routers.go b/openflare-server/internal/apps/admin/system_config/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/system_config/routers.go rename to openflare-server/internal/apps/admin/system_config/routers.go diff --git a/Wavelet/internal/apps/admin/system_config/routers_test.go b/openflare-server/internal/apps/admin/system_config/routers_test.go similarity index 100% rename from Wavelet/internal/apps/admin/system_config/routers_test.go rename to openflare-server/internal/apps/admin/system_config/routers_test.go diff --git a/Wavelet/internal/apps/admin/task/errs.go b/openflare-server/internal/apps/admin/task/errs.go similarity index 100% rename from Wavelet/internal/apps/admin/task/errs.go rename to openflare-server/internal/apps/admin/task/errs.go diff --git a/Wavelet/internal/apps/admin/task/routers.go b/openflare-server/internal/apps/admin/task/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/task/routers.go rename to openflare-server/internal/apps/admin/task/routers.go diff --git a/Wavelet/internal/apps/admin/task/routers_test.go b/openflare-server/internal/apps/admin/task/routers_test.go similarity index 100% rename from Wavelet/internal/apps/admin/task/routers_test.go rename to openflare-server/internal/apps/admin/task/routers_test.go diff --git a/Wavelet/internal/apps/admin/template/errs.go b/openflare-server/internal/apps/admin/template/errs.go similarity index 100% rename from Wavelet/internal/apps/admin/template/errs.go rename to openflare-server/internal/apps/admin/template/errs.go diff --git a/Wavelet/internal/apps/admin/template/logics.go b/openflare-server/internal/apps/admin/template/logics.go similarity index 100% rename from Wavelet/internal/apps/admin/template/logics.go rename to openflare-server/internal/apps/admin/template/logics.go diff --git a/Wavelet/internal/apps/admin/template/routers.go b/openflare-server/internal/apps/admin/template/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/template/routers.go rename to openflare-server/internal/apps/admin/template/routers.go diff --git a/Wavelet/internal/apps/admin/template/routers_test.go b/openflare-server/internal/apps/admin/template/routers_test.go similarity index 100% rename from Wavelet/internal/apps/admin/template/routers_test.go rename to openflare-server/internal/apps/admin/template/routers_test.go diff --git a/Wavelet/internal/apps/admin/updater/errs.go b/openflare-server/internal/apps/admin/updater/errs.go similarity index 100% rename from Wavelet/internal/apps/admin/updater/errs.go rename to openflare-server/internal/apps/admin/updater/errs.go diff --git a/Wavelet/internal/apps/admin/updater/export.go b/openflare-server/internal/apps/admin/updater/export.go similarity index 100% rename from Wavelet/internal/apps/admin/updater/export.go rename to openflare-server/internal/apps/admin/updater/export.go diff --git a/Wavelet/internal/apps/admin/updater/logics.go b/openflare-server/internal/apps/admin/updater/logics.go similarity index 100% rename from Wavelet/internal/apps/admin/updater/logics.go rename to openflare-server/internal/apps/admin/updater/logics.go diff --git a/Wavelet/internal/apps/admin/updater/logics_test.go b/openflare-server/internal/apps/admin/updater/logics_test.go similarity index 100% rename from Wavelet/internal/apps/admin/updater/logics_test.go rename to openflare-server/internal/apps/admin/updater/logics_test.go diff --git a/Wavelet/internal/apps/admin/updater/restart_unix.go b/openflare-server/internal/apps/admin/updater/restart_unix.go similarity index 100% rename from Wavelet/internal/apps/admin/updater/restart_unix.go rename to openflare-server/internal/apps/admin/updater/restart_unix.go diff --git a/Wavelet/internal/apps/admin/updater/restart_windows.go b/openflare-server/internal/apps/admin/updater/restart_windows.go similarity index 100% rename from Wavelet/internal/apps/admin/updater/restart_windows.go rename to openflare-server/internal/apps/admin/updater/restart_windows.go diff --git a/Wavelet/internal/apps/admin/updater/routers.go b/openflare-server/internal/apps/admin/updater/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/updater/routers.go rename to openflare-server/internal/apps/admin/updater/routers.go diff --git a/Wavelet/internal/apps/admin/user/errs.go b/openflare-server/internal/apps/admin/user/errs.go similarity index 100% rename from Wavelet/internal/apps/admin/user/errs.go rename to openflare-server/internal/apps/admin/user/errs.go diff --git a/Wavelet/internal/apps/admin/user/logics.go b/openflare-server/internal/apps/admin/user/logics.go similarity index 100% rename from Wavelet/internal/apps/admin/user/logics.go rename to openflare-server/internal/apps/admin/user/logics.go diff --git a/Wavelet/internal/apps/admin/user/routers.go b/openflare-server/internal/apps/admin/user/routers.go similarity index 100% rename from Wavelet/internal/apps/admin/user/routers.go rename to openflare-server/internal/apps/admin/user/routers.go diff --git a/Wavelet/internal/apps/admin/user/routers_test.go b/openflare-server/internal/apps/admin/user/routers_test.go similarity index 100% rename from Wavelet/internal/apps/admin/user/routers_test.go rename to openflare-server/internal/apps/admin/user/routers_test.go diff --git a/Wavelet/internal/apps/cap/errs.go b/openflare-server/internal/apps/cap/errs.go similarity index 100% rename from Wavelet/internal/apps/cap/errs.go rename to openflare-server/internal/apps/cap/errs.go diff --git a/Wavelet/internal/apps/cap/manager.go b/openflare-server/internal/apps/cap/manager.go similarity index 100% rename from Wavelet/internal/apps/cap/manager.go rename to openflare-server/internal/apps/cap/manager.go diff --git a/Wavelet/internal/apps/cap/manager_test.go b/openflare-server/internal/apps/cap/manager_test.go similarity index 100% rename from Wavelet/internal/apps/cap/manager_test.go rename to openflare-server/internal/apps/cap/manager_test.go diff --git a/Wavelet/internal/apps/cap/middleware.go b/openflare-server/internal/apps/cap/middleware.go similarity index 100% rename from Wavelet/internal/apps/cap/middleware.go rename to openflare-server/internal/apps/cap/middleware.go diff --git a/Wavelet/internal/apps/cap/routers.go b/openflare-server/internal/apps/cap/routers.go similarity index 100% rename from Wavelet/internal/apps/cap/routers.go rename to openflare-server/internal/apps/cap/routers.go diff --git a/Wavelet/internal/apps/cap/routers_test.go b/openflare-server/internal/apps/cap/routers_test.go similarity index 100% rename from Wavelet/internal/apps/cap/routers_test.go rename to openflare-server/internal/apps/cap/routers_test.go diff --git a/Wavelet/internal/apps/cap/runtime_settings.go b/openflare-server/internal/apps/cap/runtime_settings.go similarity index 100% rename from Wavelet/internal/apps/cap/runtime_settings.go rename to openflare-server/internal/apps/cap/runtime_settings.go diff --git a/Wavelet/internal/apps/cap/runtime_settings_test.go b/openflare-server/internal/apps/cap/runtime_settings_test.go similarity index 100% rename from Wavelet/internal/apps/cap/runtime_settings_test.go rename to openflare-server/internal/apps/cap/runtime_settings_test.go diff --git a/Wavelet/internal/apps/cap/testhelper_hook.go b/openflare-server/internal/apps/cap/testhelper_hook.go similarity index 100% rename from Wavelet/internal/apps/cap/testhelper_hook.go rename to openflare-server/internal/apps/cap/testhelper_hook.go diff --git a/Wavelet/internal/apps/config/public_config_cache_test.go b/openflare-server/internal/apps/config/public_config_cache_test.go similarity index 100% rename from Wavelet/internal/apps/config/public_config_cache_test.go rename to openflare-server/internal/apps/config/public_config_cache_test.go diff --git a/Wavelet/internal/apps/config/routers.go b/openflare-server/internal/apps/config/routers.go similarity index 100% rename from Wavelet/internal/apps/config/routers.go rename to openflare-server/internal/apps/config/routers.go diff --git a/Wavelet/internal/apps/config/routers_test.go b/openflare-server/internal/apps/config/routers_test.go similarity index 100% rename from Wavelet/internal/apps/config/routers_test.go rename to openflare-server/internal/apps/config/routers_test.go diff --git a/Wavelet/internal/apps/config/system_config_cache_test.go b/openflare-server/internal/apps/config/system_config_cache_test.go similarity index 100% rename from Wavelet/internal/apps/config/system_config_cache_test.go rename to openflare-server/internal/apps/config/system_config_cache_test.go diff --git a/Wavelet/internal/apps/custom/routers.go b/openflare-server/internal/apps/custom/routers.go similarity index 100% rename from Wavelet/internal/apps/custom/routers.go rename to openflare-server/internal/apps/custom/routers.go diff --git a/Wavelet/internal/apps/health/routers.go b/openflare-server/internal/apps/health/routers.go similarity index 100% rename from Wavelet/internal/apps/health/routers.go rename to openflare-server/internal/apps/health/routers.go diff --git a/Wavelet/internal/apps/oauth/audit.go b/openflare-server/internal/apps/oauth/audit.go similarity index 100% rename from Wavelet/internal/apps/oauth/audit.go rename to openflare-server/internal/apps/oauth/audit.go diff --git a/Wavelet/internal/apps/oauth/auth_source_resolver.go b/openflare-server/internal/apps/oauth/auth_source_resolver.go similarity index 100% rename from Wavelet/internal/apps/oauth/auth_source_resolver.go rename to openflare-server/internal/apps/oauth/auth_source_resolver.go diff --git a/Wavelet/internal/apps/oauth/constants.go b/openflare-server/internal/apps/oauth/constants.go similarity index 100% rename from Wavelet/internal/apps/oauth/constants.go rename to openflare-server/internal/apps/oauth/constants.go diff --git a/Wavelet/internal/apps/oauth/errs.go b/openflare-server/internal/apps/oauth/errs.go similarity index 100% rename from Wavelet/internal/apps/oauth/errs.go rename to openflare-server/internal/apps/oauth/errs.go diff --git a/Wavelet/internal/apps/oauth/gin_context.go b/openflare-server/internal/apps/oauth/gin_context.go similarity index 100% rename from Wavelet/internal/apps/oauth/gin_context.go rename to openflare-server/internal/apps/oauth/gin_context.go diff --git a/Wavelet/internal/apps/oauth/handler_authorize.go b/openflare-server/internal/apps/oauth/handler_authorize.go similarity index 100% rename from Wavelet/internal/apps/oauth/handler_authorize.go rename to openflare-server/internal/apps/oauth/handler_authorize.go diff --git a/Wavelet/internal/apps/oauth/handler_callback.go b/openflare-server/internal/apps/oauth/handler_callback.go similarity index 100% rename from Wavelet/internal/apps/oauth/handler_callback.go rename to openflare-server/internal/apps/oauth/handler_callback.go diff --git a/Wavelet/internal/apps/oauth/handler_external_accounts.go b/openflare-server/internal/apps/oauth/handler_external_accounts.go similarity index 100% rename from Wavelet/internal/apps/oauth/handler_external_accounts.go rename to openflare-server/internal/apps/oauth/handler_external_accounts.go diff --git a/Wavelet/internal/apps/oauth/handler_sources.go b/openflare-server/internal/apps/oauth/handler_sources.go similarity index 100% rename from Wavelet/internal/apps/oauth/handler_sources.go rename to openflare-server/internal/apps/oauth/handler_sources.go diff --git a/Wavelet/internal/apps/oauth/middlewares.go b/openflare-server/internal/apps/oauth/middlewares.go similarity index 100% rename from Wavelet/internal/apps/oauth/middlewares.go rename to openflare-server/internal/apps/oauth/middlewares.go diff --git a/Wavelet/internal/apps/oauth/oauth_test.go b/openflare-server/internal/apps/oauth/oauth_test.go similarity index 100% rename from Wavelet/internal/apps/oauth/oauth_test.go rename to openflare-server/internal/apps/oauth/oauth_test.go diff --git a/Wavelet/internal/apps/oauth/oauth_types.go b/openflare-server/internal/apps/oauth/oauth_types.go similarity index 100% rename from Wavelet/internal/apps/oauth/oauth_types.go rename to openflare-server/internal/apps/oauth/oauth_types.go diff --git a/Wavelet/internal/apps/oauth/oauth_userinfo.go b/openflare-server/internal/apps/oauth/oauth_userinfo.go similarity index 100% rename from Wavelet/internal/apps/oauth/oauth_userinfo.go rename to openflare-server/internal/apps/oauth/oauth_userinfo.go diff --git a/Wavelet/internal/apps/oauth/provider_cache.go b/openflare-server/internal/apps/oauth/provider_cache.go similarity index 100% rename from Wavelet/internal/apps/oauth/provider_cache.go rename to openflare-server/internal/apps/oauth/provider_cache.go diff --git a/Wavelet/internal/apps/oauth/routers.go b/openflare-server/internal/apps/oauth/routers.go similarity index 100% rename from Wavelet/internal/apps/oauth/routers.go rename to openflare-server/internal/apps/oauth/routers.go diff --git a/Wavelet/internal/apps/oauth/session.go b/openflare-server/internal/apps/oauth/session.go similarity index 100% rename from Wavelet/internal/apps/oauth/session.go rename to openflare-server/internal/apps/oauth/session.go diff --git a/Wavelet/internal/apps/oauth/session_context.go b/openflare-server/internal/apps/oauth/session_context.go similarity index 100% rename from Wavelet/internal/apps/oauth/session_context.go rename to openflare-server/internal/apps/oauth/session_context.go diff --git a/Wavelet/internal/apps/openflare/agent/access_log_region.go b/openflare-server/internal/apps/openflare/agent/access_log_region.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/access_log_region.go rename to openflare-server/internal/apps/openflare/agent/access_log_region.go diff --git a/Wavelet/internal/apps/openflare/agent/auth_cache.go b/openflare-server/internal/apps/openflare/agent/auth_cache.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/auth_cache.go rename to openflare-server/internal/apps/openflare/agent/auth_cache.go diff --git a/Wavelet/internal/apps/openflare/agent/config.go b/openflare-server/internal/apps/openflare/agent/config.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/config.go rename to openflare-server/internal/apps/openflare/agent/config.go diff --git a/Wavelet/internal/apps/openflare/agent/config_test.go b/openflare-server/internal/apps/openflare/agent/config_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/config_test.go rename to openflare-server/internal/apps/openflare/agent/config_test.go diff --git a/Wavelet/internal/apps/openflare/agent/errs.go b/openflare-server/internal/apps/openflare/agent/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/errs.go rename to openflare-server/internal/apps/openflare/agent/errs.go diff --git a/Wavelet/internal/apps/openflare/agent/helpers.go b/openflare-server/internal/apps/openflare/agent/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/helpers.go rename to openflare-server/internal/apps/openflare/agent/helpers.go diff --git a/Wavelet/internal/apps/openflare/agent/helpers_test.go b/openflare-server/internal/apps/openflare/agent/helpers_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/helpers_test.go rename to openflare-server/internal/apps/openflare/agent/helpers_test.go diff --git a/Wavelet/internal/apps/openflare/agent/logics.go b/openflare-server/internal/apps/openflare/agent/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/logics.go rename to openflare-server/internal/apps/openflare/agent/logics.go diff --git a/Wavelet/internal/apps/openflare/agent/middleware.go b/openflare-server/internal/apps/openflare/agent/middleware.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/middleware.go rename to openflare-server/internal/apps/openflare/agent/middleware.go diff --git a/Wavelet/internal/apps/openflare/agent/middleware_test.go b/openflare-server/internal/apps/openflare/agent/middleware_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/middleware_test.go rename to openflare-server/internal/apps/openflare/agent/middleware_test.go diff --git a/Wavelet/internal/apps/openflare/agent/observability.go b/openflare-server/internal/apps/openflare/agent/observability.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/observability.go rename to openflare-server/internal/apps/openflare/agent/observability.go diff --git a/Wavelet/internal/apps/openflare/agent/routers.go b/openflare-server/internal/apps/openflare/agent/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/routers.go rename to openflare-server/internal/apps/openflare/agent/routers.go diff --git a/Wavelet/internal/apps/openflare/agent/types.go b/openflare-server/internal/apps/openflare/agent/types.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/types.go rename to openflare-server/internal/apps/openflare/agent/types.go diff --git a/Wavelet/internal/apps/openflare/agent/waf_ip_group.go b/openflare-server/internal/apps/openflare/agent/waf_ip_group.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/waf_ip_group.go rename to openflare-server/internal/apps/openflare/agent/waf_ip_group.go diff --git a/Wavelet/internal/apps/openflare/agent/waf_ip_group_test.go b/openflare-server/internal/apps/openflare/agent/waf_ip_group_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/waf_ip_group_test.go rename to openflare-server/internal/apps/openflare/agent/waf_ip_group_test.go diff --git a/Wavelet/internal/apps/openflare/agent/ws_status.go b/openflare-server/internal/apps/openflare/agent/ws_status.go similarity index 100% rename from Wavelet/internal/apps/openflare/agent/ws_status.go rename to openflare-server/internal/apps/openflare/agent/ws_status.go diff --git a/Wavelet/internal/apps/openflare/apiutil/bind.go b/openflare-server/internal/apps/openflare/apiutil/bind.go similarity index 100% rename from Wavelet/internal/apps/openflare/apiutil/bind.go rename to openflare-server/internal/apps/openflare/apiutil/bind.go diff --git a/Wavelet/internal/apps/openflare/apiutil/errors.go b/openflare-server/internal/apps/openflare/apiutil/errors.go similarity index 100% rename from Wavelet/internal/apps/openflare/apiutil/errors.go rename to openflare-server/internal/apps/openflare/apiutil/errors.go diff --git a/Wavelet/internal/apps/openflare/apiutil/middleware.go b/openflare-server/internal/apps/openflare/apiutil/middleware.go similarity index 100% rename from Wavelet/internal/apps/openflare/apiutil/middleware.go rename to openflare-server/internal/apps/openflare/apiutil/middleware.go diff --git a/Wavelet/internal/apps/openflare/apiutil/middleware_test.go b/openflare-server/internal/apps/openflare/apiutil/middleware_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/apiutil/middleware_test.go rename to openflare-server/internal/apps/openflare/apiutil/middleware_test.go diff --git a/Wavelet/internal/apps/openflare/apiutil/routes.go b/openflare-server/internal/apps/openflare/apiutil/routes.go similarity index 100% rename from Wavelet/internal/apps/openflare/apiutil/routes.go rename to openflare-server/internal/apps/openflare/apiutil/routes.go diff --git a/Wavelet/internal/apps/openflare/apply_log/errs.go b/openflare-server/internal/apps/openflare/apply_log/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/apply_log/errs.go rename to openflare-server/internal/apps/openflare/apply_log/errs.go diff --git a/Wavelet/internal/apps/openflare/apply_log/logics.go b/openflare-server/internal/apps/openflare/apply_log/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/apply_log/logics.go rename to openflare-server/internal/apps/openflare/apply_log/logics.go diff --git a/Wavelet/internal/apps/openflare/apply_log/logics_test.go b/openflare-server/internal/apps/openflare/apply_log/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/apply_log/logics_test.go rename to openflare-server/internal/apps/openflare/apply_log/logics_test.go diff --git a/Wavelet/internal/apps/openflare/apply_log/routers.go b/openflare-server/internal/apps/openflare/apply_log/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/apply_log/routers.go rename to openflare-server/internal/apps/openflare/apply_log/routers.go diff --git a/Wavelet/internal/apps/openflare/async_tasks.go b/openflare-server/internal/apps/openflare/async_tasks.go similarity index 100% rename from Wavelet/internal/apps/openflare/async_tasks.go rename to openflare-server/internal/apps/openflare/async_tasks.go diff --git a/Wavelet/internal/apps/openflare/async_tasks_test.go b/openflare-server/internal/apps/openflare/async_tasks_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/async_tasks_test.go rename to openflare-server/internal/apps/openflare/async_tasks_test.go diff --git a/Wavelet/internal/apps/openflare/config_version/errs.go b/openflare-server/internal/apps/openflare/config_version/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/config_version/errs.go rename to openflare-server/internal/apps/openflare/config_version/errs.go diff --git a/Wavelet/internal/apps/openflare/config_version/helpers.go b/openflare-server/internal/apps/openflare/config_version/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/config_version/helpers.go rename to openflare-server/internal/apps/openflare/config_version/helpers.go diff --git a/Wavelet/internal/apps/openflare/config_version/logics.go b/openflare-server/internal/apps/openflare/config_version/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/config_version/logics.go rename to openflare-server/internal/apps/openflare/config_version/logics.go diff --git a/Wavelet/internal/apps/openflare/config_version/logics_test.go b/openflare-server/internal/apps/openflare/config_version/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/config_version/logics_test.go rename to openflare-server/internal/apps/openflare/config_version/logics_test.go diff --git a/Wavelet/internal/apps/openflare/config_version/renderer.go b/openflare-server/internal/apps/openflare/config_version/renderer.go similarity index 100% rename from Wavelet/internal/apps/openflare/config_version/renderer.go rename to openflare-server/internal/apps/openflare/config_version/renderer.go diff --git a/Wavelet/internal/apps/openflare/config_version/routers.go b/openflare-server/internal/apps/openflare/config_version/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/config_version/routers.go rename to openflare-server/internal/apps/openflare/config_version/routers.go diff --git a/Wavelet/internal/apps/openflare/config_version/snapshot.go b/openflare-server/internal/apps/openflare/config_version/snapshot.go similarity index 100% rename from Wavelet/internal/apps/openflare/config_version/snapshot.go rename to openflare-server/internal/apps/openflare/config_version/snapshot.go diff --git a/Wavelet/internal/apps/openflare/dashboard/helpers.go b/openflare-server/internal/apps/openflare/dashboard/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/dashboard/helpers.go rename to openflare-server/internal/apps/openflare/dashboard/helpers.go diff --git a/Wavelet/internal/apps/openflare/dashboard/logics.go b/openflare-server/internal/apps/openflare/dashboard/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/dashboard/logics.go rename to openflare-server/internal/apps/openflare/dashboard/logics.go diff --git a/Wavelet/internal/apps/openflare/dashboard/logics_test.go b/openflare-server/internal/apps/openflare/dashboard/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/dashboard/logics_test.go rename to openflare-server/internal/apps/openflare/dashboard/logics_test.go diff --git a/Wavelet/internal/apps/openflare/dashboard/routers.go b/openflare-server/internal/apps/openflare/dashboard/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/dashboard/routers.go rename to openflare-server/internal/apps/openflare/dashboard/routers.go diff --git a/Wavelet/internal/apps/openflare/flared/errs.go b/openflare-server/internal/apps/openflare/flared/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/errs.go rename to openflare-server/internal/apps/openflare/flared/errs.go diff --git a/Wavelet/internal/apps/openflare/flared/helpers.go b/openflare-server/internal/apps/openflare/flared/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/helpers.go rename to openflare-server/internal/apps/openflare/flared/helpers.go diff --git a/Wavelet/internal/apps/openflare/flared/logics.go b/openflare-server/internal/apps/openflare/flared/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/logics.go rename to openflare-server/internal/apps/openflare/flared/logics.go diff --git a/Wavelet/internal/apps/openflare/flared/middleware.go b/openflare-server/internal/apps/openflare/flared/middleware.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/middleware.go rename to openflare-server/internal/apps/openflare/flared/middleware.go diff --git a/Wavelet/internal/apps/openflare/flared/middleware_test.go b/openflare-server/internal/apps/openflare/flared/middleware_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/middleware_test.go rename to openflare-server/internal/apps/openflare/flared/middleware_test.go diff --git a/Wavelet/internal/apps/openflare/flared/observability.go b/openflare-server/internal/apps/openflare/flared/observability.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/observability.go rename to openflare-server/internal/apps/openflare/flared/observability.go diff --git a/Wavelet/internal/apps/openflare/flared/observability_test.go b/openflare-server/internal/apps/openflare/flared/observability_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/observability_test.go rename to openflare-server/internal/apps/openflare/flared/observability_test.go diff --git a/Wavelet/internal/apps/openflare/flared/routers.go b/openflare-server/internal/apps/openflare/flared/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/flared/routers.go rename to openflare-server/internal/apps/openflare/flared/routers.go diff --git a/Wavelet/internal/apps/openflare/geoip/lookup.go b/openflare-server/internal/apps/openflare/geoip/lookup.go similarity index 100% rename from Wavelet/internal/apps/openflare/geoip/lookup.go rename to openflare-server/internal/apps/openflare/geoip/lookup.go diff --git a/Wavelet/internal/apps/openflare/geoip/lookup_test.go b/openflare-server/internal/apps/openflare/geoip/lookup_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/geoip/lookup_test.go rename to openflare-server/internal/apps/openflare/geoip/lookup_test.go diff --git a/Wavelet/internal/apps/openflare/integration/agent_protocol_test.go b/openflare-server/internal/apps/openflare/integration/agent_protocol_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/integration/agent_protocol_test.go rename to openflare-server/internal/apps/openflare/integration/agent_protocol_test.go diff --git a/Wavelet/internal/apps/openflare/integration/auth_option_test.go b/openflare-server/internal/apps/openflare/integration/auth_option_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/integration/auth_option_test.go rename to openflare-server/internal/apps/openflare/integration/auth_option_test.go diff --git a/Wavelet/internal/apps/openflare/integration/core_chain_test.go b/openflare-server/internal/apps/openflare/integration/core_chain_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/integration/core_chain_test.go rename to openflare-server/internal/apps/openflare/integration/core_chain_test.go diff --git a/Wavelet/internal/apps/openflare/integration/helpers_test.go b/openflare-server/internal/apps/openflare/integration/helpers_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/integration/helpers_test.go rename to openflare-server/internal/apps/openflare/integration/helpers_test.go diff --git a/Wavelet/internal/apps/openflare/integration/security_test.go b/openflare-server/internal/apps/openflare/integration/security_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/integration/security_test.go rename to openflare-server/internal/apps/openflare/integration/security_test.go diff --git a/Wavelet/internal/apps/openflare/node/errs.go b/openflare-server/internal/apps/openflare/node/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/node/errs.go rename to openflare-server/internal/apps/openflare/node/errs.go diff --git a/Wavelet/internal/apps/openflare/node/helpers.go b/openflare-server/internal/apps/openflare/node/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/node/helpers.go rename to openflare-server/internal/apps/openflare/node/helpers.go diff --git a/Wavelet/internal/apps/openflare/node/logics.go b/openflare-server/internal/apps/openflare/node/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/node/logics.go rename to openflare-server/internal/apps/openflare/node/logics.go diff --git a/Wavelet/internal/apps/openflare/node/logics_test.go b/openflare-server/internal/apps/openflare/node/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/node/logics_test.go rename to openflare-server/internal/apps/openflare/node/logics_test.go diff --git a/Wavelet/internal/apps/openflare/node/routers.go b/openflare-server/internal/apps/openflare/node/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/node/routers.go rename to openflare-server/internal/apps/openflare/node/routers.go diff --git a/Wavelet/internal/apps/openflare/observability/access_log_logics.go b/openflare-server/internal/apps/openflare/observability/access_log_logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/observability/access_log_logics.go rename to openflare-server/internal/apps/openflare/observability/access_log_logics.go diff --git a/Wavelet/internal/apps/openflare/observability/analytics.go b/openflare-server/internal/apps/openflare/observability/analytics.go similarity index 100% rename from Wavelet/internal/apps/openflare/observability/analytics.go rename to openflare-server/internal/apps/openflare/observability/analytics.go diff --git a/Wavelet/internal/apps/openflare/observability/node_logics.go b/openflare-server/internal/apps/openflare/observability/node_logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/observability/node_logics.go rename to openflare-server/internal/apps/openflare/observability/node_logics.go diff --git a/Wavelet/internal/apps/openflare/observability/routers.go b/openflare-server/internal/apps/openflare/observability/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/observability/routers.go rename to openflare-server/internal/apps/openflare/observability/routers.go diff --git a/Wavelet/internal/apps/openflare/option/errs.go b/openflare-server/internal/apps/openflare/option/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/option/errs.go rename to openflare-server/internal/apps/openflare/option/errs.go diff --git a/Wavelet/internal/apps/openflare/option/logics.go b/openflare-server/internal/apps/openflare/option/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/option/logics.go rename to openflare-server/internal/apps/openflare/option/logics.go diff --git a/Wavelet/internal/apps/openflare/option/logics_test.go b/openflare-server/internal/apps/openflare/option/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/option/logics_test.go rename to openflare-server/internal/apps/openflare/option/logics_test.go diff --git a/Wavelet/internal/apps/openflare/option/routers.go b/openflare-server/internal/apps/openflare/option/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/option/routers.go rename to openflare-server/internal/apps/openflare/option/routers.go diff --git a/Wavelet/internal/apps/openflare/option/validate.go b/openflare-server/internal/apps/openflare/option/validate.go similarity index 100% rename from Wavelet/internal/apps/openflare/option/validate.go rename to openflare-server/internal/apps/openflare/option/validate.go diff --git a/Wavelet/internal/apps/openflare/origin/errs.go b/openflare-server/internal/apps/openflare/origin/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/origin/errs.go rename to openflare-server/internal/apps/openflare/origin/errs.go diff --git a/Wavelet/internal/apps/openflare/origin/helpers.go b/openflare-server/internal/apps/openflare/origin/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/origin/helpers.go rename to openflare-server/internal/apps/openflare/origin/helpers.go diff --git a/Wavelet/internal/apps/openflare/origin/logics.go b/openflare-server/internal/apps/openflare/origin/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/origin/logics.go rename to openflare-server/internal/apps/openflare/origin/logics.go diff --git a/Wavelet/internal/apps/openflare/origin/logics_test.go b/openflare-server/internal/apps/openflare/origin/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/origin/logics_test.go rename to openflare-server/internal/apps/openflare/origin/logics_test.go diff --git a/Wavelet/internal/apps/openflare/origin/routers.go b/openflare-server/internal/apps/openflare/origin/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/origin/routers.go rename to openflare-server/internal/apps/openflare/origin/routers.go diff --git a/Wavelet/internal/apps/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip b/openflare-server/internal/apps/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip similarity index 100% rename from Wavelet/internal/apps/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip rename to openflare-server/internal/apps/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip diff --git a/Wavelet/internal/apps/openflare/pages/errs.go b/openflare-server/internal/apps/openflare/pages/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/pages/errs.go rename to openflare-server/internal/apps/openflare/pages/errs.go diff --git a/Wavelet/internal/apps/openflare/pages/helpers.go b/openflare-server/internal/apps/openflare/pages/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/pages/helpers.go rename to openflare-server/internal/apps/openflare/pages/helpers.go diff --git a/Wavelet/internal/apps/openflare/pages/logics.go b/openflare-server/internal/apps/openflare/pages/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/pages/logics.go rename to openflare-server/internal/apps/openflare/pages/logics.go diff --git a/Wavelet/internal/apps/openflare/pages/logics_test.go b/openflare-server/internal/apps/openflare/pages/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/pages/logics_test.go rename to openflare-server/internal/apps/openflare/pages/logics_test.go diff --git a/Wavelet/internal/apps/openflare/pages/routers.go b/openflare-server/internal/apps/openflare/pages/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/pages/routers.go rename to openflare-server/internal/apps/openflare/pages/routers.go diff --git a/Wavelet/internal/apps/openflare/proxy_route/errs.go b/openflare-server/internal/apps/openflare/proxy_route/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/proxy_route/errs.go rename to openflare-server/internal/apps/openflare/proxy_route/errs.go diff --git a/Wavelet/internal/apps/openflare/proxy_route/helpers.go b/openflare-server/internal/apps/openflare/proxy_route/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/proxy_route/helpers.go rename to openflare-server/internal/apps/openflare/proxy_route/helpers.go diff --git a/Wavelet/internal/apps/openflare/proxy_route/logics.go b/openflare-server/internal/apps/openflare/proxy_route/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/proxy_route/logics.go rename to openflare-server/internal/apps/openflare/proxy_route/logics.go diff --git a/Wavelet/internal/apps/openflare/proxy_route/logics_test.go b/openflare-server/internal/apps/openflare/proxy_route/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/proxy_route/logics_test.go rename to openflare-server/internal/apps/openflare/proxy_route/logics_test.go diff --git a/Wavelet/internal/apps/openflare/proxy_route/routers.go b/openflare-server/internal/apps/openflare/proxy_route/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/proxy_route/routers.go rename to openflare-server/internal/apps/openflare/proxy_route/routers.go diff --git a/Wavelet/internal/apps/openflare/relay/errs.go b/openflare-server/internal/apps/openflare/relay/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/errs.go rename to openflare-server/internal/apps/openflare/relay/errs.go diff --git a/Wavelet/internal/apps/openflare/relay/helpers.go b/openflare-server/internal/apps/openflare/relay/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/helpers.go rename to openflare-server/internal/apps/openflare/relay/helpers.go diff --git a/Wavelet/internal/apps/openflare/relay/logics.go b/openflare-server/internal/apps/openflare/relay/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/logics.go rename to openflare-server/internal/apps/openflare/relay/logics.go diff --git a/Wavelet/internal/apps/openflare/relay/logics_test.go b/openflare-server/internal/apps/openflare/relay/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/logics_test.go rename to openflare-server/internal/apps/openflare/relay/logics_test.go diff --git a/Wavelet/internal/apps/openflare/relay/middleware.go b/openflare-server/internal/apps/openflare/relay/middleware.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/middleware.go rename to openflare-server/internal/apps/openflare/relay/middleware.go diff --git a/Wavelet/internal/apps/openflare/relay/middleware_test.go b/openflare-server/internal/apps/openflare/relay/middleware_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/middleware_test.go rename to openflare-server/internal/apps/openflare/relay/middleware_test.go diff --git a/Wavelet/internal/apps/openflare/relay/observability.go b/openflare-server/internal/apps/openflare/relay/observability.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/observability.go rename to openflare-server/internal/apps/openflare/relay/observability.go diff --git a/Wavelet/internal/apps/openflare/relay/routers.go b/openflare-server/internal/apps/openflare/relay/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/relay/routers.go rename to openflare-server/internal/apps/openflare/relay/routers.go diff --git a/Wavelet/internal/apps/openflare/tasks/database_cleanup.go b/openflare-server/internal/apps/openflare/tasks/database_cleanup.go similarity index 100% rename from Wavelet/internal/apps/openflare/tasks/database_cleanup.go rename to openflare-server/internal/apps/openflare/tasks/database_cleanup.go diff --git a/Wavelet/internal/apps/openflare/tasks/database_cleanup_test.go b/openflare-server/internal/apps/openflare/tasks/database_cleanup_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/tasks/database_cleanup_test.go rename to openflare-server/internal/apps/openflare/tasks/database_cleanup_test.go diff --git a/Wavelet/internal/apps/openflare/tasks/doc.go b/openflare-server/internal/apps/openflare/tasks/doc.go similarity index 100% rename from Wavelet/internal/apps/openflare/tasks/doc.go rename to openflare-server/internal/apps/openflare/tasks/doc.go diff --git a/Wavelet/internal/apps/openflare/tasks/ssl_renew.go b/openflare-server/internal/apps/openflare/tasks/ssl_renew.go similarity index 100% rename from Wavelet/internal/apps/openflare/tasks/ssl_renew.go rename to openflare-server/internal/apps/openflare/tasks/ssl_renew.go diff --git a/Wavelet/internal/apps/openflare/tasks/ssl_renew_test.go b/openflare-server/internal/apps/openflare/tasks/ssl_renew_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/tasks/ssl_renew_test.go rename to openflare-server/internal/apps/openflare/tasks/ssl_renew_test.go diff --git a/Wavelet/internal/apps/openflare/tls/acme/client.go b/openflare-server/internal/apps/openflare/tls/acme/client.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/acme/client.go rename to openflare-server/internal/apps/openflare/tls/acme/client.go diff --git a/Wavelet/internal/apps/openflare/tls/acme_obtain_test.go b/openflare-server/internal/apps/openflare/tls/acme_obtain_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/acme_obtain_test.go rename to openflare-server/internal/apps/openflare/tls/acme_obtain_test.go diff --git a/Wavelet/internal/apps/openflare/tls/errs.go b/openflare-server/internal/apps/openflare/tls/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/errs.go rename to openflare-server/internal/apps/openflare/tls/errs.go diff --git a/Wavelet/internal/apps/openflare/tls/helpers.go b/openflare-server/internal/apps/openflare/tls/helpers.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/helpers.go rename to openflare-server/internal/apps/openflare/tls/helpers.go diff --git a/Wavelet/internal/apps/openflare/tls/logics.go b/openflare-server/internal/apps/openflare/tls/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/logics.go rename to openflare-server/internal/apps/openflare/tls/logics.go diff --git a/Wavelet/internal/apps/openflare/tls/logics_test.go b/openflare-server/internal/apps/openflare/tls/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/logics_test.go rename to openflare-server/internal/apps/openflare/tls/logics_test.go diff --git a/Wavelet/internal/apps/openflare/tls/managed_domain.go b/openflare-server/internal/apps/openflare/tls/managed_domain.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/managed_domain.go rename to openflare-server/internal/apps/openflare/tls/managed_domain.go diff --git a/Wavelet/internal/apps/openflare/tls/obtain.go b/openflare-server/internal/apps/openflare/tls/obtain.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/obtain.go rename to openflare-server/internal/apps/openflare/tls/obtain.go diff --git a/Wavelet/internal/apps/openflare/tls/routers.go b/openflare-server/internal/apps/openflare/tls/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/routers.go rename to openflare-server/internal/apps/openflare/tls/routers.go diff --git a/Wavelet/internal/apps/openflare/tls/sensitive.go b/openflare-server/internal/apps/openflare/tls/sensitive.go similarity index 100% rename from Wavelet/internal/apps/openflare/tls/sensitive.go rename to openflare-server/internal/apps/openflare/tls/sensitive.go diff --git a/Wavelet/internal/apps/openflare/uptimekuma/client.go b/openflare-server/internal/apps/openflare/uptimekuma/client.go similarity index 100% rename from Wavelet/internal/apps/openflare/uptimekuma/client.go rename to openflare-server/internal/apps/openflare/uptimekuma/client.go diff --git a/Wavelet/internal/apps/openflare/uptimekuma/sync.go b/openflare-server/internal/apps/openflare/uptimekuma/sync.go similarity index 100% rename from Wavelet/internal/apps/openflare/uptimekuma/sync.go rename to openflare-server/internal/apps/openflare/uptimekuma/sync.go diff --git a/Wavelet/internal/apps/openflare/uptimekuma/sync_test.go b/openflare-server/internal/apps/openflare/uptimekuma/sync_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/uptimekuma/sync_test.go rename to openflare-server/internal/apps/openflare/uptimekuma/sync_test.go diff --git a/Wavelet/internal/apps/openflare/waf/errs.go b/openflare-server/internal/apps/openflare/waf/errs.go similarity index 100% rename from Wavelet/internal/apps/openflare/waf/errs.go rename to openflare-server/internal/apps/openflare/waf/errs.go diff --git a/Wavelet/internal/apps/openflare/waf/ip_group_sync.go b/openflare-server/internal/apps/openflare/waf/ip_group_sync.go similarity index 100% rename from Wavelet/internal/apps/openflare/waf/ip_group_sync.go rename to openflare-server/internal/apps/openflare/waf/ip_group_sync.go diff --git a/Wavelet/internal/apps/openflare/waf/ip_group_sync_test.go b/openflare-server/internal/apps/openflare/waf/ip_group_sync_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/waf/ip_group_sync_test.go rename to openflare-server/internal/apps/openflare/waf/ip_group_sync_test.go diff --git a/Wavelet/internal/apps/openflare/waf/logics.go b/openflare-server/internal/apps/openflare/waf/logics.go similarity index 100% rename from Wavelet/internal/apps/openflare/waf/logics.go rename to openflare-server/internal/apps/openflare/waf/logics.go diff --git a/Wavelet/internal/apps/openflare/waf/logics_test.go b/openflare-server/internal/apps/openflare/waf/logics_test.go similarity index 100% rename from Wavelet/internal/apps/openflare/waf/logics_test.go rename to openflare-server/internal/apps/openflare/waf/logics_test.go diff --git a/Wavelet/internal/apps/openflare/waf/routers.go b/openflare-server/internal/apps/openflare/waf/routers.go similarity index 100% rename from Wavelet/internal/apps/openflare/waf/routers.go rename to openflare-server/internal/apps/openflare/waf/routers.go diff --git a/Wavelet/internal/apps/openflare/websocket/agent_hub.go b/openflare-server/internal/apps/openflare/websocket/agent_hub.go similarity index 100% rename from Wavelet/internal/apps/openflare/websocket/agent_hub.go rename to openflare-server/internal/apps/openflare/websocket/agent_hub.go diff --git a/Wavelet/internal/apps/openflare/websocket/common.go b/openflare-server/internal/apps/openflare/websocket/common.go similarity index 100% rename from Wavelet/internal/apps/openflare/websocket/common.go rename to openflare-server/internal/apps/openflare/websocket/common.go diff --git a/Wavelet/internal/apps/openflare/websocket/flared_hub.go b/openflare-server/internal/apps/openflare/websocket/flared_hub.go similarity index 100% rename from Wavelet/internal/apps/openflare/websocket/flared_hub.go rename to openflare-server/internal/apps/openflare/websocket/flared_hub.go diff --git a/Wavelet/internal/apps/openflare/websocket/relay_hub.go b/openflare-server/internal/apps/openflare/websocket/relay_hub.go similarity index 100% rename from Wavelet/internal/apps/openflare/websocket/relay_hub.go rename to openflare-server/internal/apps/openflare/websocket/relay_hub.go diff --git a/Wavelet/internal/apps/risk_control/logics.go b/openflare-server/internal/apps/risk_control/logics.go similarity index 100% rename from Wavelet/internal/apps/risk_control/logics.go rename to openflare-server/internal/apps/risk_control/logics.go diff --git a/Wavelet/internal/apps/risk_control/middleware.go b/openflare-server/internal/apps/risk_control/middleware.go similarity index 100% rename from Wavelet/internal/apps/risk_control/middleware.go rename to openflare-server/internal/apps/risk_control/middleware.go diff --git a/Wavelet/internal/apps/risk_control/middleware_test.go b/openflare-server/internal/apps/risk_control/middleware_test.go similarity index 100% rename from Wavelet/internal/apps/risk_control/middleware_test.go rename to openflare-server/internal/apps/risk_control/middleware_test.go diff --git a/Wavelet/internal/apps/risk_control/model.go b/openflare-server/internal/apps/risk_control/model.go similarity index 100% rename from Wavelet/internal/apps/risk_control/model.go rename to openflare-server/internal/apps/risk_control/model.go diff --git a/Wavelet/internal/apps/user/access_tokens.go b/openflare-server/internal/apps/user/access_tokens.go similarity index 100% rename from Wavelet/internal/apps/user/access_tokens.go rename to openflare-server/internal/apps/user/access_tokens.go diff --git a/Wavelet/internal/apps/user/constants.go b/openflare-server/internal/apps/user/constants.go similarity index 100% rename from Wavelet/internal/apps/user/constants.go rename to openflare-server/internal/apps/user/constants.go diff --git a/Wavelet/internal/apps/user/errs.go b/openflare-server/internal/apps/user/errs.go similarity index 100% rename from Wavelet/internal/apps/user/errs.go rename to openflare-server/internal/apps/user/errs.go diff --git a/Wavelet/internal/apps/user/logics.go b/openflare-server/internal/apps/user/logics.go similarity index 100% rename from Wavelet/internal/apps/user/logics.go rename to openflare-server/internal/apps/user/logics.go diff --git a/Wavelet/internal/apps/user/logics_test.go b/openflare-server/internal/apps/user/logics_test.go similarity index 100% rename from Wavelet/internal/apps/user/logics_test.go rename to openflare-server/internal/apps/user/logics_test.go diff --git a/Wavelet/internal/apps/user/routers.go b/openflare-server/internal/apps/user/routers.go similarity index 100% rename from Wavelet/internal/apps/user/routers.go rename to openflare-server/internal/apps/user/routers.go diff --git a/Wavelet/internal/apps/user/routers_test.go b/openflare-server/internal/apps/user/routers_test.go similarity index 100% rename from Wavelet/internal/apps/user/routers_test.go rename to openflare-server/internal/apps/user/routers_test.go diff --git a/Wavelet/internal/apps/user/tasks.go b/openflare-server/internal/apps/user/tasks.go similarity index 100% rename from Wavelet/internal/apps/user/tasks.go rename to openflare-server/internal/apps/user/tasks.go diff --git a/Wavelet/internal/bootstrap/bootstrap.go b/openflare-server/internal/bootstrap/bootstrap.go similarity index 100% rename from Wavelet/internal/bootstrap/bootstrap.go rename to openflare-server/internal/bootstrap/bootstrap.go diff --git a/Wavelet/internal/bootstrap/bootstrap_test.go b/openflare-server/internal/bootstrap/bootstrap_test.go similarity index 100% rename from Wavelet/internal/bootstrap/bootstrap_test.go rename to openflare-server/internal/bootstrap/bootstrap_test.go diff --git a/Wavelet/internal/buildinfo/buildinfo.go b/openflare-server/internal/buildinfo/buildinfo.go similarity index 100% rename from Wavelet/internal/buildinfo/buildinfo.go rename to openflare-server/internal/buildinfo/buildinfo.go diff --git a/Wavelet/internal/cmd/all.go b/openflare-server/internal/cmd/all.go similarity index 100% rename from Wavelet/internal/cmd/all.go rename to openflare-server/internal/cmd/all.go diff --git a/Wavelet/internal/cmd/api.go b/openflare-server/internal/cmd/api.go similarity index 100% rename from Wavelet/internal/cmd/api.go rename to openflare-server/internal/cmd/api.go diff --git a/Wavelet/internal/cmd/bootstrap.go b/openflare-server/internal/cmd/bootstrap.go similarity index 100% rename from Wavelet/internal/cmd/bootstrap.go rename to openflare-server/internal/cmd/bootstrap.go diff --git a/Wavelet/internal/cmd/root.go b/openflare-server/internal/cmd/root.go similarity index 100% rename from Wavelet/internal/cmd/root.go rename to openflare-server/internal/cmd/root.go diff --git a/Wavelet/internal/cmd/scheduler.go b/openflare-server/internal/cmd/scheduler.go similarity index 100% rename from Wavelet/internal/cmd/scheduler.go rename to openflare-server/internal/cmd/scheduler.go diff --git a/Wavelet/internal/cmd/worker.go b/openflare-server/internal/cmd/worker.go similarity index 100% rename from Wavelet/internal/cmd/worker.go rename to openflare-server/internal/cmd/worker.go diff --git a/openflare-server/internal/common/constants.go b/openflare-server/internal/common/constants.go index e0261148..f317898a 100644 --- a/openflare-server/internal/common/constants.go +++ b/openflare-server/internal/common/constants.go @@ -1,178 +1,6 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package common 提供跨模块共享的常量、错误定义和通用工具函数。 package common - -import ( - "sync" - "time" - - "github.com/google/uuid" -) - -var StartTime = time.Now().Unix() // unit: second -var Version = "dev" // release builds inject the tag version via ldflags -var SystemName = "OpenFlare" -var ServerAddress = "http://localhost:3000" -var Footer = "" -var HomePageLink = "" - -// Any options with "Secret", "Token" in its key won't be return by GetOptions - -var SessionSecret = uuid.New().String() -var JWTSecret = "" // if empty, falls back to SessionSecret; set via JWT_SECRET env var -var SQLitePath = "openflare.db" -var SQLDSN = "" - -var OptionMap map[string]string -var OptionMapRWMutex sync.RWMutex - -var ItemsPerPage = 10 - -var PasswordLoginEnabled = true -var CapLoginEnabled = true -var PasswordRegisterEnabled = false -var EmailVerificationEnabled = false -var GitHubOAuthEnabled = false -var WeChatAuthEnabled = false -var RegisterEnabled = false - -var SMTPServer = "" -var SMTPPort = 587 -var SMTPAccount = "" -var SMTPToken = "" - -var GitHubClientId = "" -var GitHubClientSecret = "" - -var WeChatServerAddress = "" -var WeChatServerToken = "" -var WeChatAccountQRCodeImageURL = "" - -var AccessToken = "" -var AgentDiscoveryToken = "" -var NodeOfflineThreshold = 2 * time.Minute - -// V3 operational settings (hot-reloadable via Option table) - -var AgentHeartbeatInterval = 10000 // milliseconds -var AgentWebsocketUpgradeEnabled = true -var AgentUpdateRepo = "Rain-kl/OpenFlare" -var GeoIPProvider = "ipinfo" -var DatabaseAutoCleanupEnabled = false -var DatabaseAutoCleanupRetentionDays = 30 - -// Uptime Kuma integration settings -var UptimeKumaEnabled = false -var UptimeKumaUrl = "" -var UptimeKumaUsername = "" -var UptimeKumaPassword = "" -var UptimeKumaMonitorScope = "all" // "all" or "selected" -var UptimeKumaSelectedSites = "" // Comma-separated list of site names -var UptimeKumaSyncInterval = 5 // minutes -var UptimeKumaInterval = 60 // seconds -var UptimeKumaRetry = 0 -var UptimeKumaRetryInterval = 60 // seconds -var UptimeKumaTimeout = 48 // seconds - -// V5 OpenResty performance settings (hot-reloadable via Option table) - -var OpenRestyDefaultServerReturnStatus = 421 -var OpenRestyWorkerProcesses = "auto" -var OpenRestyWorkerConnections = 4096 -var OpenRestyWorkerRlimitNofile = 65535 -var OpenRestyEventsUse = "epoll" -var OpenRestyEventsMultiAcceptEnabled = true -var OpenRestyKeepaliveTimeout = 20 -var OpenRestyKeepaliveRequests = 1000 -var OpenRestyClientHeaderTimeout = 15 -var OpenRestyClientBodyTimeout = 15 -var OpenRestyClientMaxBodySize = "64m" -var OpenRestyLargeClientHeaderBuffers = "4 16k" -var OpenRestySendTimeout = 30 -var OpenRestyResolvers = "" -var OpenRestyProxyConnectTimeout = 3 -var OpenRestyProxySendTimeout = 60 -var OpenRestyProxyReadTimeout = 60 -var OpenRestyWebsocketEnabled = true -var OpenRestyHTTP3Enabled = true -var OpenRestyProxyRequestBufferingEnabled = false -var OpenRestyProxyBufferingEnabled = true -var OpenRestyProxyBuffers = "16 16k" -var OpenRestyProxyBufferSize = "8k" -var OpenRestyProxyBusyBuffersSize = "64k" -var OpenRestyGzipEnabled = true -var OpenRestyGzipMinLength = 1024 -var OpenRestyGzipCompLevel = 5 -var OpenRestyCacheEnabled = false -var OpenRestyCachePath = "" -var OpenRestyCacheLevels = "1:2" -var OpenRestyCacheInactive = "30m" -var OpenRestyCacheMaxSize = "1g" -var OpenRestyCacheKeyTemplate = "$scheme$host$request_uri" -var OpenRestyCacheLockEnabled = true -var OpenRestyCacheLockTimeout = "5s" -var OpenRestyCacheUseStale = "error timeout updating http_500 http_502 http_503 http_504" -var OpenRestyMainConfigTemplate = `# This file is generated by OpenFlare. Do not edit manually. -worker_processes {{OpenRestyWorkerProcesses}}; -worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}}; -pid logs/nginx.pid; -error_log {{OpenRestyErrorLogPath}} warn; - -events { - worker_connections {{OpenRestyWorkerConnections}}; -{{OpenRestyEventsUseDirective}}{{OpenRestyEventsMultiAcceptDirective}}} - -http { - include mime.types; - default_type application/octet-stream; -{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}'; - access_log {{OpenRestyAccessLogPath}} openflare_json; - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout {{OpenRestyKeepaliveTimeout}}; - keepalive_requests {{OpenRestyKeepaliveRequests}}; - client_header_timeout {{OpenRestyClientHeaderTimeout}}; - client_body_timeout {{OpenRestyClientBodyTimeout}}; - client_max_body_size {{OpenRestyClientMaxBodySize}}; - large_client_header_buffers {{OpenRestyLargeClientHeaderBuffers}}; - send_timeout {{OpenRestySendTimeout}}; - proxy_connect_timeout {{OpenRestyProxyConnectTimeout}}; - proxy_send_timeout {{OpenRestyProxySendTimeout}}; - proxy_read_timeout {{OpenRestyProxyReadTimeout}}; - proxy_request_buffering {{OpenRestyProxyRequestBuffering}}; - proxy_buffering {{OpenRestyProxyBuffering}}; - proxy_buffers {{OpenRestyProxyBuffers}}; - proxy_buffer_size {{OpenRestyProxyBufferSize}}; - proxy_busy_buffers_size {{OpenRestyProxyBusyBuffersSize}}; - gzip {{OpenRestyGzip}}; - gzip_min_length {{OpenRestyGzipMinLength}}; - gzip_comp_level {{OpenRestyGzipCompLevel}}; -{{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}}; -} -` - -const ( - RoleGuestUser = 0 - RoleCommonUser = 1 - RoleAdminUser = 10 - RoleRootUser = 100 -) - -// All duration's unit is seconds -// Shouldn't larger then RateLimitKeyExpirationDuration -var ( - GlobalApiRateLimitNum = 300 - GlobalApiRateLimitDuration int64 = 3 * 60 - - GlobalWebRateLimitNum = 300 - GlobalWebRateLimitDuration int64 = 3 * 60 - - CriticalRateLimitNum = 100 - CriticalRateLimitDuration int64 = 20 * 60 -) - -var RateLimitKeyExpirationDuration = 20 * time.Minute - -const ( - UserStatusEnabled = 1 // don't use 0, 0 is the default value! - UserStatusDisabled = 2 // also don't use 0 -) diff --git a/Wavelet/internal/common/errs.go b/openflare-server/internal/common/errs.go similarity index 100% rename from Wavelet/internal/common/errs.go rename to openflare-server/internal/common/errs.go diff --git a/openflare-server/internal/common/init.go b/openflare-server/internal/common/init.go deleted file mode 100644 index b8d409d0..00000000 --- a/openflare-server/internal/common/init.go +++ /dev/null @@ -1,84 +0,0 @@ -package common - -import ( - "flag" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" -) - -var ( - Port = flag.Int("port", 3000, "the listening port") - PrintVersion = flag.Bool("version", false, "print version and exit") - PrintHelp = flag.Bool("help", false, "print help and exit") - LogDir = flag.String("log-dir", "", "specify the log directory") -) - -func printHelp() { - fmt.Println("OpenFlare " + Version + " - Internal OpenResty Control Plane.") - fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.") - fmt.Println("GitHub: https://github.com/Rain-kl/OpenFlare") - fmt.Println("Usage: openflare [--port ] [--log-dir ] [--version] [--help]") -} - -// ParseFlags 在命令行参数被任何 import 链上的 init() 误解析之前, -// 由各 binary 的 main() 显式调用一次。openflare-server 与 openflare-relay -// 共用 flag.CommandLine,必须先注册各自的 flag 再调用本函数。 -// 测试场景(go test)下不会执行本函数,单元测试可直接跳过命令行解析。 -func ParseFlags() { - executableName := strings.ToLower(filepath.Base(os.Args[0])) - isTest := strings.Contains(executableName, ".test") || flag.Lookup("test.v") != nil - if isTest { - return - } - flag.Parse() - - if *PrintVersion { - fmt.Println(Version) - os.Exit(0) - } - - if *PrintHelp { - printHelp() - os.Exit(0) - } - - if os.Getenv("SESSION_SECRET") != "" { - SessionSecret = os.Getenv("SESSION_SECRET") - } - if os.Getenv("JWT_SECRET") != "" { - JWTSecret = os.Getenv("JWT_SECRET") - } - if os.Getenv("SQLITE_PATH") != "" { - SQLitePath = os.Getenv("SQLITE_PATH") - } - if os.Getenv("SQL_DSN") != "" { - SQLDSN = os.Getenv("SQL_DSN") - } - if os.Getenv("DSN") != "" { - SQLDSN = os.Getenv("DSN") - } - - if os.Getenv("AGENT_TOKEN") != "" { - AccessToken = os.Getenv("AGENT_TOKEN") - } - SetLogLevel(os.Getenv("LOG_LEVEL")) - if *LogDir != "" { - var err error - *LogDir, err = filepath.Abs(*LogDir) - if err != nil { - slog.Error("resolve log directory failed", "error", err) - os.Exit(1) - } - if _, err := os.Stat(*LogDir); os.IsNotExist(err) { - err = os.Mkdir(*LogDir, 0777) - if err != nil { - slog.Error("create log directory failed", "error", err) - os.Exit(1) - } - } - } - -} diff --git a/openflare-server/internal/common/logger.go b/openflare-server/internal/common/logger.go deleted file mode 100644 index b6dafde7..00000000 --- a/openflare-server/internal/common/logger.go +++ /dev/null @@ -1,242 +0,0 @@ -package common - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "runtime" - "slices" - "strings" - - "github.com/gin-gonic/gin" -) - -type logLevel int - -const ( - logLevelDebug logLevel = iota - logLevelInfo - logLevelWarn - logLevelError -) - -var currentLogLevel = logLevelInfo -var currentLogLevelName = "info" -var commonLogWriter io.Writer = os.Stdout -var errorLogWriter io.Writer = os.Stderr -var defaultLogger *slog.Logger - -type customTextHandler struct { - writer io.Writer - level slog.Level - attrs []slog.Attr - groups []string -} - -type levelRouterHandler struct { - commonHandler slog.Handler - errorHandler slog.Handler -} - -func (h *customTextHandler) Enabled(_ context.Context, level slog.Level) bool { - return level >= h.level -} - -func (h *customTextHandler) Handle(_ context.Context, record slog.Record) error { - var builder strings.Builder - builder.WriteString(record.Time.Format("2006-01-02 15:04:05.000")) - builder.WriteString(" | ") - builder.WriteString(fmt.Sprintf("%-8s", levelLabel(record.Level))) - builder.WriteString(" | ") - builder.WriteString(sourceLocation(record.PC)) - builder.WriteString(" - ") - builder.WriteString(record.Message) - - attrs := make([]slog.Attr, 0, len(h.attrs)+record.NumAttrs()) - attrs = append(attrs, h.attrs...) - record.Attrs(func(attr slog.Attr) bool { - attrs = append(attrs, attr) - return true - }) - if len(attrs) > 0 { - builder.WriteString(" | ") - builder.WriteString(formatAttrs(h.groups, attrs)) - } - builder.WriteByte('\n') - _, err := io.WriteString(h.writer, builder.String()) - return err -} - -func (h *customTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - cloned := *h - cloned.attrs = append(slices.Clone(h.attrs), attrs...) - return &cloned -} - -func (h *customTextHandler) WithGroup(name string) slog.Handler { - if strings.TrimSpace(name) == "" { - return h - } - cloned := *h - cloned.groups = append(slices.Clone(h.groups), name) - return &cloned -} - -func (h *levelRouterHandler) Enabled(ctx context.Context, level slog.Level) bool { - return h.commonHandler.Enabled(ctx, level) || h.errorHandler.Enabled(ctx, level) -} - -func (h *levelRouterHandler) Handle(ctx context.Context, record slog.Record) error { - if record.Level >= slog.LevelError { - return h.errorHandler.Handle(ctx, record) - } - return h.commonHandler.Handle(ctx, record) -} - -func (h *levelRouterHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &levelRouterHandler{ - commonHandler: h.commonHandler.WithAttrs(attrs), - errorHandler: h.errorHandler.WithAttrs(attrs), - } -} - -func (h *levelRouterHandler) WithGroup(name string) slog.Handler { - return &levelRouterHandler{ - commonHandler: h.commonHandler.WithGroup(name), - errorHandler: h.errorHandler.WithGroup(name), - } -} - -func configureGinWriters() { - if shouldLog(logLevelDebug) { - gin.DefaultWriter = commonLogWriter - } else { - gin.DefaultWriter = io.Discard - } - gin.DefaultErrorWriter = errorLogWriter -} - -func slogLevel() slog.Level { - switch currentLogLevel { - case logLevelDebug: - return slog.LevelDebug - case logLevelWarn: - return slog.LevelWarn - case logLevelError: - return slog.LevelError - default: - return slog.LevelInfo - } -} - -func ensureLogger() *slog.Logger { - if defaultLogger != nil { - return defaultLogger - } - defaultLogger = slog.New(&levelRouterHandler{ - commonHandler: &customTextHandler{writer: commonLogWriter, level: slogLevel()}, - errorHandler: &customTextHandler{writer: errorLogWriter, level: slogLevel()}, - }) - slog.SetDefault(defaultLogger) - return defaultLogger -} - -func SetLogLevel(level string) { - normalized := strings.TrimSpace(strings.ToLower(level)) - switch normalized { - case "debug": - currentLogLevel = logLevelDebug - currentLogLevelName = "debug" - case "warn", "warning": - currentLogLevel = logLevelWarn - currentLogLevelName = "warn" - case "error": - currentLogLevel = logLevelError - currentLogLevelName = "error" - default: - currentLogLevel = logLevelInfo - currentLogLevelName = "info" - } - configureGinWriters() -} - -func GetLogLevel() string { - return currentLogLevelName -} - -func shouldLog(level logLevel) bool { - return level >= currentLogLevel -} - -func SetupGinLog() { - if *LogDir != "" { - commonLogPath := filepath.Join(*LogDir, "common.log") - errorLogPath := filepath.Join(*LogDir, "error.log") - commonFd, err := os.OpenFile(commonLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - _, _ = io.WriteString(os.Stderr, "failed to open common log file\n") - os.Exit(1) - } - errorFd, err := os.OpenFile(errorLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - _, _ = io.WriteString(os.Stderr, "failed to open error log file\n") - os.Exit(1) - } - commonLogWriter = io.MultiWriter(os.Stdout, commonFd) - errorLogWriter = io.MultiWriter(os.Stderr, errorFd) - } - configureGinWriters() - defaultLogger = nil - ensureLogger() -} - -func levelLabel(level slog.Level) string { - switch { - case level <= slog.LevelDebug: - return "DEBUG" - case level < slog.LevelWarn: - return "INFO" - case level < slog.LevelError: - return "WARNING" - default: - return "ERROR" - } -} - -func sourceLocation(pc uintptr) string { - if pc == 0 { - return "unknown:unknown:0" - } - frame, _ := runtime.CallersFrames([]uintptr{pc}).Next() - fileName := strings.TrimSuffix(filepath.Base(frame.File), filepath.Ext(frame.File)) - if fileName == "" { - fileName = "unknown" - } - functionName := "unknown" - if frame.Function != "" { - parts := strings.Split(frame.Function, "/") - functionName = parts[len(parts)-1] - if dot := strings.LastIndex(functionName, "."); dot >= 0 && dot < len(functionName)-1 { - functionName = functionName[dot+1:] - } - } - return fmt.Sprintf("%s:%s:%d", fileName, functionName, frame.Line) -} - -func formatAttrs(groups []string, attrs []slog.Attr) string { - parts := make([]string, 0, len(attrs)) - for _, attr := range attrs { - key := attr.Key - if key == "" { - continue - } - if len(groups) > 0 { - key = strings.Join(append(slices.Clone(groups), key), ".") - } - parts = append(parts, fmt.Sprintf("%s=%v", key, attr.Value.Any())) - } - return strings.Join(parts, " ") -} diff --git a/openflare-server/internal/common/redis.go b/openflare-server/internal/common/redis.go deleted file mode 100644 index 54061aee..00000000 --- a/openflare-server/internal/common/redis.go +++ /dev/null @@ -1,41 +0,0 @@ -package common - -import ( - "context" - "log/slog" - "os" - "time" - - "github.com/go-redis/redis/v8" -) - -var RDB *redis.Client -var RedisEnabled = true - -// InitRedisClient This function is called after init() -func InitRedisClient() (err error) { - if os.Getenv("REDIS_CONN_STRING") == "" { - RedisEnabled = false - slog.Info("redis disabled because REDIS_CONN_STRING is not set") - return nil - } - opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) - if err != nil { - panic(err) - } - RDB = redis.NewClient(opt) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - _, err = RDB.Ping(ctx).Result() - return err -} - -func ParseRedisOption() *redis.Options { - opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) - if err != nil { - panic(err) - } - return opt -} diff --git a/Wavelet/internal/common/response/abort.go b/openflare-server/internal/common/response/abort.go similarity index 100% rename from Wavelet/internal/common/response/abort.go rename to openflare-server/internal/common/response/abort.go diff --git a/Wavelet/internal/common/response/middleware.go b/openflare-server/internal/common/response/middleware.go similarity index 100% rename from Wavelet/internal/common/response/middleware.go rename to openflare-server/internal/common/response/middleware.go diff --git a/Wavelet/internal/common/response/middleware_test.go b/openflare-server/internal/common/response/middleware_test.go similarity index 100% rename from Wavelet/internal/common/response/middleware_test.go rename to openflare-server/internal/common/response/middleware_test.go diff --git a/openflare-server/internal/common/response/response.go b/openflare-server/internal/common/response/response.go index dcae0d23..14e32b0f 100644 --- a/openflare-server/internal/common/response/response.go +++ b/openflare-server/internal/common/response/response.go @@ -1,82 +1,57 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package response provides shared HTTP API response structures. package response -import ( - "net/http" +import "github.com/gin-gonic/gin" - "github.com/gin-gonic/gin" -) - -const invalidParamsMessage = "参数错误" - -// RespondSuccess sends a successful response with data -func RespondSuccess(c *gin.Context, data any) { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "", - "data": data, - }) +// Response 通用响应体 +type Response[T any] struct { + ErrorMsg string `json:"error_msg"` + Data T `json:"data"` } -// RespondSuccessWithExtras sends a successful response with data and extra fields -func RespondSuccessWithExtras(c *gin.Context, data any, extras gin.H) { - payload := gin.H{ - "success": true, - "message": "", - "data": data, - } - for key, value := range extras { - payload[key] = value - } - c.JSON(http.StatusOK, payload) +// Any 用于 Swagger 文档的响应类型(非泛型) +// swag 不支持泛型,使用此类型替代 Response[T] +type Any struct { + ErrorMsg string `json:"error_msg" example:""` + Data interface{} `json:"data"` } -// RespondSuccessMessage sends a successful response with a custom message -func RespondSuccessMessage(c *gin.Context, message string) { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": message, - }) +// APIError 统一的 API 业务错误类型,可被全局错误处理中间件捕获 +type APIError struct { + Code int + Msg string } -// RespondFailure sends a failed response with http.StatusOK and a failure message -func RespondFailure(c *gin.Context, message string) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": message, - }) +func (e *APIError) Error() string { + return e.Msg } -// RespondBadRequest sends a bad request response (400) -func RespondBadRequest(c *gin.Context, message string) { - if message == "" { - message = invalidParamsMessage - } - c.JSON(http.StatusBadRequest, gin.H{ - "success": false, - "message": message, - }) +// NewError 实例化一个 APIError +func NewError(code int, msg string) *APIError { + return &APIError{Code: code, Msg: msg} } -// RespondUnauthorized sends an unauthorized response (401) -func RespondUnauthorized(c *gin.Context, message string) { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": message, - }) +// AbortWithError 将 API 错误挂载到 Gin Context 并中断执行流 +func AbortWithError(c *gin.Context, code int, msg string) { + _ = c.Error(NewError(code, msg)) + c.Abort() } -// RespondForbidden sends a forbidden response (403) -func RespondForbidden(c *gin.Context, message string) { - c.JSON(http.StatusForbidden, gin.H{ - "success": false, - "message": message, - }) +// OK 构造成功响应 +func OK[T any](data T) Response[T] { + return Response[T]{Data: data} } -// RespondErrorWithStatus sends a response with target HTTP status code and a message -func RespondErrorWithStatus(c *gin.Context, code int, message string) { - c.JSON(code, gin.H{ - "success": false, - "message": message, - }) +// OKNil 构造成功响应(data 为 null) +func OKNil() Response[any] { + return Response[any]{Data: nil} +} + +// Err 构造错误响应 +func Err(msg string) Response[any] { + return Response[any]{ErrorMsg: msg, Data: nil} } diff --git a/Wavelet/internal/config/config.go b/openflare-server/internal/config/config.go similarity index 100% rename from Wavelet/internal/config/config.go rename to openflare-server/internal/config/config.go diff --git a/Wavelet/internal/config/model.go b/openflare-server/internal/config/model.go similarity index 100% rename from Wavelet/internal/config/model.go rename to openflare-server/internal/config/model.go diff --git a/openflare-server/internal/controller/access_log.go b/openflare-server/internal/controller/access_log.go deleted file mode 100644 index 5285083b..00000000 --- a/openflare-server/internal/controller/access_log.go +++ /dev/null @@ -1,196 +0,0 @@ -package controller - -import ( - "strconv" - - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// GetAccessLogs godoc -// @Summary List access logs -// @Tags AccessLogs -// @Produce json -// @Security OpenFlareTokenAuth -// @Param node_id query string false "Node ID" -// @Param remote_addr query string false "Remote address" -// @Param host query string false "Host" -// @Param path query string false "Path" -// @Param p query int false "Page index" -// @Param page_size query int false "Page size" -// @Param sort_by query string false "Sort by" -// @Param sort_order query string false "Sort order" -// @Success 200 {object} map[string]interface{} -// @Router /api/access-logs/ [get] -func GetAccessLogs(c *gin.Context) { - logs, err := service.ListAccessLogs(readAccessLogQuery(c)) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, logs) -} - -// GetFoldedAccessLogs godoc -// @Summary List folded access logs -// @Tags AccessLogs -// @Produce json -// @Security OpenFlareTokenAuth -// @Param node_id query string false "Node ID" -// @Param remote_addr query string false "Remote address" -// @Param host query string false "Host" -// @Param path query string false "Path" -// @Param p query int false "Page index" -// @Param page_size query int false "Page size" -// @Param sort_by query string false "Sort by" -// @Param sort_order query string false "Sort order" -// @Param fold_minutes query int false "Fold minutes" -// @Success 200 {object} map[string]interface{} -// @Router /api/access-logs/folds [get] -func GetFoldedAccessLogs(c *gin.Context) { - query := readAccessLogQuery(c) - query.FoldMinutes = readQueryInt(c, "fold_minutes") - logs, err := service.ListFoldedAccessLogs(query) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, logs) -} - -// GetFoldedAccessLogIPs godoc -// @Summary List folded access log IP summaries -// @Tags AccessLogs -// @Produce json -// @Security OpenFlareTokenAuth -// @Param node_id query string false "Node ID" -// @Param remote_addr query string false "Remote address" -// @Param host query string false "Host" -// @Param path query string false "Path" -// @Param bucket_started_at query string true "Bucket started at" -// @Param fold_minutes query int true "Fold minutes" -// @Param p query int false "Page index" -// @Param page_size query int false "Page size" -// @Param sort_by query string false "Sort by" -// @Param sort_order query string false "Sort order" -// @Success 200 {object} map[string]interface{} -// @Router /api/access-logs/folds/ip-summary [get] -func GetFoldedAccessLogIPs(c *gin.Context) { - result, err := service.ListFoldedAccessLogIPs(service.FoldedAccessLogIPQuery{ - NodeID: c.Query("node_id"), - RemoteAddr: c.Query("remote_addr"), - Host: c.Query("host"), - Path: c.Query("path"), - BucketStartedAt: c.Query("bucket_started_at"), - FoldMinutes: readQueryInt(c, "fold_minutes"), - Page: readQueryInt(c, "p"), - PageSize: readQueryInt(c, "page_size"), - SortBy: c.Query("sort_by"), - SortOrder: c.Query("sort_order"), - }) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -// GetAccessLogIPSummaries godoc -// @Summary List access log IP summaries -// @Tags AccessLogs -// @Produce json -// @Security OpenFlareTokenAuth -// @Param node_id query string false "Node ID" -// @Param remote_addr query string false "Remote address" -// @Param host query string false "Host" -// @Param p query int false "Page index" -// @Param page_size query int false "Page size" -// @Param sort_by query string false "Sort by" -// @Param sort_order query string false "Sort order" -// @Success 200 {object} map[string]interface{} -// @Router /api/access-logs/ip-summary [get] -func GetAccessLogIPSummaries(c *gin.Context) { - result, err := service.ListAccessLogIPSummaries(service.AccessLogIPSummaryQuery{ - NodeID: c.Query("node_id"), - RemoteAddr: c.Query("remote_addr"), - Host: c.Query("host"), - Page: readQueryInt(c, "p"), - PageSize: readQueryInt(c, "page_size"), - SortBy: c.Query("sort_by"), - SortOrder: c.Query("sort_order"), - }) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -// GetAccessLogIPTrend godoc -// @Summary Get access log IP trend -// @Tags AccessLogs -// @Produce json -// @Security OpenFlareTokenAuth -// @Param node_id query string false "Node ID" -// @Param remote_addr query string true "Remote address" -// @Param host query string false "Host" -// @Param hours query int false "Hours" -// @Param bucket_minutes query int false "Bucket minutes" -// @Success 200 {object} map[string]interface{} -// @Router /api/access-logs/ip-summary/trend [get] -func GetAccessLogIPTrend(c *gin.Context) { - result, err := service.GetAccessLogIPTrend(service.AccessLogIPTrendQuery{ - NodeID: c.Query("node_id"), - RemoteAddr: c.Query("remote_addr"), - Host: c.Query("host"), - Hours: readQueryInt(c, "hours"), - BucketMinutes: readQueryInt(c, "bucket_minutes"), - }) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -// CleanupAccessLogs godoc -// @Summary Cleanup access logs by retention days -// @Tags AccessLogs -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/access-logs/cleanup [post] -func CleanupAccessLogs(c *gin.Context) { - var input service.AccessLogCleanupInput - if !bind.JSON(c, &input) { - return - } - result, err := service.CleanupAccessLogs(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -func readAccessLogQuery(c *gin.Context) service.AccessLogQuery { - return service.AccessLogQuery{ - NodeID: c.Query("node_id"), - RemoteAddr: c.Query("remote_addr"), - Host: c.Query("host"), - Path: c.Query("path"), - Page: readQueryInt(c, "p"), - PageSize: readQueryInt(c, "page_size"), - SortBy: c.Query("sort_by"), - SortOrder: c.Query("sort_order"), - } -} - -func readQueryInt(c *gin.Context, key string) int { - value, _ := strconv.Atoi(c.DefaultQuery(key, "0")) - return value -} diff --git a/openflare-server/internal/controller/acme_account.go b/openflare-server/internal/controller/acme_account.go deleted file mode 100644 index 282b2552..00000000 --- a/openflare-server/internal/controller/acme_account.go +++ /dev/null @@ -1,24 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "github.com/gin-gonic/gin" -) - -// GetDefaultAcmeAccount godoc -// @Summary Get default ACME account -// @Tags AcmeAccounts -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/acme-accounts/default [get] -func GetDefaultAcmeAccount(c *gin.Context) { - account, err := model.GetDefaultAcmeAccount() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, account) -} diff --git a/openflare-server/internal/controller/agent.go b/openflare-server/internal/controller/agent.go deleted file mode 100644 index 7c4b5b6c..00000000 --- a/openflare-server/internal/controller/agent.go +++ /dev/null @@ -1,363 +0,0 @@ -package controller - -import ( - "encoding/json" - "log/slog" - "net" - "strconv" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" - "golang.org/x/net/websocket" -) - -// AgentRegister godoc -// @Summary Register or discover agent node -// @Tags Agent -// @Accept json -// @Produce json -// @Security AccessTokenAuth -// @Param payload body service.AgentNodePayload true "Agent node payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/agent/nodes/register [post] -func AgentRegister(c *gin.Context) { - var payload service.AgentNodePayload - if !bind.JSON(c, &payload) { - return - } - payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr) - - var ( - result *service.AgentRegistrationResponse - err error - ) - if authNode, ok := c.Get("agent_node"); ok { - result, err = service.RegisterNodeWithAccessToken(authNode.(*model.Node), payload) - } else { - result, err = service.RegisterNodeWithDiscovery(payload) - } - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -// AgentHeartbeat godoc -// @Summary Report agent heartbeat -// @Tags Agent -// @Accept json -// @Produce json -// @Security AccessTokenAuth -// @Param payload body service.AgentNodePayload true "Agent heartbeat payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/agent/nodes/heartbeat [post] -func AgentHeartbeat(c *gin.Context) { - var payload service.AgentNodePayload - if !bind.JSON(c, &payload) { - return - } - payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr) - - authNode, ok := c.Get("agent_node") - if !ok { - response.RespondUnauthorized(c, "鏃犳潈杩涜姝ゆ搷浣滐紝Agent Token 鏃犳晥") - return - } - - node, err := service.HeartbeatNode(authNode.(*model.Node), payload) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessWithExtras(c, node.Node, gin.H{ - "agent_settings": node.AgentSettings, - "active_config": node.ActiveConfig, - "waf_ip_groups": node.WAFIPGroups, - }) -} - -// AgentSyncWAFIPGroups godoc -// @Summary Sync WAF IP groups for agent -// @Tags Agent -// @Accept json -// @Produce json -// @Security AccessTokenAuth -// @Param payload body service.AgentWAFIPGroupSyncInput true "WAF IP group sync payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/agent/waf/ip-groups/sync [post] -func AgentSyncWAFIPGroups(c *gin.Context) { - var input service.AgentWAFIPGroupSyncInput - if !bind.JSON(c, &input) { - return - } - result, err := service.SyncWAFIPGroupsForAgent(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -// AgentGetActiveConfig godoc -// @Summary Get active config for agent -// @Tags Agent -// @Produce json -// @Security AccessTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/agent/config-versions/active [get] -func AgentGetActiveConfig(c *gin.Context) { - authNode, ok := c.Get("agent_node") - if !ok { - response.RespondUnauthorized(c, "Node object missing from context") - return - } - node := authNode.(*model.Node) - - if node.NodeType == "tunnel_client" { - config, err := service.GetFlaredTunnelConfig(node) - if err != nil { - response.RespondFailure(c, "无法生成隧道配置: "+err.Error()) - return - } - response.RespondSuccess(c, config) - return - } - - config, err := service.GetActiveConfigForAgent() - if err != nil { - response.RespondFailure(c, "当前没有激活版本") - return - } - response.RespondSuccess(c, config) -} - -// AgentReportApplyLog godoc -// @Summary Report agent apply result -// @Tags Agent -// @Accept json -// @Produce json -// @Security AccessTokenAuth -// @Param payload body service.ApplyLogPayload true "Apply log payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/agent/apply-logs [post] -func AgentReportApplyLog(c *gin.Context) { - var payload service.ApplyLogPayload - if !bind.JSON(c, &payload) { - return - } - - if authNode, ok := c.Get("agent_node"); ok { - payload.NodeID = authNode.(*model.Node).NodeID - } - - log, err := service.ReportApplyLog(payload) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, log) -} - -// AgentWebSocket godoc -// @Summary Upgrade agent connection to websocket -// @Tags Agent -// @Security AccessTokenAuth -// @Router /api/agent/ws [get] -func AgentWebSocket(c *gin.Context) { - authNode, ok := c.Get("agent_node") - if !ok { - response.RespondUnauthorized(c, "无权进行此操作,Agent Token 无效") - return - } - node := authNode.(*model.Node) - slog.Debug("agent ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr) - websocket.Handler(func(conn *websocket.Conn) { - client := service.RegisterAgentWSClient(node.NodeID) - defer service.UnregisterAgentWSClient(client) - defer func() { - _ = conn.Close() - slog.Debug("agent ws connection closed", "node_id", node.NodeID) - }() - - slog.Debug("agent ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr) - - go func() { - <-client.Done() - _ = conn.Close() - }() - - go streamAgentWSMessages(c, conn, client) - - for { - var message service.AgentWSInboundMessage - _ = conn.SetReadDeadline(time.Now().Add(agentWSReadTimeout())) - if err := websocket.JSON.Receive(conn, &message); err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - slog.Debug("agent ws receive timeout waiting for status or pong", "node_id", node.NodeID, "timeout", agentWSReadTimeout()) - return - } - slog.Debug("agent ws receive failed", "node_id", node.NodeID, "error", err) - return - } - slog.Debug("agent ws message received", "node_id", node.NodeID, "type", message.Type) - switch message.Type { - case service.AgentWSMessageTypeStatus: - handleAgentWSStatus(c, node, message) - case service.AgentWSMessageTypePing: - if !service.SendAgentWSPong(node.NodeID) { - slog.Debug("agent ws pong enqueue failed", "node_id", node.NodeID) - } - case service.AgentWSMessageTypePong: - slog.Debug("agent ws pong received", "node_id", node.NodeID) - default: - slog.Debug("agent ws unsupported message type", "node_id", node.NodeID, "type", message.Type) - } - } - }).ServeHTTP(c.Writer, c.Request) -} - -func agentWSReadTimeout() time.Duration { - timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3 - if timeout < 30*time.Second { - return 30 * time.Second - } - return timeout -} - -func agentWSWriteTimeout() time.Duration { - return 10 * time.Second -} - -func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) { - for { - select { - case <-c.Request.Context().Done(): - return - case <-client.Done(): - return - case message, ok := <-client.Messages(): - if !ok { - return - } - _ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout())) - if err := websocket.JSON.Send(conn, message); err != nil { - slog.Debug("agent ws send failed", "node_id", client.ID(), "error", err) - return - } - } - } -} - -func handleAgentWSStatus(c *gin.Context, node *model.Node, message service.AgentWSInboundMessage) { - var payload service.AgentNodePayload - if err := json.Unmarshal(message.Payload, &payload); err != nil { - slog.Debug("agent ws status payload decode failed", "node_id", node.NodeID, "error", err) - return - } - freshNode, err := model.GetNodeByNodeID(node.NodeID) - if err != nil { - slog.Debug("agent ws status reload node failed", "node_id", node.NodeID, "error", err) - return - } - payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr) - res, err := service.HeartbeatNode(freshNode, payload) - if err != nil { - slog.Debug("agent ws status handling failed", "node_id", node.NodeID, "error", err) - return - } - settingsSent := service.SendAgentWSSettings(node.NodeID, res.AgentSettings) - activeConfigSent := false - if res.ActiveConfig != nil { - activeConfigSent = service.SendAgentWSActiveConfig(node.NodeID, res.ActiveConfig) - } - wafIPGroupsSent := false - if len(res.WAFIPGroups) > 0 { - wafIPGroupsSent = service.SendAgentWSWAFIPGroups(node.NodeID, res.WAFIPGroups) - } - slog.Debug("agent ws status processed", - "node_id", node.NodeID, - "current_version", payload.CurrentVersion, - "openresty_status", payload.OpenrestyStatus, - "settings_sent", settingsSent, - "active_config_sent", activeConfigSent, - "waf_ip_groups_sent", wafIPGroupsSent, - ) -} - -// GetNodes godoc -// @Summary List nodes -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/nodes/ [get] -func GetNodes(c *gin.Context) { - nodes, err := service.ListNodeViews() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, nodes) -} - -// GetApplyLogs godoc -// @Summary List apply logs -// @Tags ApplyLogs -// @Produce json -// @Security OpenFlareTokenAuth -// @Param node_id query string false "Node ID" -// @Success 200 {object} map[string]interface{} -// @Router /api/apply-logs/ [get] -func GetApplyLogs(c *gin.Context) { - logs, err := service.ListApplyLogsPage(service.ApplyLogListQuery{ - NodeID: c.Query("node_id"), - PageNo: readIntQueryFallback(c, "pageNo", "page_no"), - PageSize: readIntQueryFallback(c, "pageSize", "page_size"), - }) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, logs) -} - -// CleanupApplyLogs godoc -// @Summary Cleanup apply logs -// @Tags ApplyLogs -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/apply-logs/cleanup [post] -func CleanupApplyLogs(c *gin.Context) { - var input service.ApplyLogCleanupInput - if !bind.JSON(c, &input) { - return - } - result, err := service.CleanupApplyLogs(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -func readIntQueryFallback(c *gin.Context, primary string, secondary string) int { - value := c.Query(primary) - if value == "" { - value = c.Query(secondary) - } - parsed, _ := strconv.Atoi(value) - return parsed -} diff --git a/openflare-server/internal/controller/auth_source.go b/openflare-server/internal/controller/auth_source.go deleted file mode 100644 index 6da30566..00000000 --- a/openflare-server/internal/controller/auth_source.go +++ /dev/null @@ -1,358 +0,0 @@ -package controller - -import ( - "encoding/json" - "fmt" - "net/url" - "strconv" - "strings" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -const pendingExternalAccountSessionKey = "pending_external_account" - -type authSourceTogglePayload struct { - IsActive bool `json:"is_active"` -} - -type authSourcePayload struct { - Name string `json:"name"` - Type string `json:"type"` - DisplayName string `json:"display_name"` - IsActive bool `json:"is_active"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - OpenIDDiscoveryURL string `json:"openid_discovery_url"` - Scopes string `json:"scopes"` - IconURL string `json:"icon_url"` -} - -func (payload authSourcePayload) toModel() model.AuthSource { - return model.AuthSource{ - Name: payload.Name, - Type: payload.Type, - DisplayName: payload.DisplayName, - IsActive: payload.IsActive, - ClientID: payload.ClientID, - ClientSecret: payload.ClientSecret, - OpenIDDiscoveryURL: payload.OpenIDDiscoveryURL, - Scopes: payload.Scopes, - IconURL: payload.IconURL, - } -} - -func ListAuthSources(c *gin.Context) { - sources, err := model.GetAuthSources() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, sources) -} - -func CreateAuthSource(c *gin.Context) { - var payload authSourcePayload - if err := bind.DecodeJSONBody(c.Request.Body, &payload); err != nil { - response.RespondBadRequest(c, "无效的参数") - return - } - source := payload.toModel() - if err := model.CreateAuthSource(&source); err != nil { - response.RespondFailure(c, err.Error()) - return - } - source.Sanitize() - response.RespondSuccess(c, source) -} - -func UpdateAuthSource(c *gin.Context) { - id, err := parseAuthSourceID(c) - if err != nil { - response.RespondBadRequest(c, err.Error()) - return - } - var payload authSourcePayload - if err := bind.DecodeJSONBody(c.Request.Body, &payload); err != nil { - response.RespondBadRequest(c, "无效的参数") - return - } - source := payload.toModel() - source.ID = id - keepSecret := strings.TrimSpace(source.ClientSecret) == "" - if err := model.UpdateAuthSource(&source, keepSecret); err != nil { - response.RespondFailure(c, err.Error()) - return - } - updated, err := model.GetAuthSourceByID(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - updated.Sanitize() - response.RespondSuccess(c, updated) -} - -func DeleteAuthSource(c *gin.Context) { - id, err := parseAuthSourceID(c) - if err != nil { - response.RespondBadRequest(c, err.Error()) - return - } - if err := model.DeleteAuthSource(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func ToggleAuthSource(c *gin.Context) { - id, err := parseAuthSourceID(c) - if err != nil { - response.RespondBadRequest(c, err.Error()) - return - } - var payload authSourceTogglePayload - if err := bind.DecodeJSONBody(c.Request.Body, &payload); err != nil { - response.RespondBadRequest(c, "无效的参数") - return - } - if err := model.ToggleAuthSource(id, payload.IsActive); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func OAuthAuthorize(c *gin.Context) { - source, err := getAuthSourceFromRoute(c) - if err != nil { - response.RespondBadRequest(c, err.Error()) - return - } - if !source.IsActive { - response.RespondFailure(c, "认证源未启用") - return - } - if err := source.Validate(); err != nil { - response.RespondFailure(c, err.Error()) - return - } - state, err := service.GenerateOAuthState() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - session := sessions.Default(c) - session.Set(oauthStateSessionKey(source.ID), state) - if err := session.Save(); err != nil { - response.RespondFailure(c, "无法保存授权状态,请重试") - return - } - redirectURL := oauthFrontendCallbackURL(c, source.ID) - authorizeURL, err := service.BuildAuthorizeURL(c.Request.Context(), source, redirectURL, state) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, gin.H{"authorize_url": authorizeURL}) -} - -func OAuthCallback(c *gin.Context) { - source, err := getAuthSourceFromRoute(c) - if err != nil { - response.RespondBadRequest(c, err.Error()) - return - } - if !source.IsActive { - response.RespondFailure(c, "认证源未启用") - return - } - session := sessions.Default(c) - expectedState, _ := session.Get(oauthStateSessionKey(source.ID)).(string) - state := c.Query("state") - if expectedState == "" || state == "" || state != expectedState { - response.RespondFailure(c, "授权状态无效,请重新登录") - return - } - session.Delete(oauthStateSessionKey(source.ID)) - if err := session.Save(); err != nil { - response.RespondFailure(c, "无法更新授权状态,请重试") - return - } - if oauthError := c.Query("error"); oauthError != "" { - description := c.Query("error_description") - if description == "" { - description = oauthError - } - response.RespondFailure(c, description) - return - } - - profile, err := service.ExchangeOAuthProfile(c.Request.Context(), source, c.Query("code"), oauthFrontendCallbackURL(c, source.ID)) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - var currentUserID *int - if currentUser := currentUserFromOpenFlareToken(c); currentUser != nil { - currentUserID = ¤tUser.Id - } - result, pending, err := service.CompleteOAuthLogin(source, profile, currentUserID) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - if pending != nil { - raw, err := json.Marshal(pending) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - session.Set(pendingExternalAccountSessionKey, string(raw)) - if err := session.Save(); err != nil { - response.RespondFailure(c, "无法保存待绑定账号,请重试") - return - } - response.RespondSuccess(c, result) - return - } - if result.User != nil { - cleanUser, err := setLoginToken(result.User) - if err != nil { - response.RespondFailure(c, "无法保存会话信息,请重试") - return - } - result.User = cleanUser - } - response.RespondSuccess(c, result) -} - -func LinkExistingOAuthAccount(c *gin.Context) { - session := sessions.Default(c) - raw, _ := session.Get(pendingExternalAccountSessionKey).(string) - if raw == "" { - response.RespondFailure(c, "待绑定第三方账号已失效,请重新登录") - return - } - var pending service.PendingExternalAccount - if err := json.Unmarshal([]byte(raw), &pending); err != nil { - response.RespondFailure(c, "待绑定第三方账号无效,请重新登录") - return - } - var input service.LinkExistingRequest - if err := bind.DecodeJSONBody(c.Request.Body, &input); err != nil { - response.RespondBadRequest(c, "无效的参数") - return - } - user, err := service.LinkPendingExternalAccount(&pending, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - session.Delete(pendingExternalAccountSessionKey) - if err := session.Save(); err != nil { - response.RespondFailure(c, "无法更新会话信息,请重试") - return - } - cleanUser, err := setLoginToken(user) - if err != nil { - response.RespondFailure(c, "无法保存会话信息,请重试") - return - } - response.RespondSuccess(c, service.OAuthCallbackResult{Status: "linked", User: cleanUser}) -} - -func ListExternalAccounts(c *gin.Context) { - userID := c.GetInt("id") - accounts, err := model.ListExternalAccountsByUserID(userID) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, accounts) -} - -func DeleteExternalAccount(c *gin.Context) { - rawID := strings.TrimSpace(c.Param("id")) - parsedID, err := strconv.ParseUint(rawID, 10, 64) - if err != nil || parsedID == 0 { - response.RespondBadRequest(c, "绑定记录 ID 无效") - return - } - if err := model.DeleteExternalAccountForUser(uint(parsedID), c.GetInt("id")); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func parseAuthSourceID(c *gin.Context) (uint, error) { - raw := c.Param("source_id") - if raw == "" { - raw = c.Param("id") - } - parsed, err := strconv.ParseUint(raw, 10, 64) - if err != nil || parsed == 0 { - return 0, fmt.Errorf("认证源 ID 无效") - } - return uint(parsed), nil -} - -func getAuthSourceFromRoute(c *gin.Context) (*model.AuthSource, error) { - raw := strings.TrimSpace(c.Param("source")) - if raw == "" { - raw = strings.TrimSpace(c.Param("source_id")) - } - if raw == "" { - raw = strings.TrimSpace(c.Param("id")) - } - if raw == "" { - return nil, fmt.Errorf("认证源不能为空") - } - if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil && parsed > 0 { - source, err := model.GetAuthSourceByID(uint(parsed)) - if err != nil { - return nil, err - } - return source, nil - } - source, err := model.GetAuthSourceByName(raw) - if err != nil { - return nil, err - } - return source, nil -} - -func oauthStateSessionKey(sourceID uint) string { - return fmt.Sprintf("oauth_state_%d", sourceID) -} - -func oauthFrontendCallbackURL(c *gin.Context, sourceID uint) string { - base := strings.TrimRight(common.ServerAddress, "/") - if base == "" { - scheme := "http" - if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { - scheme = "https" - } - host := c.Request.Host - if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { - host = forwardedHost - } - base = scheme + "://" + host - } - source, err := model.GetAuthSourceByID(sourceID) - sourceName := strconv.FormatUint(uint64(sourceID), 10) - if err == nil && strings.TrimSpace(source.Name) != "" { - sourceName = source.Name - } - callback, _ := url.JoinPath(base, "oauth", sourceName) - return callback -} diff --git a/openflare-server/internal/controller/bind/bind.go b/openflare-server/internal/controller/bind/bind.go deleted file mode 100644 index cd182ad2..00000000 --- a/openflare-server/internal/controller/bind/bind.go +++ /dev/null @@ -1,49 +0,0 @@ -package bind - -import ( - "encoding/json" - "errors" - "io" - "strconv" - - "github.com/gin-gonic/gin" - - "github.com/rain-kl/openflare/openflare-server/internal/common/response" -) - -// DecodeJSONBody decodes JSON reader to target -func DecodeJSONBody(body io.Reader, target any) error { - return json.NewDecoder(body).Decode(target) -} - -// OptionalJSON decodes optional JSON body of reader to target, allowing EOF -func OptionalJSON(body io.Reader, target any) error { - if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) { - return err - } - return nil -} - -// IDParam parses "id" parameter from context path -func IDParam(c *gin.Context) (uint, bool) { - return IDParamByName(c, "id") -} - -// IDParamByName parses target parameter from context path -func IDParamByName(c *gin.Context, name string) (uint, bool) { - id, err := strconv.ParseUint(c.Param(name), 10, 64) - if err != nil || id == 0 { - response.RespondBadRequest(c, "") - return 0, false - } - return uint(id), true -} - -// JSON binds JSON body of context request to target -func JSON(c *gin.Context, target any) bool { - if err := DecodeJSONBody(c.Request.Body, target); err != nil { - response.RespondBadRequest(c, "") - return false - } - return true -} diff --git a/openflare-server/internal/controller/cap.go b/openflare-server/internal/controller/cap.go deleted file mode 100644 index a2feca12..00000000 --- a/openflare-server/internal/controller/cap.go +++ /dev/null @@ -1,51 +0,0 @@ -package controller - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - "github.com/rain-kl/openflare/openflare-server/internal/utils/cap" -) - -// GetCapChallenge generates a new CAPTCHA challenge -func GetCapChallenge(c *gin.Context) { - scope := c.Param("scope") - if scope == "" { - scope = c.Query("scope") - } - resp, err := service.CapManager.Generate(scope) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "error": err.Error(), - }) - return - } - c.JSON(http.StatusOK, resp) -} - -// RedeemCapChallenge validates CAPTCHA solutions and yields a one-time redeem token -func RedeemCapChallenge(c *gin.Context) { - scope := c.Param("scope") - if scope == "" { - scope = c.Query("scope") - } - - var req cap.RedeemRequest - if !bind.JSON(c, &req) { - return - } - - resp, err := service.CapManager.Redeem(c.Request.Context(), req.Token, req.Solutions, scope) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "error": err.Error(), - }) - return - } - - c.JSON(http.StatusOK, resp) -} diff --git a/openflare-server/internal/controller/config_version.go b/openflare-server/internal/controller/config_version.go deleted file mode 100644 index 2d087aaa..00000000 --- a/openflare-server/internal/controller/config_version.go +++ /dev/null @@ -1,165 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// GetConfigVersions godoc -// @Summary List config versions -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/config-versions/ [get] -func GetConfigVersions(c *gin.Context) { - versions, err := service.ListConfigVersions() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, versions) -} - -// GetConfigVersion godoc -// @Summary Get config version detail -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Version ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/config-versions/{id} [get] -func GetConfigVersion(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - version, err := service.GetConfigVersionDetail(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, version) -} - -// GetActiveConfigVersion godoc -// @Summary Get active config version -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/config-versions/active [get] -func GetActiveConfigVersion(c *gin.Context) { - version, err := service.GetActiveConfigVersion() - if err != nil { - response.RespondFailure(c, "当前没有激活版本") - return - } - response.RespondSuccess(c, version) -} - -// PreviewConfigVersion godoc -// @Summary Preview config rendering -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/config-versions/preview [get] -func PreviewConfigVersion(c *gin.Context) { - preview, err := service.PreviewConfigVersion() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, preview) -} - -// DiffConfigVersion godoc -// @Summary Diff current draft against active version -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/config-versions/diff [get] -func DiffConfigVersion(c *gin.Context) { - diff, err := service.DiffConfigVersion() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, diff) -} - -// PublishConfigVersion godoc -// @Summary Publish a new config version -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/config-versions/publish [post] -func PublishConfigVersion(c *gin.Context) { - username := c.GetString("username") - force := c.Query("force") == "true" - result, err := service.PublishConfigVersion(username, force) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result.Version) -} - -// ActivateConfigVersion godoc -// @Summary Activate an existing config version -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Version ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/config-versions/{id}/activate [post] -func ActivateConfigVersion(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - version, err := service.ActivateConfigVersion(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, version) -} - -type CleanupConfigVersionRequest struct { - KeepCount int `json:"keep_count" binding:"required,min=3"` -} - -// CleanupConfigVersions godoc -// @Summary Cleanup old config versions -// @Tags ConfigVersions -// @Produce json -// @Security OpenFlareTokenAuth -// @Param request body CleanupConfigVersionRequest true "Cleanup request" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/config-versions/cleanup [post] -func CleanupConfigVersions(c *gin.Context) { - var req CleanupConfigVersionRequest - if !bind.JSON(c, &req) { - return - } - - deletedCount, err := service.CleanupConfigVersions(req.KeepCount) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccessWithExtras(c, map[string]interface{}{"deleted_count": deletedCount}, gin.H{ - "message": "清理成功", - }) -} diff --git a/openflare-server/internal/controller/dashboard.go b/openflare-server/internal/controller/dashboard.go deleted file mode 100644 index c00db3e4..00000000 --- a/openflare-server/internal/controller/dashboard.go +++ /dev/null @@ -1,173 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -type dashboardOverviewPayload struct { - GeneratedAt any `json:"generated_at"` - Summary service.DashboardSummary `json:"summary"` - Traffic service.DashboardTraffic `json:"traffic"` - Capacity service.DashboardCapacity `json:"capacity"` - Distributions dashboardDistributionsPayload `json:"distributions"` - Trends dashboardTrendsPayload `json:"trends"` - Nodes [][]any `json:"nodes"` -} - -type dashboardDistributionsPayload struct { - StatusCodes [][]any `json:"status_codes"` - TopDomains [][]any `json:"top_domains"` - SourceCountries [][]any `json:"source_countries"` -} - -type dashboardTrendsPayload struct { - Traffic24h [][]any `json:"traffic_24h"` - Capacity24h [][]any `json:"capacity_24h"` - Network24h [][]any `json:"network_24h"` - DiskIO24h [][]any `json:"disk_io_24h"` -} - -// GetDashboardOverview godoc -// @Summary Get dashboard overview -// @Tags Dashboard -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/dashboard/overview [get] -func GetDashboardOverview(c *gin.Context) { - view, err := service.GetDashboardOverview() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, compressDashboardOverview(view)) -} - -func compressDashboardOverview(view *service.DashboardOverviewView) *dashboardOverviewPayload { - if view == nil { - return &dashboardOverviewPayload{ - Distributions: dashboardDistributionsPayload{ - StatusCodes: [][]any{}, - TopDomains: [][]any{}, - SourceCountries: [][]any{}, - }, - Trends: dashboardTrendsPayload{ - Traffic24h: [][]any{}, - Capacity24h: [][]any{}, - Network24h: [][]any{}, - DiskIO24h: [][]any{}, - }, - Nodes: [][]any{}, - } - } - return &dashboardOverviewPayload{ - GeneratedAt: view.GeneratedAt, - Summary: view.Summary, - Traffic: view.Traffic, - Capacity: view.Capacity, - Distributions: dashboardDistributionsPayload{ - StatusCodes: compressDistributionItems(view.Distributions.StatusCodes), - TopDomains: compressDistributionItems(view.Distributions.TopDomains), - SourceCountries: compressDistributionItems(view.Distributions.SourceCountries), - }, - Trends: dashboardTrendsPayload{ - Traffic24h: compressTrafficTrendPoints(view.Trends.Traffic24h), - Capacity24h: compressCapacityTrendPoints(view.Trends.Capacity24h), - Network24h: compressNetworkTrendPoints(view.Trends.Network24h), - DiskIO24h: compressDiskIOTrendPoints(view.Trends.DiskIO24h), - }, - Nodes: compressDashboardNodes(view.Nodes), - } -} - -func compressDistributionItems(items []service.DistributionItem) [][]any { - rows := make([][]any, 0, len(items)) - for _, item := range items { - rows = append(rows, []any{item.Key, item.Value}) - } - return rows -} - -func compressTrafficTrendPoints(points []service.TrafficTrendPoint) [][]any { - rows := make([][]any, 0, len(points)) - for _, point := range points { - rows = append(rows, []any{ - point.BucketStartedAt, - point.RequestCount, - point.ErrorCount, - point.UniqueVisitorCount, - }) - } - return rows -} - -func compressCapacityTrendPoints(points []service.CapacityTrendPoint) [][]any { - rows := make([][]any, 0, len(points)) - for _, point := range points { - rows = append(rows, []any{ - point.BucketStartedAt, - point.AverageCPUUsagePercent, - point.AverageMemoryUsagePercent, - point.ReportedNodes, - }) - } - return rows -} - -func compressNetworkTrendPoints(points []service.NetworkTrendPoint) [][]any { - rows := make([][]any, 0, len(points)) - for _, point := range points { - rows = append(rows, []any{ - point.BucketStartedAt, - point.NetworkRxBytes, - point.NetworkTxBytes, - point.OpenrestyRxBytes, - point.OpenrestyTxBytes, - point.ReportedNodes, - }) - } - return rows -} - -func compressDiskIOTrendPoints(points []service.DiskIOTrendPoint) [][]any { - rows := make([][]any, 0, len(points)) - for _, point := range points { - rows = append(rows, []any{ - point.BucketStartedAt, - point.DiskReadBytes, - point.DiskWriteBytes, - point.ReportedNodes, - }) - } - return rows -} - -func compressDashboardNodes(nodes []service.DashboardNodeHealth) [][]any { - rows := make([][]any, 0, len(nodes)) - for _, node := range nodes { - rows = append(rows, []any{ - node.ID, - node.NodeID, - node.Name, - node.GeoName, - node.GeoLatitude, - node.GeoLongitude, - node.Status, - node.OpenrestyStatus, - node.CurrentVersion, - node.LastSeenAt, - node.ActiveEventCount, - node.CPUUsagePercent, - node.MemoryUsagePercent, - node.StorageUsagePercent, - node.RequestCount, - node.ErrorCount, - node.UniqueVisitorCount, - }) - } - return rows -} diff --git a/openflare-server/internal/controller/database.go b/openflare-server/internal/controller/database.go deleted file mode 100644 index b207a757..00000000 --- a/openflare-server/internal/controller/database.go +++ /dev/null @@ -1,31 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// CleanupDatabaseObservability godoc -// @Summary Cleanup observability tables -// @Tags Options -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/option/database/cleanup [post] -func CleanupDatabaseObservability(c *gin.Context) { - var input service.DatabaseCleanupInput - if err := bind.OptionalJSON(c.Request.Body, &input); err != nil { - response.RespondBadRequest(c, "") - return - } - result, err := service.CleanupDatabaseObservability(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} diff --git a/openflare-server/internal/controller/dns_account.go b/openflare-server/internal/controller/dns_account.go deleted file mode 100644 index 2690c8fc..00000000 --- a/openflare-server/internal/controller/dns_account.go +++ /dev/null @@ -1,135 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "github.com/gin-gonic/gin" -) - -type DnsAccountInput struct { - Name string `json:"name"` - Type string `json:"type"` - Authorization string `json:"authorization"` -} - -// GetDnsAccounts godoc -// @Summary List DNS accounts -// @Tags DnsAccounts -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/dns-accounts/ [get] -func GetDnsAccounts(c *gin.Context) { - accounts, err := model.ListDnsAccounts() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, accounts) -} - -// CreateDnsAccount godoc -// @Summary Create DNS account -// @Tags DnsAccounts -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param payload body DnsAccountInput true "DNS account payload" -// @Success 200 {object} map[string]interface{} -// @Router /api/dns-accounts/ [post] -func CreateDnsAccount(c *gin.Context) { - var input DnsAccountInput - if !bind.JSON(c, &input) { - return - } - - account := &model.DnsAccount{ - Name: input.Name, - Type: input.Type, - Authorization: input.Authorization, - } - - if err := account.Insert(); err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccess(c, account) -} - -// UpdateDnsAccount godoc -// @Summary Update DNS account -// @Tags DnsAccounts -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "DNS Account ID" -// @Param payload body DnsAccountInput true "DNS account payload" -// @Success 200 {object} map[string]interface{} -// @Router /api/dns-accounts/{id}/update [post] -func UpdateDnsAccount(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - var input DnsAccountInput - if !bind.JSON(c, &input) { - return - } - - account, err := model.GetDnsAccountByID(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - - account.Name = input.Name - account.Type = input.Type - account.Authorization = input.Authorization - - if err := account.Update(); err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccess(c, account) -} - -// DeleteDnsAccount godoc -// @Summary Delete DNS account -// @Tags DnsAccounts -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "DNS Account ID" -// @Success 200 {object} map[string]interface{} -// @Router /api/dns-accounts/{id}/delete [post] -func DeleteDnsAccount(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - account, err := model.GetDnsAccountByID(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - - // Verify no cert uses this before deleting - var count int64 - model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", id).Count(&count) - if count > 0 { - response.RespondFailure(c, "该 DNS 账号已被证书使用,无法删除") - return - } - - if err := account.Delete(); err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccess(c, nil) -} diff --git a/openflare-server/internal/controller/flared.go b/openflare-server/internal/controller/flared.go deleted file mode 100644 index 0c9eb2fb..00000000 --- a/openflare-server/internal/controller/flared.go +++ /dev/null @@ -1,176 +0,0 @@ -package controller - -import ( - "log/slog" - "net" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" - "golang.org/x/net/websocket" -) - -// FlaredHeartbeat godoc -// @Summary Report OpenFlared heartbeat -// @Tags Flared -// @Accept json -// @Produce json -// @Security TunnelTokenAuth -// @Param payload body service.FlaredHeartbeatPayload true "Flared heartbeat payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/flared/heartbeat [post] -func FlaredHeartbeat(c *gin.Context) { - var payload service.FlaredHeartbeatPayload - if !bind.JSON(c, &payload) { - return - } - authNode, ok := c.Get("flared_node") - if !ok { - response.RespondUnauthorized(c, "无权进行此操作,Tunnel Token 无效") - return - } - node := authNode.(*model.Node) - res, err := service.HeartbeatFlared(node, payload) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, res) -} - -// FlaredGetActiveConfig godoc -// @Summary Get active tunnel config for OpenFlared -// @Tags Flared -// @Produce json -// @Security TunnelTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/flared/config/active [get] -func FlaredGetActiveConfig(c *gin.Context) { - authNode, ok := c.Get("flared_node") - if !ok { - response.RespondUnauthorized(c, "无权进行此操作,Tunnel Token 无效") - return - } - node := authNode.(*model.Node) - config, err := service.GetFlaredTunnelConfig(node) - if err != nil { - response.RespondFailure(c, "无法生成隧道配置: "+err.Error()) - return - } - response.RespondSuccess(c, config) -} - -// FlaredReportApplyLog godoc -// @Summary Report OpenFlared apply result -// @Tags Flared -// @Accept json -// @Produce json -// @Security TunnelTokenAuth -// @Param payload body service.ApplyLogPayload true "Apply log payload" -// @Success 200 {object} map[string]interface{} -// @Router /api/flared/apply-log [post] -func FlaredReportApplyLog(c *gin.Context) { - var payload service.ApplyLogPayload - if !bind.JSON(c, &payload) { - return - } - if authNode, ok := c.Get("flared_node"); ok { - payload.NodeID = authNode.(*model.Node).NodeID - } - log, err := service.ReportApplyLog(payload) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, log) -} - -// FlaredWebSocket godoc -// @Summary Upgrade OpenFlared connection to websocket -// @Tags Flared -// @Security TunnelTokenAuth -// @Router /api/flared/ws [get] -func FlaredWebSocket(c *gin.Context) { - authNode, ok := c.Get("flared_node") - if !ok { - response.RespondUnauthorized(c, "无权进行此操作,Tunnel Token 无效") - return - } - node := authNode.(*model.Node) - slog.Debug("flared ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr) - websocket.Handler(func(conn *websocket.Conn) { - client := service.RegisterFlaredWSClient(node.NodeID) - defer service.UnregisterFlaredWSClient(client) - defer func() { - _ = conn.Close() - slog.Debug("flared ws connection closed", "node_id", node.NodeID) - }() - - slog.Debug("flared ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr) - - go func() { - <-client.Done() - _ = conn.Close() - }() - - go streamFlaredWSMessages(c, conn, client) - - for { - var message service.WSMessage - _ = conn.SetReadDeadline(time.Now().Add(flaredWSReadTimeout())) - if err := websocket.JSON.Receive(conn, &message); err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - slog.Debug("flared ws receive timeout", "node_id", node.NodeID) - return - } - slog.Debug("flared ws receive failed", "node_id", node.NodeID, "error", err) - return - } - slog.Debug("flared ws message received", "node_id", node.NodeID, "type", message.Type) - switch message.Type { - case "ping": - if !service.SendFlaredWSPong(node.NodeID) { - slog.Debug("flared ws pong enqueue failed", "node_id", node.NodeID) - } - case "pong": - slog.Debug("flared ws pong received", "node_id", node.NodeID) - default: - slog.Debug("flared ws unsupported message type", "node_id", node.NodeID, "type", message.Type) - } - } - }).ServeHTTP(c.Writer, c.Request) -} - -func streamFlaredWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) { - for { - select { - case <-c.Request.Context().Done(): - return - case <-client.Done(): - return - case message, ok := <-client.Messages(): - if !ok { - return - } - _ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout())) - if err := websocket.JSON.Send(conn, message); err != nil { - slog.Debug("flared ws send failed", "node_id", client.ID(), "error", err) - return - } - } - } -} - -func flaredWSReadTimeout() time.Duration { - timeout := time.Duration(common.AgentHeartbeatInterval) * time.Millisecond * 3 - if timeout < 30*time.Second { - return 30 * time.Second - } - return timeout -} diff --git a/openflare-server/internal/controller/geoip.go b/openflare-server/internal/controller/geoip.go deleted file mode 100644 index 5edb5f3e..00000000 --- a/openflare-server/internal/controller/geoip.go +++ /dev/null @@ -1,37 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -type geoIPLookupRequest struct { - Provider string `json:"provider"` - IP string `json:"ip"` -} - -// LookupGeoIP godoc -// @Summary Test GeoIP lookup -// @Tags Options -// @Accept json -// @Produce json -// @Param payload body geoIPLookupRequest true "GeoIP lookup payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/option/geoip/lookup [post] -func LookupGeoIP(c *gin.Context) { - var request geoIPLookupRequest - if !bind.JSON(c, &request) { - return - } - - view, err := service.LookupGeoIP(request.Provider, request.IP) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, view) -} diff --git a/openflare-server/internal/controller/github.go b/openflare-server/internal/controller/github.go deleted file mode 100644 index a6f832cc..00000000 --- a/openflare-server/internal/controller/github.go +++ /dev/null @@ -1,155 +0,0 @@ -package controller - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "github.com/gin-gonic/gin" -) - -type GitHubOAuthResponse struct { - AccessToken string `json:"access_token"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` -} - -type GitHubUser struct { - Login string `json:"login"` - Name string `json:"name"` - Email string `json:"email"` -} - -func getGitHubUserInfoByCode(code string) (*GitHubUser, error) { - if code == "" { - return nil, errors.New("无效的参数") - } - values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code} - jsonData, err := json.Marshal(values) - if err != nil { - return nil, err - } - req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - client := http.Client{ - Timeout: 5 * time.Second, - } - res, err := client.Do(req) - if err != nil { - slog.Error("github oauth access token request failed", "error", err) - return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!") - } - defer res.Body.Close() - var oAuthResponse GitHubOAuthResponse - err = json.NewDecoder(res.Body).Decode(&oAuthResponse) - if err != nil { - return nil, err - } - req, err = http.NewRequest("GET", "https://api.github.com/user", nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken)) - res2, err := client.Do(req) - if err != nil { - slog.Error("github user info request failed", "error", err) - return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!") - } - defer res2.Body.Close() - var githubUser GitHubUser - err = json.NewDecoder(res2.Body).Decode(&githubUser) - if err != nil { - return nil, err - } - if githubUser.Login == "" { - return nil, errors.New("返回值非法,用户字段为空,请稍后重试!") - } - return &githubUser, nil -} - -func GitHubOAuth(c *gin.Context) { - if currentUserFromOpenFlareToken(c) != nil { - GitHubBind(c) - return - } - - if !common.GitHubOAuthEnabled { - response.RespondFailure(c, "管理员未开启通过 GitHub 登录以及注册") - return - } - code := c.Query("code") - githubUser, err := getGitHubUserInfoByCode(code) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - user := model.User{ - GitHubId: githubUser.Login, - } - if model.IsGitHubIdAlreadyTaken(user.GitHubId) { - err := user.FillUserByGitHubId() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - } else { - response.RespondFailure(c, "管理员关闭了新用户注册") - return - } - - if user.Status != common.UserStatusEnabled { - response.RespondFailure(c, "用户已被封禁") - return - } - setupLogin(&user, c) -} - -func GitHubBind(c *gin.Context) { - if !common.GitHubOAuthEnabled { - response.RespondFailure(c, "管理员未开启通过 GitHub 登录以及注册") - return - } - code := c.Query("code") - githubUser, err := getGitHubUserInfoByCode(code) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - user := model.User{ - GitHubId: githubUser.Login, - } - if model.IsGitHubIdAlreadyTaken(user.GitHubId) { - response.RespondFailure(c, "该 GitHub 账户已被绑定") - return - } - currentUser := currentUserFromOpenFlareToken(c) - if currentUser == nil { - response.RespondFailure(c, "无权进行此操作,未登录或 token 无效") - return - } - user.Id = currentUser.Id - err = user.FillUserById() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - user.GitHubId = githubUser.Login - err = user.Update(false) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "bind") -} diff --git a/openflare-server/internal/controller/managed_domain.go b/openflare-server/internal/controller/managed_domain.go deleted file mode 100644 index 9cdcbbb6..00000000 --- a/openflare-server/internal/controller/managed_domain.go +++ /dev/null @@ -1,117 +0,0 @@ -package controller - -import ( - "strings" - - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// GetManagedDomains godoc -// @Summary List managed domains -// @Tags ManagedDomains -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/managed-domains/ [get] -func GetManagedDomains(c *gin.Context) { - domains, err := service.ListManagedDomains() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, domains) -} - -// CreateManagedDomain godoc -// @Summary Create managed domain -// @Tags ManagedDomains -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param payload body service.ManagedDomainInput true "Managed domain payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/managed-domains/ [post] -func CreateManagedDomain(c *gin.Context) { - var input service.ManagedDomainInput - if !bind.JSON(c, &input) { - return - } - domain, err := service.CreateManagedDomain(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, domain) -} - -// UpdateManagedDomain godoc -// @Summary Update managed domain -// @Tags ManagedDomains -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Managed domain ID" -// @Param payload body service.ManagedDomainInput true "Managed domain payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/managed-domains/{id}/update [post] -func UpdateManagedDomain(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - var input service.ManagedDomainInput - if !bind.JSON(c, &input) { - return - } - domain, err := service.UpdateManagedDomain(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, domain) -} - -// DeleteManagedDomain godoc -// @Summary Delete managed domain -// @Tags ManagedDomains -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Managed domain ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/managed-domains/{id}/delete [post] -func DeleteManagedDomain(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - if err := service.DeleteManagedDomain(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, nil) -} - -// MatchManagedDomainCertificate godoc -// @Summary Match certificate for domain -// @Tags ManagedDomains -// @Produce json -// @Security OpenFlareTokenAuth -// @Param domain query string true "Domain" -// @Success 200 {object} map[string]interface{} -// @Router /api/managed-domains/match [get] -func MatchManagedDomainCertificate(c *gin.Context) { - domain := strings.TrimSpace(c.Query("domain")) - result, err := service.MatchManagedDomainCertificate(domain) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} diff --git a/openflare-server/internal/controller/misc.go b/openflare-server/internal/controller/misc.go deleted file mode 100644 index 1a9dec6f..00000000 --- a/openflare-server/internal/controller/misc.go +++ /dev/null @@ -1,148 +0,0 @@ -package controller - -import ( - "fmt" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/service" - "github.com/rain-kl/openflare/openflare-server/internal/utils/mail" - "github.com/rain-kl/openflare/openflare-server/internal/utils/security" - "github.com/rain-kl/openflare/openflare-server/internal/utils/validation" - - "github.com/gin-gonic/gin" -) - -// GetStatus godoc -// @Summary Get server status -// @Tags Public -// @Produce json -// @Success 200 {object} map[string]interface{} -// @Router /api/status [get] -func GetStatus(c *gin.Context) { - authSources, err := service.PublicAuthSources("/api") - if err != nil { - authSources = []service.PublicAuthSource{} - } - response.RespondSuccess(c, 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, - "password_register_enabled": common.PasswordRegisterEnabled, - "cap_login_enabled": common.CapLoginEnabled, - "auth_sources": authSources, - }) -} - -func GetNotice(c *gin.Context) { - common.OptionMapRWMutex.RLock() - defer common.OptionMapRWMutex.RUnlock() - response.RespondSuccess(c, common.OptionMap["Notice"]) -} - -func GetAbout(c *gin.Context) { - common.OptionMapRWMutex.RLock() - defer common.OptionMapRWMutex.RUnlock() - response.RespondSuccess(c, common.OptionMap["About"]) -} - -func SendEmailVerification(c *gin.Context) { - email := c.Query("email") - if err := validation.Validate.Var(email, "required,email"); err != nil { - response.RespondFailure(c, "无效的参数") - return - } - if model.IsEmailAlreadyTaken(email) { - response.RespondFailure(c, "邮箱地址已被占用") - return - } - code := security.GenerateVerificationCode(6) - security.RegisterVerificationCodeWithKey(email, code, security.EmailVerificationPurpose) - subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName) - content := fmt.Sprintf("

您好,你正在进行%s邮箱验证。

"+ - "

您的验证码为: %s

"+ - "

验证码 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, code, security.VerificationValidMinutes) - cfg := mail.SMTPConfig{ - Server: common.SMTPServer, - Port: common.SMTPPort, - Account: common.SMTPAccount, - Token: common.SMTPToken, - SystemName: common.SystemName, - } - err := mail.SendEmail(cfg, subject, email, content) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func SendPasswordResetEmail(c *gin.Context) { - email := c.Query("email") - if err := validation.Validate.Var(email, "required,email"); err != nil { - response.RespondFailure(c, "无效的参数") - return - } - if !model.IsEmailAlreadyTaken(email) { - response.RespondFailure(c, "该邮箱地址未注册") - return - } - code := security.GenerateVerificationCode(0) - security.RegisterVerificationCodeWithKey(email, code, security.PasswordResetPurpose) - link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", common.ServerAddress, email, code) - subject := fmt.Sprintf("%s密码重置", common.SystemName) - content := fmt.Sprintf("

您好,你正在进行%s密码重置。

"+ - "

点击此处进行密码重置。

"+ - "

重置链接 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, link, security.VerificationValidMinutes) - cfg := mail.SMTPConfig{ - Server: common.SMTPServer, - Port: common.SMTPPort, - Account: common.SMTPAccount, - Token: common.SMTPToken, - SystemName: common.SystemName, - } - err := mail.SendEmail(cfg, subject, email, content) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -type PasswordResetRequest struct { - Email string `json:"email"` - Token string `json:"token"` -} - -func ResetPassword(c *gin.Context) { - var req PasswordResetRequest - if !bind.JSON(c, &req) { - return - } - if req.Email == "" || req.Token == "" { - response.RespondFailure(c, "无效的参数") - return - } - if !security.VerifyCodeWithKey(req.Email, req.Token, security.PasswordResetPurpose) { - response.RespondFailure(c, "重置链接非法或已过期") - return - } - password := security.GenerateVerificationCode(12) - err := model.ResetUserPasswordByEmail(req.Email, password) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - security.DeleteKey(req.Email, security.PasswordResetPurpose) - response.RespondSuccess(c, password) -} diff --git a/openflare-server/internal/controller/node.go b/openflare-server/internal/controller/node.go deleted file mode 100644 index a562d70a..00000000 --- a/openflare-server/internal/controller/node.go +++ /dev/null @@ -1,288 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -type nodeAgentUpdateRequest struct { - Channel string `json:"channel"` - TagName string `json:"tag_name"` -} - -type nodeObservabilityQuery struct { - Hours int `form:"hours"` - Limit int `form:"limit"` -} - -// CreateNode godoc -// @Summary Create node -// @Tags Nodes -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param payload body service.NodeInput true "Node payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/ [post] -func CreateNode(c *gin.Context) { - var input service.NodeInput - if !bind.JSON(c, &input) { - return - } - - node, err := service.CreateNode(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, node) -} - -// GetNodeBootstrapToken godoc -// @Summary Get global discovery token -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/nodes/bootstrap-token [get] -func GetNodeBootstrapToken(c *gin.Context) { - bootstrap, err := service.GetNodeBootstrapView() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, bootstrap) -} - -// RotateNodeBootstrapToken godoc -// @Summary Rotate global discovery token -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/nodes/bootstrap-token/rotate [post] -func RotateNodeBootstrapToken(c *gin.Context) { - bootstrap, err := service.RotateGlobalDiscoveryToken() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, bootstrap) -} - -// UpdateNode godoc -// @Summary Update node -// @Tags Nodes -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Param payload body service.NodeInput true "Node payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/update [post] -func UpdateNode(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - var input service.NodeInput - if !bind.JSON(c, &input) { - return - } - - node, err := service.UpdateNode(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, node) -} - -// DeleteNode godoc -// @Summary Delete node -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/delete [post] -func DeleteNode(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - if err := service.DeleteNode(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -// RequestNodeAgentUpdate godoc -// @Summary Request agent self-update on node -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/agent-update [post] -func RequestNodeAgentUpdate(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - var request nodeAgentUpdateRequest - if c.Request.ContentLength > 0 { - if err := bind.OptionalJSON(c.Request.Body, &request); err != nil { - response.RespondBadRequest(c, "") - return - } - } - - node, err := service.RequestNodeAgentUpdate(id, service.NodeAgentUpdateInput{ - Channel: request.Channel, - TagName: request.TagName, - }) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, node) -} - -// RequestNodeOpenrestyRestart godoc -// @Summary Request openresty restart on node -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/openresty-restart [post] -func RequestNodeOpenrestyRestart(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - node, err := service.RequestNodeOpenrestyRestart(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, node) -} - -// RequestNodeForceSync godoc -// @Summary Request force sync config on node -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/force-sync [post] -func RequestNodeForceSync(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - node, err := service.RequestNodeForceSync(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, node) -} - -// GetNodeAgentRelease godoc -// @Summary Check latest agent release for node -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Param channel query string false "stable or preview" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/agent-release [get] -func GetNodeAgentRelease(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - release, err := service.GetNodeAgentRelease(c.Request.Context(), id, c.Query("channel")) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, release) -} - -// GetNodeObservability godoc -// @Summary Get node observability details -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Param hours query int false "Lookback window in hours" -// @Param limit query int false "Max records per section" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/observability [get] -func GetNodeObservability(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - var query nodeObservabilityQuery - if err := c.ShouldBindQuery(&query); err != nil { - response.RespondBadRequest(c, "") - return - } - - view, err := service.GetNodeObservability(id, service.NodeObservabilityQuery{ - Hours: query.Hours, - Limit: query.Limit, - }) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, view) -} - -// CleanupNodeHealthEvents godoc -// @Summary Cleanup node health events -// @Tags Nodes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Node ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/nodes/{id}/observability/cleanup [post] -func CleanupNodeHealthEvents(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - result, err := service.CleanupNodeHealthEvents(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} diff --git a/openflare-server/internal/controller/option.go b/openflare-server/internal/controller/option.go deleted file mode 100644 index e1de2ff8..00000000 --- a/openflare-server/internal/controller/option.go +++ /dev/null @@ -1,417 +0,0 @@ -package controller - -import ( - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/service" - "github.com/rain-kl/openflare/pkg/geoip" - "github.com/rain-kl/openflare/pkg/utils" - - "github.com/gin-gonic/gin" -) - -var ( - openRestySizePattern = regexp.MustCompile(`^\d+[kKmMgG]?$`) - openRestyProxyBuffersPattern = regexp.MustCompile(`^\d+\s+\d+[kKmMgG]?$`) - openRestyCacheLevelsPattern = regexp.MustCompile(`^\d{1,2}(?::\d{1,2}){0,2}$`) - 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()) - - switch key { - case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "CriticalRateLimitNum": - intValue, err := strconv.Atoi(value) - if err != nil || intValue <= 0 { - return fmt.Errorf("%s 必须为大于 0 的整数", key) - } - return nil - case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "CriticalRateLimitDuration": - intValue, err := strconv.Atoi(value) - if err != nil || intValue <= 0 { - return fmt.Errorf("%s 必须为大于 0 的整数秒", key) - } - if intValue > maxDurationSeconds { - return fmt.Errorf("%s 不能大于 %d 秒", key, maxDurationSeconds) - } - return nil - default: - return nil - } -} - -func validatePositiveIntegerOption(key string, value string) error { - intValue, err := strconv.Atoi(value) - if err != nil || intValue <= 0 { - return fmt.Errorf("%s 必须为大于 0 的整数", key) - } - return nil -} - -func validateBooleanOption(key string, value string) error { - switch value { - case "true", "false": - return nil - default: - return fmt.Errorf("%s 必须为 true 或 false", key) - } -} - -func validateGeoIPOption(key string, value string) error { - if key != "GeoIPProvider" { - return nil - } - if !geoip.IsValidProvider(value) { - return fmt.Errorf("%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", key) - } - return nil -} - -func validateDatabaseCleanupOption(key string, value string) error { - switch key { - case "DatabaseAutoCleanupEnabled": - return validateBooleanOption(key, value) - case "DatabaseAutoCleanupRetentionDays": - intValue, err := strconv.Atoi(value) - if err != nil || intValue < 1 { - return fmt.Errorf("%s 必须为大于等于 1 的整数天", key) - } - return nil - default: - return nil - } -} - -func validateAgentOption(key string, value string) error { - switch key { - case "AgentWebsocketUpgradeEnabled": - return validateBooleanOption(key, strings.TrimSpace(value)) - default: - return nil - } -} - -func validateUptimeKumaOption(key string, value string, state map[string]string) error { - trimmed := strings.TrimSpace(value) - switch key { - case "UptimeKumaEnabled": - if err := validateBooleanOption(key, trimmed); err != nil { - return err - } - if trimmed == "true" { - url := strings.TrimSpace(state["UptimeKumaUrl"]) - username := strings.TrimSpace(state["UptimeKumaUsername"]) - password := strings.TrimSpace(state["UptimeKumaPassword"]) - if url == "" { - return fmt.Errorf("启用 Uptime Kuma 时地址不能为空") - } - if username == "" { - return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空") - } - if password == "" && common.UptimeKumaPassword == "" { - return fmt.Errorf("启用 Uptime Kuma 时密码不能为空") - } - } - case "UptimeKumaUsername": - if trimmed == "" && state["UptimeKumaEnabled"] == "true" { - return fmt.Errorf("启用 Uptime Kuma 时用户名不能为空") - } - case "UptimeKumaPassword": - // No specific format checks needed - case "UptimeKumaUrl": - if trimmed != "" { - if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") { - return fmt.Errorf("Uptime Kuma 地址必须以 http:// 或 https:// 开头") - } - } - case "UptimeKumaMonitorScope": - if trimmed != "all" && trimmed != "selected" { - return fmt.Errorf("监控范围必须为全部站点 (all) 或选择站点 (selected)") - } - case "UptimeKumaSyncInterval", "UptimeKumaInterval", "UptimeKumaRetryInterval", "UptimeKumaTimeout": - if err := validatePositiveIntegerOption(key, trimmed); err != nil { - return err - } - case "UptimeKumaRetry": - intValue, err := strconv.Atoi(trimmed) - if err != nil || intValue < 0 { - return fmt.Errorf("%s 必须为大于等于 0 的整数", key) - } - } - return nil -} - -func validateOpenRestyOption(key string, value string) error { - trimmed := strings.TrimSpace(value) - - switch key { - case "OpenRestyDefaultServerReturnStatus": - if err := validatePositiveIntegerOption(key, trimmed); err != nil { - return err - } - statusCode, _ := strconv.Atoi(trimmed) - if statusCode < 100 || statusCode > 999 { - return fmt.Errorf("%s 必须在 100 到 999 之间", key) - } - return nil - case "OpenRestyWorkerProcesses": - if trimmed == "auto" { - return nil - } - return validatePositiveIntegerOption(key, trimmed) - case "OpenRestyWorkerConnections", - "OpenRestyWorkerRlimitNofile", - "OpenRestyKeepaliveTimeout", - "OpenRestyKeepaliveRequests", - "OpenRestyClientHeaderTimeout", - "OpenRestyClientBodyTimeout", - "OpenRestySendTimeout", - "OpenRestyProxyConnectTimeout", - "OpenRestyProxySendTimeout", - "OpenRestyProxyReadTimeout", - "OpenRestyGzipMinLength": - return validatePositiveIntegerOption(key, trimmed) - case "OpenRestyGzipCompLevel": - if err := validatePositiveIntegerOption(key, trimmed); err != nil { - return err - } - level, _ := strconv.Atoi(trimmed) - if level > 9 { - return fmt.Errorf("%s 不能大于 9", key) - } - return nil - case "OpenRestyEventsUse": - if trimmed == "" { - return nil - } - switch trimmed { - case "epoll", "kqueue", "poll", "select", "rtsig", "/dev/poll", "eventport": - return nil - default: - return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key) - } - case "OpenRestyResolvers": - if trimmed == "" { - return nil - } - if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) { - return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key) - } - return nil - case "OpenRestyEventsMultiAcceptEnabled", - "OpenRestyWebsocketEnabled", - "OpenRestyHTTP3Enabled", - "OpenRestyProxyRequestBufferingEnabled", - "OpenRestyProxyBufferingEnabled", - "OpenRestyGzipEnabled", - "OpenRestyCacheEnabled", - "OpenRestyCacheLockEnabled": - return validateBooleanOption(key, trimmed) - case "OpenRestyProxyBuffers", "OpenRestyLargeClientHeaderBuffers": - if openRestyProxyBuffersPattern.MatchString(trimmed) { - return nil - } - return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key) - case "OpenRestyProxyBufferSize", "OpenRestyProxyBusyBuffersSize", "OpenRestyCacheMaxSize", "OpenRestyClientMaxBodySize": - if openRestySizePattern.MatchString(trimmed) { - return nil - } - return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key) - case "OpenRestyCachePath": - if strings.ContainsAny(trimmed, "\r\n\t") { - return fmt.Errorf("%s 不能包含换行或制表符", key) - } - return nil - case "OpenRestyCacheLevels": - if openRestyCacheLevelsPattern.MatchString(trimmed) { - return nil - } - return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key) - case "OpenRestyCacheInactive", "OpenRestyCacheLockTimeout": - if openRestyDurationTokenPattern.MatchString(trimmed) { - return nil - } - return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key) - case "OpenRestyCacheKeyTemplate": - if trimmed == "" { - return fmt.Errorf("%s 不能为空", key) - } - if strings.ContainsAny(trimmed, "\r\n") { - return fmt.Errorf("%s 不能包含换行", key) - } - return nil - case "OpenRestyCacheUseStale": - if trimmed == "" { - return fmt.Errorf("%s 不能为空", key) - } - allowedTokens := map[string]struct{}{ - "error": {}, "timeout": {}, "invalid_header": {}, "updating": {}, - "http_500": {}, "http_502": {}, "http_503": {}, "http_504": {}, - "http_403": {}, "http_404": {}, "http_429": {}, "off": {}, - } - for _, token := range strings.Fields(trimmed) { - if _, ok := allowedTokens[token]; !ok { - return fmt.Errorf("%s 包含不支持的值 %q", key, token) - } - } - return nil - case "OpenRestyMainConfigTemplate": - return service.ValidateOpenRestyMainConfigTemplate(value) - default: - return nil - } -} - -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("无法启用微信登录,请先填入微信登录相关配置信息!") - } - - } - - 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 - } - if err := validateAgentOption(option.Key, option.Value); err != nil { - return err - } - if err := validateUptimeKumaOption(option.Key, option.Value, state); 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 -// @Produce json -// @Success 200 {object} map[string]interface{} -// @Router /api/option/ [get] -func GetOptions(c *gin.Context) { - var options []*model.Option - common.OptionMapRWMutex.RLock() - for k, v := range common.OptionMap { - if strings.Contains(k, "Token") || strings.Contains(k, "Secret") || strings.Contains(k, "Password") { - continue - } - options = append(options, &model.Option{ - Key: k, - Value: utils.Interface2String(v), - }) - } - common.OptionMapRWMutex.RUnlock() - response.RespondSuccess(c, options) -} - -// UpdateOption godoc -// @Summary Update option -// @Tags Options -// @Accept json -// @Produce json -// @Param payload body model.Option true "Option payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/option/update [post] -func UpdateOption(c *gin.Context) { - var option model.Option - if !bind.JSON(c, &option) { - return - } - state := buildOptionValidationState([]model.Option{option}) - if err := validateOptionWithState(option, state); err != nil { - response.RespondFailure(c, err.Error()) - return - } - err := model.UpdateOption(option.Key, option.Value) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -// 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 !bind.JSON(c, &payload) { - return - } - if len(payload.Options) == 0 { - response.RespondBadRequest(c, "无效的参数") - return - } - - if err := updateOptions(payload.Options); err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccessMessage(c, "") -} diff --git a/openflare-server/internal/controller/option_test.go b/openflare-server/internal/controller/option_test.go deleted file mode 100644 index 92165f48..00000000 --- a/openflare-server/internal/controller/option_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package controller - -import ( - "testing" -) - -func TestValidateOpenRestyOption(t *testing.T) { - testCases := []struct { - name string - key string - value string - wantErr bool - }{ - {name: "default server status valid 421", key: "OpenRestyDefaultServerReturnStatus", value: "421"}, - {name: "default server status valid 200", key: "OpenRestyDefaultServerReturnStatus", value: "200"}, - {name: "default server status invalid 99", key: "OpenRestyDefaultServerReturnStatus", value: "99", wantErr: true}, - {name: "default server status invalid 1000", key: "OpenRestyDefaultServerReturnStatus", value: "1000", wantErr: true}, - {name: "default server status invalid abc", key: "OpenRestyDefaultServerReturnStatus", value: "abc", wantErr: true}, - {name: "worker processes auto", key: "OpenRestyWorkerProcesses", value: "auto"}, - {name: "worker processes number", key: "OpenRestyWorkerProcesses", value: "8"}, - {name: "worker processes invalid", key: "OpenRestyWorkerProcesses", value: "0", wantErr: true}, - {name: "events use empty", key: "OpenRestyEventsUse", value: ""}, - {name: "events use invalid", key: "OpenRestyEventsUse", value: "io_uring", wantErr: true}, - {name: "resolvers valid", key: "OpenRestyResolvers", value: "1.1.1.1 8.8.8.8"}, - {name: "resolvers invalid", key: "OpenRestyResolvers", value: "1.1.1.1; 8.8.8.8", wantErr: true}, - {name: "proxy buffers valid", key: "OpenRestyProxyBuffers", value: "16 16k"}, - {name: "proxy buffers invalid", key: "OpenRestyProxyBuffers", value: "16x16k", wantErr: true}, - {name: "cache max size valid", key: "OpenRestyCacheMaxSize", value: "2g"}, - {name: "cache max size invalid", key: "OpenRestyCacheMaxSize", value: "2gb", wantErr: true}, - {name: "client max body size valid", key: "OpenRestyClientMaxBodySize", value: "64m"}, - {name: "client max body size invalid", key: "OpenRestyClientMaxBodySize", value: "64mb", wantErr: true}, - {name: "large client header buffers valid", key: "OpenRestyLargeClientHeaderBuffers", value: "4 16k"}, - {name: "large client header buffers invalid", key: "OpenRestyLargeClientHeaderBuffers", value: "4x16k", wantErr: true}, - {name: "proxy request buffering valid", key: "OpenRestyProxyRequestBufferingEnabled", value: "true"}, - {name: "proxy request buffering invalid", key: "OpenRestyProxyRequestBufferingEnabled", value: "on", wantErr: true}, - {name: "websocket valid", key: "OpenRestyWebsocketEnabled", value: "false"}, - {name: "websocket invalid", key: "OpenRestyWebsocketEnabled", value: "off", wantErr: true}, - {name: "cache inactive valid", key: "OpenRestyCacheInactive", value: "30m"}, - {name: "cache inactive invalid", key: "OpenRestyCacheInactive", value: "30", wantErr: true}, - {name: "cache use stale valid", key: "OpenRestyCacheUseStale", value: "error timeout http_500"}, - {name: "cache use stale invalid", key: "OpenRestyCacheUseStale", value: "error whatever", wantErr: true}, - {name: "gzip level valid", key: "OpenRestyGzipCompLevel", value: "9"}, - {name: "gzip level invalid", key: "OpenRestyGzipCompLevel", value: "10", wantErr: true}, - } - - for _, testCase := range testCases { - err := validateOpenRestyOption(testCase.key, testCase.value) - if testCase.wantErr && err == nil { - t.Fatalf("%s: expected error", testCase.name) - } - if !testCase.wantErr && err != nil { - t.Fatalf("%s: unexpected error: %v", testCase.name, err) - } - } -} - -func TestValidateAgentOption(t *testing.T) { - if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "true"); err != nil { - t.Fatalf("expected websocket upgrade option to accept true: %v", err) - } - if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "false"); err != nil { - t.Fatalf("expected websocket upgrade option to accept false: %v", err) - } - if err := validateAgentOption("AgentWebsocketUpgradeEnabled", "on"); err == nil { - t.Fatal("expected websocket upgrade option to reject non-boolean value") - } -} - -func TestValidateUptimeKumaOption(t *testing.T) { - state := map[string]string{ - "UptimeKumaUrl": "http://localhost:3001", - "UptimeKumaUsername": "admin", - "UptimeKumaPassword": "password", - } - - testCases := []struct { - name string - key string - value string - wantErr bool - }{ - {name: "enabled true", key: "UptimeKumaEnabled", value: "true"}, - {name: "enabled false", key: "UptimeKumaEnabled", value: "false"}, - {name: "enabled invalid", key: "UptimeKumaEnabled", value: "on", wantErr: true}, - {name: "url http valid", key: "UptimeKumaUrl", value: "http://192.168.1.100:3001"}, - {name: "url https valid", key: "UptimeKumaUrl", value: "https://kuma.example.com"}, - {name: "url invalid", key: "UptimeKumaUrl", value: "kuma.example.com", wantErr: true}, - {name: "scope all", key: "UptimeKumaMonitorScope", value: "all"}, - {name: "scope selected", key: "UptimeKumaMonitorScope", value: "selected"}, - {name: "scope invalid", key: "UptimeKumaMonitorScope", value: "none", wantErr: true}, - {name: "sync interval valid", key: "UptimeKumaSyncInterval", value: "5"}, - {name: "sync interval invalid", key: "UptimeKumaSyncInterval", value: "0", wantErr: true}, - {name: "interval valid", key: "UptimeKumaInterval", value: "60"}, - {name: "interval invalid", key: "UptimeKumaInterval", value: "-60", wantErr: true}, - {name: "retry valid", key: "UptimeKumaRetry", value: "0"}, - {name: "retry positive valid", key: "UptimeKumaRetry", value: "3"}, - {name: "retry invalid", key: "UptimeKumaRetry", value: "-1", wantErr: true}, - } - - for _, tc := range testCases { - err := validateUptimeKumaOption(tc.key, tc.value, state) - if tc.wantErr && err == nil { - t.Fatalf("%s: expected error", tc.name) - } - if !tc.wantErr && err != nil { - t.Fatalf("%s: unexpected error: %v", tc.name, err) - } - } - - // Test enabling Uptime Kuma when URL or credentials are empty in state - stateEmpty := map[string]string{ - "UptimeKumaUrl": "", - "UptimeKumaUsername": "", - "UptimeKumaPassword": "", - } - if err := validateUptimeKumaOption("UptimeKumaEnabled", "true", stateEmpty); err == nil { - t.Fatal("expected error when enabling Uptime Kuma with empty URL/credentials in state") - } -} diff --git a/openflare-server/internal/controller/origin.go b/openflare-server/internal/controller/origin.go deleted file mode 100644 index 84b09ef6..00000000 --- a/openflare-server/internal/controller/origin.go +++ /dev/null @@ -1,73 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -func GetOrigins(c *gin.Context) { - origins, err := service.ListOrigins() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, origins) -} - -func GetOrigin(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - origin, err := service.GetOriginDetail(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, origin) -} - -func CreateOrigin(c *gin.Context) { - var input service.OriginInput - if !bind.JSON(c, &input) { - return - } - origin, err := service.CreateOrigin(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, origin) -} - -func UpdateOrigin(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - var input service.OriginInput - if !bind.JSON(c, &input) { - return - } - origin, err := service.UpdateOrigin(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, origin) -} - -func DeleteOrigin(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - if err := service.DeleteOrigin(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, nil) -} diff --git a/openflare-server/internal/controller/pages.go b/openflare-server/internal/controller/pages.go deleted file mode 100644 index c5694326..00000000 --- a/openflare-server/internal/controller/pages.go +++ /dev/null @@ -1,170 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -func ListPagesProjects(c *gin.Context) { - projects, err := service.ListPagesProjects() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, projects) -} - -func GetPagesProject(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - project, err := service.GetPagesProject(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, project) -} - -func CreatePagesProject(c *gin.Context) { - var input service.PagesProjectInput - if !bind.JSON(c, &input) { - return - } - project, err := service.CreatePagesProject(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, project) -} - -func UpdatePagesProject(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - var input service.PagesProjectInput - if !bind.JSON(c, &input) { - return - } - project, err := service.UpdatePagesProject(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, project) -} - -func DeletePagesProject(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - if err := service.DeletePagesProject(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, nil) -} - -func ListPagesDeployments(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - deployments, err := service.ListPagesProjectDeployments(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, deployments) -} - -func UploadPagesDeployment(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - file, err := c.FormFile("package") - if err != nil { - response.RespondBadRequest(c, "缺少 Pages 部署包") - return - } - deployment, err := service.UploadPagesDeployment( - id, - file, - c.PostForm("root_dir"), - c.PostForm("entry_file"), - c.GetString("username"), - ) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, deployment) -} - -func ActivatePagesDeployment(c *gin.Context) { - projectID, ok := bind.IDParam(c) - if !ok { - return - } - deploymentID, ok := bind.IDParamByName(c, "deployment_id") - if !ok { - return - } - project, err := service.ActivatePagesDeployment(projectID, deploymentID) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, project) -} - -func DeletePagesDeployment(c *gin.Context) { - projectID, ok := bind.IDParam(c) - if !ok { - return - } - deploymentID, ok := bind.IDParamByName(c, "deployment_id") - if !ok { - return - } - if err := service.DeletePagesDeployment(projectID, deploymentID); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, nil) -} - -func ListPagesDeploymentFiles(c *gin.Context) { - deploymentID, ok := bind.IDParamByName(c, "deployment_id") - if !ok { - return - } - files, err := service.ListPagesDeploymentFiles(deploymentID) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, files) -} - -func AgentDownloadPagesDeploymentPackage(c *gin.Context) { - deploymentID, ok := bind.IDParamByName(c, "deployment_id") - if !ok { - return - } - filePath, fileName, err := service.GetPagesDeploymentPackagePath(deploymentID) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - c.Header("Content-Disposition", "attachment; filename="+fileName) - c.File(filePath) -} diff --git a/openflare-server/internal/controller/proxy_route.go b/openflare-server/internal/controller/proxy_route.go deleted file mode 100644 index 310bb792..00000000 --- a/openflare-server/internal/controller/proxy_route.go +++ /dev/null @@ -1,119 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// GetProxyRoutes godoc -// @Summary List proxy routes -// @Tags ProxyRoutes -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/proxy-routes/ [get] -func GetProxyRoutes(c *gin.Context) { - routes, err := service.ListProxyRoutes() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, routes) -} - -// GetProxyRoute godoc -// @Summary Get proxy route detail -// @Tags ProxyRoutes -// @Produce json -// @Security OpenFlareTokenAuth -// @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, ok := bind.IDParam(c) - if !ok { - return - } - route, err := service.GetProxyRoute(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, route) -} - -// CreateProxyRoute godoc -// @Summary Create proxy route -// @Tags ProxyRoutes -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param payload body service.ProxyRouteInput true "Proxy route payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/proxy-routes/ [post] -func CreateProxyRoute(c *gin.Context) { - var input service.ProxyRouteInput - if !bind.JSON(c, &input) { - return - } - route, err := service.CreateProxyRoute(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, route) -} - -// UpdateProxyRoute godoc -// @Summary Update proxy route -// @Tags ProxyRoutes -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Route ID" -// @Param payload body service.ProxyRouteInput true "Proxy route payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/proxy-routes/{id}/update [post] -func UpdateProxyRoute(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - var input service.ProxyRouteInput - if !bind.JSON(c, &input) { - return - } - route, err := service.UpdateProxyRoute(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, route) -} - -// DeleteProxyRoute godoc -// @Summary Delete proxy route -// @Tags ProxyRoutes -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Route ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/proxy-routes/{id}/delete [post] -func DeleteProxyRoute(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - if err := service.DeleteProxyRoute(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, nil) -} diff --git a/openflare-server/internal/controller/relay.go b/openflare-server/internal/controller/relay.go deleted file mode 100644 index 828127eb..00000000 --- a/openflare-server/internal/controller/relay.go +++ /dev/null @@ -1,121 +0,0 @@ -package controller - -import ( - "log/slog" - "net" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" - "golang.org/x/net/websocket" -) - -// RelayHeartbeat godoc -// @Summary Report relay heartbeat -// @Tags Relay -// @Accept json -// @Produce json -// @Security AccessTokenAuth -// @Param payload body service.RelayHeartbeatPayload true "Relay heartbeat payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/relay/heartbeat [post] -func RelayHeartbeat(c *gin.Context) { - var payload service.RelayHeartbeatPayload - if !bind.JSON(c, &payload) { - return - } - payload.IP = service.ResolveReportedNodeIP(payload.IP, c.Request.RemoteAddr) - authNode, ok := c.Get("relay_node") - if !ok { - response.RespondUnauthorized(c, "无权进行此操作") - return - } - node := authNode.(*model.Node) - result, err := service.HeartbeatRelay(node, payload) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -// RelayWebSocket godoc -// @Summary Upgrade relay connection to websocket -// @Tags Relay -// @Security AccessTokenAuth -// @Router /api/relay/ws [get] -func RelayWebSocket(c *gin.Context) { - authNode, ok := c.Get("relay_node") - if !ok { - response.RespondUnauthorized(c, "无权进行此操作") - return - } - node := authNode.(*model.Node) - slog.Debug("relay ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr) - websocket.Handler(func(conn *websocket.Conn) { - client := service.RegisterRelayWSClient(node.NodeID) - defer service.UnregisterRelayWSClient(client) - defer func() { - _ = conn.Close() - slog.Debug("relay ws connection closed", "node_id", node.NodeID) - }() - - slog.Debug("relay ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr) - - go func() { - <-client.Done() - _ = conn.Close() - }() - - go streamRelayWSMessages(c, conn, client) - - for { - var message service.WSMessage - _ = conn.SetReadDeadline(time.Now().Add(agentWSReadTimeout())) - if err := websocket.JSON.Receive(conn, &message); err != nil { - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - slog.Debug("relay ws receive timeout", "node_id", node.NodeID) - return - } - slog.Debug("relay ws receive failed", "node_id", node.NodeID, "error", err) - return - } - slog.Debug("relay ws message received", "node_id", node.NodeID, "type", message.Type) - switch message.Type { - case "ping": - if !service.SendRelayWSPong(node.NodeID) { - slog.Debug("relay ws pong enqueue failed", "node_id", node.NodeID) - } - case "pong": - slog.Debug("relay ws pong received", "node_id", node.NodeID) - default: - slog.Debug("relay ws unsupported message type", "node_id", node.NodeID, "type", message.Type) - } - } - }).ServeHTTP(c.Writer, c.Request) -} - -func streamRelayWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) { - for { - select { - case <-c.Request.Context().Done(): - return - case <-client.Done(): - return - case message, ok := <-client.Messages(): - if !ok { - return - } - _ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout())) - if err := websocket.JSON.Send(conn, message); err != nil { - slog.Debug("relay ws send failed", "node_id", client.ID(), "error", err) - return - } - } - } -} diff --git a/openflare-server/internal/controller/tls_certificate.go b/openflare-server/internal/controller/tls_certificate.go deleted file mode 100644 index fbfb96c9..00000000 --- a/openflare-server/internal/controller/tls_certificate.go +++ /dev/null @@ -1,282 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// GetTLSCertificates godoc -// @Summary List TLS certificates -// @Tags TLSCertificates -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/tls-certificates/ [get] -func GetTLSCertificates(c *gin.Context) { - certificates, err := service.ListTLSCertificates() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificates) -} - -// GetTLSCertificate godoc -// @Summary Get TLS certificate detail -// @Tags TLSCertificates -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Certificate ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/{id} [get] -func GetTLSCertificate(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - certificate, err := service.GetTLSCertificate(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} - -// GetTLSCertificateContent godoc -// @Summary Get TLS certificate PEM content -// @Tags TLSCertificates -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Certificate ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/{id}/content [get] -func GetTLSCertificateContent(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - content, err := service.GetTLSCertificateContent(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, content) -} - -// CreateTLSCertificate godoc -// @Summary Create TLS certificate from PEM -// @Tags TLSCertificates -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param payload body service.TLSCertificateInput true "TLS certificate payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/ [post] -func CreateTLSCertificate(c *gin.Context) { - var input service.TLSCertificateInput - if !bind.JSON(c, &input) { - return - } - certificate, err := service.CreateTLSCertificate(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} - -// UpdateTLSCertificate godoc -// @Summary Update TLS certificate from PEM -// @Tags TLSCertificates -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Certificate ID" -// @Param payload body service.TLSCertificateInput true "TLS certificate payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/{id}/update [post] -func UpdateTLSCertificate(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - var input service.TLSCertificateInput - if !bind.JSON(c, &input) { - return - } - - certificate, err := service.UpdateTLSCertificate(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} - -// ImportTLSCertificateFile godoc -// @Summary Import TLS certificate from files -// @Tags TLSCertificates -// @Accept multipart/form-data -// @Produce json -// @Security OpenFlareTokenAuth -// @Param name formData string true "Certificate name" -// @Param remark formData string false "Remark" -// @Param cert_file formData file true "Certificate file" -// @Param key_file formData file true "Private key file" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/import-file [post] -func ImportTLSCertificateFile(c *gin.Context) { - name := c.PostForm("name") - remark := c.PostForm("remark") - certFile, err := c.FormFile("cert_file") - if err != nil { - response.RespondBadRequest(c, "缺少证书文件") - return - } - keyFile, err := c.FormFile("key_file") - if err != nil { - response.RespondBadRequest(c, "缺少私钥文件") - return - } - certificate, err := service.CreateTLSCertificateFromFiles(name, certFile, keyFile, remark) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} - -// DeleteTLSCertificate godoc -// @Summary Delete TLS certificate -// @Tags TLSCertificates -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Certificate ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/{id}/delete [post] -func DeleteTLSCertificate(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - if err := service.DeleteTLSCertificate(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, nil) -} - -// ApplyTLSCertificate godoc -// @Summary Apply TLS certificate via ACME -// @Tags TLSCertificates -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param payload body service.TLSApplyInput true "TLS apply payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/apply [post] -func ApplyTLSCertificate(c *gin.Context) { - var input service.TLSApplyInput - if !bind.JSON(c, &input) { - return - } - certificate, err := service.ApplyTLSCertificate(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} - -// UpdateAcmeCertificate godoc -// @Summary Update ACME TLS certificate -// @Tags TLSCertificates -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Certificate ID" -// @Param payload body service.TLSApplyInput true "TLS apply payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/{id}/update-acme [post] -func UpdateAcmeCertificate(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - var input service.TLSApplyInput - if !bind.JSON(c, &input) { - return - } - certificate, err := service.UpdateAcmeCertificate(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} - -// ConvertTLSCertificateToAcme godoc -// @Summary Convert uploaded TLS certificate to ACME managed certificate -// @Tags TLSCertificates -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Certificate ID" -// @Param payload body service.TLSApplyInput true "TLS apply payload" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/{id}/convert-acme [post] -func ConvertTLSCertificateToAcme(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - - var input service.TLSApplyInput - if !bind.JSON(c, &input) { - return - } - certificate, err := service.ConvertTLSCertificateToAcme(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} - -// RenewTLSCertificate godoc -// @Summary Renew TLS certificate -// @Tags TLSCertificates -// @Produce json -// @Security OpenFlareTokenAuth -// @Param id path int true "Certificate ID" -// @Success 200 {object} map[string]interface{} -// @Failure 400 {object} map[string]interface{} -// @Router /api/tls-certificates/{id}/renew [post] -func RenewTLSCertificate(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - certificate, err := service.RenewTLSCertificate(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, certificate) -} diff --git a/openflare-server/internal/controller/update.go b/openflare-server/internal/controller/update.go deleted file mode 100644 index f0988b11..00000000 --- a/openflare-server/internal/controller/update.go +++ /dev/null @@ -1,166 +0,0 @@ -package controller - -import ( - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" - "golang.org/x/net/websocket" -) - -type confirmManualUpgradeRequest struct { - UploadToken string `json:"upload_token"` -} - -type serverUpgradeRequest struct { - Channel string `json:"channel"` -} - -// GetLatestRelease godoc -// @Summary Get latest GitHub release -// @Tags Update -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/update/latest-release [get] -func GetLatestRelease(c *gin.Context) { - release, err := service.GetLatestServerRelease(c.Request.Context(), c.Query("channel")) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, release) -} - -// UpgradeServer godoc -// @Summary Upgrade server binary from latest GitHub release -// @Tags Update -// @Produce json -// @Success 200 {object} map[string]interface{} -// @Router /api/update/upgrade [post] -func UpgradeServer(c *gin.Context) { - var request serverUpgradeRequest - if c.Request.ContentLength > 0 { - if err := bind.OptionalJSON(c.Request.Body, &request); err != nil { - response.RespondBadRequest(c, "无效的参数") - return - } - } - release, err := service.ScheduleServerUpgrade(request.Channel) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccessWithExtras(c, release, gin.H{ - "message": "服务升级任务已启动,下载完成后将自动重启。", - }) -} - -// StreamServerUpgradeLogs godoc -// @Summary Stream server upgrade logs over websocket -// @Tags Update -// @Router /api/update/logs/ws [get] -func StreamServerUpgradeLogs(c *gin.Context) { - websocket.Handler(func(conn *websocket.Conn) { - defer func() { - _ = conn.Close() - }() - - updates, unsubscribe := service.SubscribeServerUpgradeStream() - defer unsubscribe() - - heartbeatTicker := time.NewTicker(15 * time.Second) - defer heartbeatTicker.Stop() - - for { - select { - case snapshot, ok := <-updates: - if !ok { - return - } - if err := websocket.JSON.Send(conn, snapshot); err != nil { - return - } - case <-heartbeatTicker.C: - if err := websocket.JSON.Send(conn, service.ServerUpgradeStreamSnapshot{}); err != nil { - return - } - case <-c.Request.Context().Done(): - return - } - } - }).ServeHTTP(c.Writer, c.Request) -} - -// UploadManualServerBinary godoc -// @Summary Upload server binary and inspect version before upgrade -// @Tags Update -// @Accept mpfd -// @Produce json -// @Success 200 {object} map[string]interface{} -// @Router /api/update/manual-upload [post] -func UploadManualServerBinary(c *gin.Context) { - response.RespondFailure(c, "手动升级功能已禁用") - return - // - //fileHeader, err := c.FormFile("binary") - //if err != nil { - // response.RespondFailure(c, "请先选择要上传的服务端二进制文件。") - // return - //} - // - //file, err := fileHeader.Open() - //if err != nil { - // response.RespondFailure(c, "读取上传文件失败。") - // return - //} - //defer func() { - // _ = file.Close() - //}() - // - //info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file) - //if err != nil { - // response.RespondFailure(c, err.Error()) - // return - //} - // - //message := strings.TrimSpace(info.ComparisonMessage) - //if message == "" { - // message = "已完成上传并检查升级包版本。" - //} - // - //response.RespondSuccessWithExtras(c, info, gin.H{ - // "message": message, - //}) -} - -// ConfirmManualServerUpgrade godoc -// @Summary Confirm upgrade with previously uploaded server binary -// @Tags Update -// @Accept json -// @Produce json -// @Success 200 {object} map[string]interface{} -// @Router /api/update/manual-upgrade [post] -func ConfirmManualServerUpgrade(c *gin.Context) { - response.RespondFailure(c, "手动升级功能已禁用") - return - // - //var request confirmManualUpgradeRequest - //if !bind.JSON(c, &request) { - // return - //} - // - //info, err := service.ConfirmManualServerUpgrade(request.UploadToken) - //if err != nil { - // response.RespondFailure(c, err.Error()) - // return - //} - // - //response.RespondSuccessWithExtras(c, info, gin.H{ - // "message": "服务升级任务已启动,确认无误后将自动重启。", - //}) -} diff --git a/openflare-server/internal/controller/uptimekuma.go b/openflare-server/internal/controller/uptimekuma.go deleted file mode 100644 index 11ee836c..00000000 --- a/openflare-server/internal/controller/uptimekuma.go +++ /dev/null @@ -1,25 +0,0 @@ -package controller - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// SyncUptimeKuma godoc -// @Summary Manually trigger Uptime Kuma sync -// @Tags UptimeKuma -// @Accept json -// @Produce json -// @Security OpenFlareTokenAuth -// @Success 200 {object} map[string]interface{} -// @Router /api/uptimekuma/sync [post] -func SyncUptimeKuma(c *gin.Context) { - err := service.SyncToUptimeKuma() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "同步成功") -} diff --git a/openflare-server/internal/controller/user.go b/openflare-server/internal/controller/user.go deleted file mode 100644 index 9e889d82..00000000 --- a/openflare-server/internal/controller/user.go +++ /dev/null @@ -1,415 +0,0 @@ -package controller - -import ( - "strconv" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/middleware" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/utils/security" - "github.com/rain-kl/openflare/openflare-server/internal/utils/validation" - - "github.com/gin-gonic/gin" -) - -type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -func Login(c *gin.Context) { - if !common.PasswordLoginEnabled { - response.RespondFailure(c, "管理员关闭了密码登录") - return - } - var loginRequest LoginRequest - if !bind.JSON(c, &loginRequest) { - return - } - username := loginRequest.Username - password := loginRequest.Password - if username == "" || password == "" { - response.RespondFailure(c, "无效的参数") - return - } - user := model.User{ - Username: username, - Password: password, - } - err := user.ValidateAndFill() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - setupLogin(&user, c) -} - -// setup token and then return user info -func setLoginToken(user *model.User) (*model.User, error) { - // Generate a signed JWT using gin-jwt middleware - tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user) - if err != nil { - return nil, err - } - // Persist JWT in DB so we can invalidate it on logout - if err := model.DB.Model(user).Update("token", tokenString).Error; err != nil { - return nil, err - } - cleanUser := &model.User{ - Id: user.Id, - Username: user.Username, - DisplayName: user.DisplayName, - Role: user.Role, - Status: user.Status, - Token: tokenString, - } - return cleanUser, nil -} - -func setupLogin(user *model.User, c *gin.Context) { - cleanUser, err := setLoginToken(user) - if err != nil { - response.RespondFailure(c, "无法保存会话信息,请重试") - return - } - response.RespondSuccess(c, *cleanUser) -} - -func Logout(c *gin.Context) { - token := c.GetHeader("OpenFlare-Token") - if token != "" { - user := model.ValidateUserToken(token) - if user != nil && user.Id != 0 { - if err := model.DB.Model(user).Update("token", "").Error; err != nil { - response.RespondFailure(c, err.Error()) - return - } - } - } - response.RespondSuccessMessage(c, "") -} - -func currentUserFromOpenFlareToken(c *gin.Context) *model.User { - token := c.GetHeader("OpenFlare-Token") - if token == "" { - return nil - } - return model.ValidateUserToken(token) -} - -func Register(c *gin.Context) { - response.RespondFailure(c, "非法请求") -} - -func GetAllUsers(c *gin.Context) { - p, _ := strconv.Atoi(c.Query("p")) - if p < 0 { - p = 0 - } - users, err := model.GetAllUsers(p*common.ItemsPerPage, common.ItemsPerPage) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, users) -} - -func SearchUsers(c *gin.Context) { - keyword := c.Query("keyword") - users, err := model.SearchUsers(keyword) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, users) -} - -func GetUser(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - user, err := model.GetUserById(int(id), false) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - myRole := c.GetInt("role") - if myRole <= user.Role { - response.RespondFailure(c, "无权获取同级或更高等级用户的信息") - return - } - response.RespondSuccess(c, user) -} - -func GenerateToken(c *gin.Context) { - id := c.GetInt("id") - user, err := model.GetUserById(id, true) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - // Generate a fresh JWT for the user - tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user) - if err != nil { - response.RespondFailure(c, "生成 Token 失败: "+err.Error()) - return - } - user.Token = tokenString - if err := user.Update(false); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, user.Token) -} - -func GetSelf(c *gin.Context) { - id := c.GetInt("id") - user, err := model.GetUserById(id, false) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, user) -} - -func UpdateUser(c *gin.Context) { - var updatedUser model.User - if !bind.JSON(c, &updatedUser) { - return - } - if updatedUser.Id == 0 { - response.RespondFailure(c, "无效的参数") - return - } - if updatedUser.Password == "" { - updatedUser.Password = "$I_LOVE_U" // make Validator happy :) - } - if err := validation.Validate.Struct(&updatedUser); err != nil { - response.RespondFailure(c, "输入不合法 "+err.Error()) - return - } - originUser, err := model.GetUserById(updatedUser.Id, false) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - myRole := c.GetInt("role") - if myRole <= originUser.Role { - response.RespondFailure(c, "无权更新同权限等级或更高权限等级的用户信息") - return - } - if myRole <= updatedUser.Role { - response.RespondFailure(c, "无权将其他用户权限等级提升到大于等于自己的权限等级") - return - } - if updatedUser.Password == "$I_LOVE_U" { - updatedUser.Password = "" // rollback to what it should be - } - updatePassword := updatedUser.Password != "" - if err := updatedUser.Update(updatePassword); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func UpdateSelf(c *gin.Context) { - var user model.User - if !bind.JSON(c, &user) { - return - } - if user.Password == "" { - user.Password = "$I_LOVE_U" // make Validator happy :) - } - if err := validation.Validate.Struct(&user); err != nil { - response.RespondFailure(c, "输入不合法 "+err.Error()) - return - } - - cleanUser := model.User{ - Id: c.GetInt("id"), - Username: user.Username, - Password: user.Password, - DisplayName: user.DisplayName, - } - if user.Password == "$I_LOVE_U" { - user.Password = "" // rollback to what it should be - cleanUser.Password = "" - } - updatePassword := user.Password != "" - if err := cleanUser.Update(updatePassword); err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccessMessage(c, "") -} - -func DeleteUser(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - originUser, err := model.GetUserById(int(id), false) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - myRole := c.GetInt("role") - if myRole <= originUser.Role { - response.RespondFailure(c, "无权删除同权限等级或更高权限等级的用户") - return - } - err = model.DeleteUserById(int(id)) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func DeleteSelf(c *gin.Context) { - id := c.GetInt("id") - err := model.DeleteUserById(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func CreateUser(c *gin.Context) { - var user model.User - if !bind.JSON(c, &user) { - return - } - if user.Username == "" || user.Password == "" { - response.RespondFailure(c, "无效的参数") - return - } - if user.DisplayName == "" { - user.DisplayName = user.Username - } - myRole := c.GetInt("role") - if user.Role >= myRole { - response.RespondFailure(c, "无法创建权限大于等于自己的用户") - return - } - // Even for admin users, we cannot fully trust them! - cleanUser := model.User{ - Username: user.Username, - Password: user.Password, - DisplayName: user.DisplayName, - } - if err := cleanUser.Insert(); err != nil { - response.RespondFailure(c, err.Error()) - return - } - - response.RespondSuccessMessage(c, "") -} - -type ManageRequest struct { - Username string `json:"username"` - Action string `json:"action"` -} - -// ManageUser Only admin user can do this -func ManageUser(c *gin.Context) { - var req ManageRequest - if !bind.JSON(c, &req) { - return - } - user := model.User{ - Username: req.Username, - } - // Fill attributes - model.DB.Where(&user).First(&user) - if user.Id == 0 { - response.RespondFailure(c, "用户不存在") - return - } - myRole := c.GetInt("role") - if myRole <= user.Role && myRole != common.RoleRootUser { - response.RespondFailure(c, "无权更新同权限等级或更高权限等级的用户信息") - return - } - switch req.Action { - case "disable": - user.Status = common.UserStatusDisabled - if user.Role == common.RoleRootUser { - response.RespondFailure(c, "无法禁用超级管理员用户") - return - } - case "enable": - user.Status = common.UserStatusEnabled - case "delete": - if user.Role == common.RoleRootUser { - response.RespondFailure(c, "无法删除超级管理员用户") - return - } - if err := user.Delete(); err != nil { - response.RespondFailure(c, err.Error()) - return - } - case "promote": - if myRole != common.RoleRootUser { - response.RespondFailure(c, "普通管理员用户无法提升其他用户为管理员") - return - } - if user.Role >= common.RoleAdminUser { - response.RespondFailure(c, "该用户已经是管理员") - return - } - user.Role = common.RoleAdminUser - case "demote": - if user.Role == common.RoleRootUser { - response.RespondFailure(c, "无法降级超级管理员用户") - return - } - if user.Role == common.RoleCommonUser { - response.RespondFailure(c, "该用户已经是普通用户") - return - } - user.Role = common.RoleCommonUser - } - - if err := user.Update(false); err != nil { - response.RespondFailure(c, err.Error()) - return - } - clearUser := model.User{ - Role: user.Role, - Status: user.Status, - } - response.RespondSuccess(c, clearUser) -} - -func EmailBind(c *gin.Context) { - email := c.Query("email") - code := c.Query("code") - if !security.VerifyCodeWithKey(email, code, security.EmailVerificationPurpose) { - response.RespondFailure(c, "验证码错误或已过期") - return - } - id := c.GetInt("id") - user := model.User{ - Id: id, - } - err := user.FillUserById() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - user.Email = email - // no need to check if this email already taken, because we have used verification code to check it - err = user.Update(false) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} diff --git a/openflare-server/internal/controller/waf.go b/openflare-server/internal/controller/waf.go deleted file mode 100644 index a6eac65e..00000000 --- a/openflare-server/internal/controller/waf.go +++ /dev/null @@ -1,225 +0,0 @@ -package controller - -import ( - "strconv" - - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/controller/bind" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -type wafIDsRequest struct { - IDs []uint `json:"ids"` -} - -func ListWAFRuleGroups(c *gin.Context) { - groups, err := service.ListWAFRuleGroups() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, groups) -} - -func GetWAFRuleGroup(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - group, err := service.GetWAFRuleGroup(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, group) -} - -func CreateWAFRuleGroup(c *gin.Context) { - var input service.WAFRuleGroupInput - if !bind.JSON(c, &input) { - return - } - group, err := service.CreateWAFRuleGroup(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, group) -} - -func UpdateWAFRuleGroup(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - var input service.WAFRuleGroupInput - if !bind.JSON(c, &input) { - return - } - group, err := service.UpdateWAFRuleGroup(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, group) -} - -func DeleteWAFRuleGroup(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - if err := service.DeleteWAFRuleGroup(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func ReplaceWAFRuleGroupSites(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - var request wafIDsRequest - if !bind.JSON(c, &request) { - return - } - group, err := service.ReplaceWAFRuleGroupSites(id, request.IDs) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, group) -} - -func GetWAFSiteRuleGroups(c *gin.Context) { - routeID, ok := parseUintPathParam(c, "route_id") - if !ok { - return - } - view, err := service.GetWAFSiteRuleGroups(routeID) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, view) -} - -func ReplaceWAFSiteRuleGroups(c *gin.Context) { - routeID, ok := parseUintPathParam(c, "route_id") - if !ok { - return - } - var request wafIDsRequest - if !bind.JSON(c, &request) { - return - } - view, err := service.ReplaceWAFSiteRuleGroups(routeID, request.IDs) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, view) -} - -func ListWAFIPGroups(c *gin.Context) { - groups, err := service.ListWAFIPGroups() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, groups) -} - -func GetWAFIPGroup(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - group, err := service.GetWAFIPGroup(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, group) -} - -func CreateWAFIPGroup(c *gin.Context) { - var input service.WAFIPGroupInput - if !bind.JSON(c, &input) { - return - } - group, err := service.CreateWAFIPGroup(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, group) -} - -func UpdateWAFIPGroup(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - var input service.WAFIPGroupInput - if !bind.JSON(c, &input) { - return - } - group, err := service.UpdateWAFIPGroup(id, input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, group) -} - -func DeleteWAFIPGroup(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - if err := service.DeleteWAFIPGroup(id); err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") -} - -func SyncWAFIPGroup(c *gin.Context) { - id, ok := bind.IDParam(c) - if !ok { - return - } - result, err := service.SyncWAFIPGroup(id) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -func TestWAFIPGroupAutoConfig(c *gin.Context) { - var input service.WAFIPGroupAutoTestInput - if !bind.JSON(c, &input) { - return - } - result, err := service.TestWAFIPGroupAutoConfig(input) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccess(c, result) -} - -func parseUintPathParam(c *gin.Context, name string) (uint, bool) { - id, err := strconv.ParseUint(c.Param(name), 10, 64) - if err != nil || id == 0 { - response.RespondBadRequest(c, "invalid id") - return 0, false - } - return uint(id), true -} diff --git a/openflare-server/internal/controller/wechat.go b/openflare-server/internal/controller/wechat.go deleted file mode 100644 index 1af43935..00000000 --- a/openflare-server/internal/controller/wechat.go +++ /dev/null @@ -1,125 +0,0 @@ -package controller - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "github.com/gin-gonic/gin" -) - -type wechatLoginResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Data string `json:"data"` -} - -func getWeChatIdByCode(code string) (string, error) { - if code == "" { - return "", errors.New("无效的参数") - } - req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil) - if err != nil { - return "", err - } - req.Header.Set("Authorization", common.WeChatServerToken) - client := http.Client{ - Timeout: 5 * time.Second, - } - httpResponse, err := client.Do(req) - if err != nil { - return "", err - } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - slog.Error("Failed to close response body", "error", err) - } - }(httpResponse.Body) - var res wechatLoginResponse - err = json.NewDecoder(httpResponse.Body).Decode(&res) - if err != nil { - return "", err - } - if !res.Success { - return "", errors.New(res.Message) - } - if res.Data == "" { - return "", errors.New("验证码错误或已过期") - } - return res.Data, nil -} - -func WeChatAuth(c *gin.Context) { - if !common.WeChatAuthEnabled { - response.RespondFailure(c, "管理员未开启通过微信登录以及注册") - return - } - code := c.Query("code") - wechatId, err := getWeChatIdByCode(code) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - user := model.User{ - WeChatId: wechatId, - } - if model.IsWeChatIdAlreadyTaken(wechatId) { - err := user.FillUserByWeChatId() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - } else { - response.RespondFailure(c, "管理员关闭了新用户注册") - return - } - - if user.Status != common.UserStatusEnabled { - response.RespondFailure(c, "用户已被封禁") - return - } - setupLogin(&user, c) -} - -func WeChatBind(c *gin.Context) { - if !common.WeChatAuthEnabled { - response.RespondFailure(c, "管理员未开启通过微信登录以及注册") - return - } - code := c.Query("code") - wechatId, err := getWeChatIdByCode(code) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - if model.IsWeChatIdAlreadyTaken(wechatId) { - response.RespondFailure(c, "该微信账号已被绑定") - return - } - id := c.GetInt("id") - user := model.User{ - Id: id, - } - err = user.FillUserById() - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - user.WeChatId = wechatId - err = user.Update(false) - if err != nil { - response.RespondFailure(c, err.Error()) - return - } - response.RespondSuccessMessage(c, "") - return -} diff --git a/Wavelet/internal/db/clickhouse.go b/openflare-server/internal/db/clickhouse.go similarity index 100% rename from Wavelet/internal/db/clickhouse.go rename to openflare-server/internal/db/clickhouse.go diff --git a/Wavelet/internal/db/errs.go b/openflare-server/internal/db/errs.go similarity index 100% rename from Wavelet/internal/db/errs.go rename to openflare-server/internal/db/errs.go diff --git a/Wavelet/internal/db/idgen/snowflake.go b/openflare-server/internal/db/idgen/snowflake.go similarity index 100% rename from Wavelet/internal/db/idgen/snowflake.go rename to openflare-server/internal/db/idgen/snowflake.go diff --git a/Wavelet/internal/db/idgen/snowflake_test.go b/openflare-server/internal/db/idgen/snowflake_test.go similarity index 100% rename from Wavelet/internal/db/idgen/snowflake_test.go rename to openflare-server/internal/db/idgen/snowflake_test.go diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql b/openflare-server/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606100001_create_schedules.sql b/openflare-server/internal/db/migrator/goose/postgres/202606100001_create_schedules.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606100001_create_schedules.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606100001_create_schedules.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606100002_access_token_is_admin.sql b/openflare-server/internal/db/migrator/goose/postgres/202606100002_access_token_is_admin.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606100002_access_token_is_admin.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606100002_access_token_is_admin.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql b/openflare-server/internal/db/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql b/openflare-server/internal/db/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql b/openflare-server/internal/db/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql b/openflare-server/internal/db/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql b/openflare-server/internal/db/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql b/openflare-server/internal/db/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql b/openflare-server/internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606130001_add_upload_access_mode.sql b/openflare-server/internal/db/migrator/goose/postgres/202606130001_add_upload_access_mode.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606130001_add_upload_access_mode.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606130001_add_upload_access_mode.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606130002_expand_system_config_value.sql b/openflare-server/internal/db/migrator/goose/postgres/202606130002_expand_system_config_value.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606130002_expand_system_config_value.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606130002_expand_system_config_value.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606130003_add_storage_config.sql b/openflare-server/internal/db/migrator/goose/postgres/202606130003_add_storage_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606130003_add_storage_config.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606130003_add_storage_config.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606140001_create_push_tables.sql b/openflare-server/internal/db/migrator/goose/postgres/202606140001_create_push_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606140001_create_push_tables.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606140001_create_push_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606140003_add_system_user.sql b/openflare-server/internal/db/migrator/goose/postgres/202606140003_add_system_user.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606140003_add_system_user.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606140003_add_system_user.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606140004_create_push_channels.sql b/openflare-server/internal/db/migrator/goose/postgres/202606140004_create_push_channels.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606140004_create_push_channels.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606140004_create_push_channels.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql b/openflare-server/internal/db/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql b/openflare-server/internal/db/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606160001_remove_push_config.sql b/openflare-server/internal/db/migrator/goose/postgres/202606160001_remove_push_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606160001_remove_push_config.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606160001_remove_push_config.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql b/openflare-server/internal/db/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606170002_create_upload_stats_table.sql b/openflare-server/internal/db/migrator/goose/postgres/202606170002_create_upload_stats_table.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606170002_create_upload_stats_table.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606170002_create_upload_stats_table.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606170003_backfill_upload_stats.sql b/openflare-server/internal/db/migrator/goose/postgres/202606170003_backfill_upload_stats.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606170003_backfill_upload_stats.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606170003_backfill_upload_stats.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql b/openflare-server/internal/db/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190001_create_of_options.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190001_create_of_options.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190001_create_of_options.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190001_create_of_options.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190002_create_of_origins.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190002_create_of_origins.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190002_create_of_origins.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190002_create_of_origins.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190003_create_of_apply_logs.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190003_create_of_apply_logs.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190003_create_of_apply_logs.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190003_create_of_apply_logs.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190005_create_of_nodes.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190005_create_of_nodes.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190005_create_of_nodes.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190005_create_of_nodes.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190006_create_of_waf_tables.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190006_create_of_waf_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190006_create_of_waf_tables.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190006_create_of_waf_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190007_create_of_tls_tables.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190007_create_of_tls_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190007_create_of_tls_tables.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190007_create_of_tls_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190008_create_of_config_versions.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190008_create_of_config_versions.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190008_create_of_config_versions.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190008_create_of_config_versions.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190009_create_of_pages_tables.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190009_create_of_pages_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190009_create_of_pages_tables.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190009_create_of_pages_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190010_create_of_observability_tables.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190010_create_of_observability_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190010_create_of_observability_tables.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190010_create_of_observability_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql b/openflare-server/internal/db/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql b/openflare-server/internal/db/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606200002_update_security_defaults.sql b/openflare-server/internal/db/migrator/goose/postgres/202606200002_update_security_defaults.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606200002_update_security_defaults.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606200002_update_security_defaults.sql diff --git a/Wavelet/internal/db/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql b/openflare-server/internal/db/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql rename to openflare-server/internal/db/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606100001_create_schedules.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606100001_create_schedules.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606100001_create_schedules.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606100001_create_schedules.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606100002_access_token_is_admin.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606100002_access_token_is_admin.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606100002_access_token_is_admin.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606100002_access_token_is_admin.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606130002_expand_system_config_value.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606130002_expand_system_config_value.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606130002_expand_system_config_value.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606130002_expand_system_config_value.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606130003_add_storage_config.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606130003_add_storage_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606130003_add_storage_config.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606130003_add_storage_config.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606140001_create_push_tables.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606140001_create_push_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606140001_create_push_tables.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606140001_create_push_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606140003_add_system_user.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606140003_add_system_user.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606140003_add_system_user.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606140003_add_system_user.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606140004_create_push_channels.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606140004_create_push_channels.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606140004_create_push_channels.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606140004_create_push_channels.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606160001_remove_push_config.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606160001_remove_push_config.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606160001_remove_push_config.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606160001_remove_push_config.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190001_create_of_options.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190001_create_of_options.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190001_create_of_options.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190001_create_of_options.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190002_create_of_origins.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190002_create_of_origins.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190002_create_of_origins.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190002_create_of_origins.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190005_create_of_nodes.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190005_create_of_nodes.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190005_create_of_nodes.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190005_create_of_nodes.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190008_create_of_config_versions.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190008_create_of_config_versions.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190008_create_of_config_versions.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190008_create_of_config_versions.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606200002_update_security_defaults.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606200002_update_security_defaults.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606200002_update_security_defaults.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606200002_update_security_defaults.sql diff --git a/Wavelet/internal/db/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql b/openflare-server/internal/db/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql similarity index 100% rename from Wavelet/internal/db/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql rename to openflare-server/internal/db/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql diff --git a/Wavelet/internal/db/migrator/migrator.go b/openflare-server/internal/db/migrator/migrator.go similarity index 100% rename from Wavelet/internal/db/migrator/migrator.go rename to openflare-server/internal/db/migrator/migrator.go diff --git a/Wavelet/internal/db/migrator/migrator_test.go b/openflare-server/internal/db/migrator/migrator_test.go similarity index 100% rename from Wavelet/internal/db/migrator/migrator_test.go rename to openflare-server/internal/db/migrator/migrator_test.go diff --git a/Wavelet/internal/db/postgres.go b/openflare-server/internal/db/postgres.go similarity index 100% rename from Wavelet/internal/db/postgres.go rename to openflare-server/internal/db/postgres.go diff --git a/Wavelet/internal/db/postgres_logger.go b/openflare-server/internal/db/postgres_logger.go similarity index 100% rename from Wavelet/internal/db/postgres_logger.go rename to openflare-server/internal/db/postgres_logger.go diff --git a/Wavelet/internal/db/redis.go b/openflare-server/internal/db/redis.go similarity index 100% rename from Wavelet/internal/db/redis.go rename to openflare-server/internal/db/redis.go diff --git a/Wavelet/internal/diskcache/cache.go b/openflare-server/internal/diskcache/cache.go similarity index 100% rename from Wavelet/internal/diskcache/cache.go rename to openflare-server/internal/diskcache/cache.go diff --git a/Wavelet/internal/diskcache/cache_test.go b/openflare-server/internal/diskcache/cache_test.go similarity index 100% rename from Wavelet/internal/diskcache/cache_test.go rename to openflare-server/internal/diskcache/cache_test.go diff --git a/openflare-server/internal/job/cron.go b/openflare-server/internal/job/cron.go deleted file mode 100644 index 73416e12..00000000 --- a/openflare-server/internal/job/cron.go +++ /dev/null @@ -1,44 +0,0 @@ -package job - -import ( - "log/slog" - - "github.com/robfig/cron/v3" -) - -var cronRunner *cron.Cron - -func InitCronJobs() { - cronRunner = cron.New() - - // Register SSL renew job - _, err := cronRunner.AddJob("0 0 * * *", &SSLRenewJob{}) - if err != nil { - slog.Error("failed to register SSL renew cron job", "error", err) - } else { - slog.Info("registered SSL renew cron job") - } - - _, err = cronRunner.AddJob("@every 5m", &WAFIPGroupSyncJob{}) - if err != nil { - slog.Error("failed to register WAF IP group sync cron job", "error", err) - } else { - slog.Info("registered WAF IP group sync cron job") - } - - // Register Uptime Kuma sync job (check every minute) - _, err = cronRunner.AddJob("* * * * *", &UptimeKumaSyncJob{}) - if err != nil { - slog.Error("failed to register Uptime Kuma sync cron job", "error", err) - } else { - slog.Info("registered Uptime Kuma sync cron job") - } - - cronRunner.Start() -} - -func StopCronJobs() { - if cronRunner != nil { - cronRunner.Stop() - } -} diff --git a/openflare-server/internal/job/ssl_renew.go b/openflare-server/internal/job/ssl_renew.go deleted file mode 100644 index 282c2aee..00000000 --- a/openflare-server/internal/job/ssl_renew.go +++ /dev/null @@ -1,44 +0,0 @@ -package job - -import ( - "log/slog" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/service" -) - -type SSLRenewJob struct { -} - -func (j *SSLRenewJob) Run() { - slog.Info("The scheduled certificate update task is currently in progress ...") - - certificates, err := model.ListTLSCertificates() - if err != nil { - slog.Error("failed to list certificates in SSL renew job", "error", err) - return - } - - now := time.Now() - for _, cert := range certificates { - if !cert.AutoRenew || cert.Provider != "acme" || cert.ApplyStatus == "applying" { - continue - } - - sub := cert.NotAfter.Sub(now) - // Expiring in less than 7 days (7 * 24 hours) - if sub.Hours() < 168 { - slog.Info("Update the SSL certificate for the domain", "domain", cert.PrimaryDomain) - - // Invoke renew process (async go-routine handles Lego inside) - _, err := service.RenewTLSCertificate(cert.ID) - if err != nil { - slog.Error("Failed to update the SSL certificate", "domain", cert.PrimaryDomain, "error", err) - continue - } - slog.Info("Triggered the SSL certificate renew for domain", "domain", cert.PrimaryDomain) - } - } - slog.Info("The scheduled certificate update task has completed") -} diff --git a/openflare-server/internal/job/uptimekuma.go b/openflare-server/internal/job/uptimekuma.go deleted file mode 100644 index f8410656..00000000 --- a/openflare-server/internal/job/uptimekuma.go +++ /dev/null @@ -1,44 +0,0 @@ -package job - -import ( - "log/slog" - "sync" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/service" -) - -var lastUptimeKumaSyncTime time.Time -var uptimeKumaSyncMutex sync.Mutex - -type UptimeKumaSyncJob struct{} - -func (j *UptimeKumaSyncJob) Run() { - if !common.UptimeKumaEnabled { - return - } - - interval := common.UptimeKumaSyncInterval - if interval <= 0 { - interval = 5 - } - - if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute { - return - } - - if !uptimeKumaSyncMutex.TryLock() { - slog.Warn("Uptime Kuma sync job is already running, skipping this scheduled run") - return - } - defer uptimeKumaSyncMutex.Unlock() - - slog.Info("Starting scheduled Uptime Kuma sync") - if err := service.SyncToUptimeKuma(); err != nil { - slog.Error("Uptime Kuma sync failed", "error", err) - } else { - lastUptimeKumaSyncTime = time.Now() - slog.Info("Uptime Kuma sync completed successfully") - } -} diff --git a/openflare-server/internal/job/waf_ip_group_sync.go b/openflare-server/internal/job/waf_ip_group_sync.go deleted file mode 100644 index 77e86772..00000000 --- a/openflare-server/internal/job/waf_ip_group_sync.go +++ /dev/null @@ -1,15 +0,0 @@ -package job - -import ( - "log/slog" - - "github.com/rain-kl/openflare/openflare-server/internal/service" -) - -type WAFIPGroupSyncJob struct{} - -func (j *WAFIPGroupSyncJob) Run() { - if err := service.SyncDueWAFIPGroups(); err != nil { - slog.Error("failed to sync due waf ip groups", "error", err) - } -} diff --git a/Wavelet/internal/listener/admin_login.go b/openflare-server/internal/listener/admin_login.go similarity index 100% rename from Wavelet/internal/listener/admin_login.go rename to openflare-server/internal/listener/admin_login.go diff --git a/openflare-server/internal/middleware/agent-auth.go b/openflare-server/internal/middleware/agent-auth.go deleted file mode 100644 index f30e29dd..00000000 --- a/openflare-server/internal/middleware/agent-auth.go +++ /dev/null @@ -1,39 +0,0 @@ -package middleware - -import ( - "github.com/gin-gonic/gin" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/service" -) - -func AgentAuth() func(c *gin.Context) { - return func(c *gin.Context) { - token := c.GetHeader("X-Agent-Token") - node, err := service.AuthenticateAccessToken(token) - if err != nil { - response.RespondUnauthorized(c, "无权进行此操作,Agent Token 无效") - c.Abort() - return - } - c.Set("agent_node", node) - c.Next() - } -} - -func AgentRegisterAuth() func(c *gin.Context) { - return func(c *gin.Context) { - token := c.GetHeader("X-Agent-Token") - if node, err := service.AuthenticateAccessToken(token); err == nil { - c.Set("agent_node", node) - c.Next() - return - } - if err := service.ValidateDiscoveryToken(token); err != nil { - response.RespondUnauthorized(c, "无权进行此操作,注册 Token 无效") - c.Abort() - return - } - c.Set("discovery_enabled", true) - c.Next() - } -} diff --git a/openflare-server/internal/middleware/auth.go b/openflare-server/internal/middleware/auth.go deleted file mode 100644 index 4fbd3e83..00000000 --- a/openflare-server/internal/middleware/auth.go +++ /dev/null @@ -1,101 +0,0 @@ -package middleware - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - jwt "github.com/appleboy/gin-jwt/v2" - "github.com/gin-gonic/gin" -) - -const OpenFlareTokenHeader = "OpenFlare-Token" - -func authHelper(c *gin.Context, minRole int) { - tokenStr := c.GetHeader(OpenFlareTokenHeader) - if tokenStr == "" { - response.RespondUnauthorized(c, "无权进行此操作,未登录或 token 无效") - c.Abort() - return - } - - token, err := JWTMiddleware.ParseTokenString(tokenStr) - if err != nil { - response.RespondUnauthorized(c, "无权进行此操作,token 无效: "+err.Error()) - c.Abort() - return - } - - claims := jwt.ExtractClaimsFromToken(token) - id, ok := claims["id"].(float64) - if !ok { - response.RespondUnauthorized(c, "无权进行此操作,token 格式错误") - c.Abort() - return - } - - dbUser := &model.User{} - dbErr := model.DB.Select([]string{"id", "username", "display_name", "role", "status", "token"}). - First(dbUser, "id = ?", int(id)).Error - if dbErr != nil || dbUser.Username == "" { - response.RespondUnauthorized(c, "无权进行此操作,用户不存在") - c.Abort() - return - } - - if dbUser.Token != tokenStr { - response.RespondUnauthorized(c, "无权进行此操作,token 已失效或已登出") - c.Abort() - return - } - - if dbUser.Status == common.UserStatusDisabled { - response.RespondFailure(c, "用户已被封禁") - c.Abort() - return - } - - if int(dbUser.Role) < minRole { - response.RespondFailure(c, "无权进行此操作,权限不足") - c.Abort() - return - } - - c.Set("username", dbUser.Username) - c.Set("role", dbUser.Role) - c.Set("id", dbUser.Id) - c.Set("authByToken", true) - c.Next() -} - -func UserAuth() func(c *gin.Context) { - return func(c *gin.Context) { - authHelper(c, common.RoleCommonUser) - } -} - -func AdminAuth() func(c *gin.Context) { - return func(c *gin.Context) { - authHelper(c, common.RoleAdminUser) - } -} - -func RootAuth() func(c *gin.Context) { - return func(c *gin.Context) { - authHelper(c, common.RoleRootUser) - } -} - -// NoTokenAuth is kept as a compatibility no-op because admin APIs now always use OPENFLARE_TOKEN. -func NoTokenAuth() func(c *gin.Context) { - return func(c *gin.Context) { - c.Next() - } -} - -// TokenOnlyAuth is kept as a compatibility no-op because admin APIs now always use OPENFLARE_TOKEN. -func TokenOnlyAuth() func(c *gin.Context) { - return func(c *gin.Context) { - c.Next() - } -} diff --git a/openflare-server/internal/middleware/cache.go b/openflare-server/internal/middleware/cache.go deleted file mode 100644 index b4eb9a66..00000000 --- a/openflare-server/internal/middleware/cache.go +++ /dev/null @@ -1,37 +0,0 @@ -package middleware - -import ( - "path" - "strings" - - "github.com/gin-gonic/gin" -) - -func Cache() func(c *gin.Context) { - return func(c *gin.Context) { - requestPath := c.Request.URL.Path - - switch { - case strings.HasPrefix(requestPath, "/_next/static/"): - c.Header("Cache-Control", "public, max-age=31536000, immutable") - case isStaticPublicAsset(requestPath): - c.Header("Cache-Control", "public, max-age=86400") - default: - c.Header("Cache-Control", "no-store, no-cache, must-revalidate") - c.Header("Pragma", "no-cache") - c.Header("Expires", "0") - } - - c.Next() - } -} - -func isStaticPublicAsset(requestPath string) bool { - ext := strings.ToLower(path.Ext(requestPath)) - switch ext { - case ".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".css", ".js": - return true - default: - return false - } -} diff --git a/openflare-server/internal/middleware/cap.go b/openflare-server/internal/middleware/cap.go deleted file mode 100644 index 50f3854f..00000000 --- a/openflare-server/internal/middleware/cap.go +++ /dev/null @@ -1,14 +0,0 @@ -package middleware - -import ( - "github.com/gin-gonic/gin" - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/service" -) - -// CapAuth wraps the core Cap middleware with OpenFlare's dynamic CapLoginEnabled configuration switch -func CapAuth(scope string) gin.HandlerFunc { - return service.CapManager.VerifyMiddleware(scope, func() bool { - return common.CapLoginEnabled - }) -} diff --git a/openflare-server/internal/middleware/cors.go b/openflare-server/internal/middleware/cors.go deleted file mode 100644 index 93efc7e1..00000000 --- a/openflare-server/internal/middleware/cors.go +++ /dev/null @@ -1,27 +0,0 @@ -package middleware - -import ( - "strings" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" -) - -func CORS() gin.HandlerFunc { - config := cors.DefaultConfig() - config.AllowCredentials = true - config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "OpenFlare-Token", "X-Agent-Token", "Accept"} - config.AllowOriginFunc = func(origin string) bool { - serverAddr := strings.TrimRight(common.ServerAddress, "/") - if serverAddr == "" { - return true - } - if origin == serverAddr { - return true - } - return false - } - return cors.New(config) -} diff --git a/openflare-server/internal/middleware/jwt.go b/openflare-server/internal/middleware/jwt.go deleted file mode 100644 index 89106762..00000000 --- a/openflare-server/internal/middleware/jwt.go +++ /dev/null @@ -1,72 +0,0 @@ -package middleware - -import ( - "log" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - jwt "github.com/appleboy/gin-jwt/v2" - "github.com/gin-gonic/gin" -) - -var JWTMiddleware *jwt.GinJWTMiddleware - -// jwtSigningKey returns JWT_SECRET when set, falling back to SESSION_SECRET -// for backward compatibility with deployments that only configure SESSION_SECRET. -func jwtSigningKey() []byte { - if common.JWTSecret != "" { - return []byte(common.JWTSecret) - } - return []byte(common.SessionSecret) -} - -func InitJWTMiddleware() { - var err error - JWTMiddleware, err = jwt.New(&jwt.GinJWTMiddleware{ - Realm: "openflare", - Key: jwtSigningKey(), - Timeout: 24 * time.Hour, - MaxRefresh: 24 * time.Hour, - IdentityKey: "identity", - PayloadFunc: func(data interface{}) jwt.MapClaims { - if v, ok := data.(*model.User); ok { - return jwt.MapClaims{ - "id": v.Id, - "username": v.Username, - "role": v.Role, - } - } - return jwt.MapClaims{} - }, - IdentityHandler: func(c *gin.Context) interface{} { - claims := jwt.ExtractClaims(c) - id, ok := claims["id"].(float64) - if !ok { - return nil - } - username, _ := claims["username"].(string) - role, _ := claims["role"].(float64) - return &model.User{ - Id: int(id), - Username: username, - Role: int(role), - } - }, - Authorizator: func(data interface{}, c *gin.Context) bool { - return data != nil - }, - Unauthorized: func(c *gin.Context, code int, message string) { - response.RespondErrorWithStatus(c, code, "无权进行此操作,未登录或 token 无效: "+message) - }, - TokenLookup: "header: OpenFlare-Token", - TokenHeadName: "", // Empty for raw token value directly - SendCookie: false, - }) - - if err != nil { - log.Fatalf("JWT Init Error: %s", err.Error()) - } -} diff --git a/openflare-server/internal/middleware/rate-limit.go b/openflare-server/internal/middleware/rate-limit.go deleted file mode 100644 index 63ca4a6a..00000000 --- a/openflare-server/internal/middleware/rate-limit.go +++ /dev/null @@ -1,98 +0,0 @@ -package middleware - -import ( - "context" - "log/slog" - "net/http" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/utils/ratelimit" - - "github.com/gin-gonic/gin" -) - -var timeFormat = "2006-01-02T15:04:05.000Z" - -var inMemoryRateLimiter ratelimit.InMemoryRateLimiter - -func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) { - ctx := context.Background() - rdb := common.RDB - key := "rateLimit:" + mark + c.ClientIP() - listLength, err := rdb.LLen(ctx, key).Result() - if err != nil { - slog.Error("redis rate limiter llen failed", "error", err) - c.Status(http.StatusInternalServerError) - c.Abort() - return - } - if listLength < int64(maxRequestNum) { - rdb.LPush(ctx, key, time.Now().Format(timeFormat)) - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - } else { - oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() - oldTime, err := time.Parse(timeFormat, oldTimeStr) - if err != nil { - slog.Error("parse redis rate limiter old timestamp failed", "error", err) - c.Status(http.StatusInternalServerError) - c.Abort() - return - } - nowTimeStr := time.Now().Format(timeFormat) - nowTime, err := time.Parse(timeFormat, nowTimeStr) - if err != nil { - slog.Error("parse redis rate limiter current timestamp failed", "error", err) - c.Status(http.StatusInternalServerError) - c.Abort() - return - } - // time.Since will return negative number! - // See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows - if int64(nowTime.Sub(oldTime).Seconds()) < duration { - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - c.Status(http.StatusTooManyRequests) - c.Abort() - return - } - - rdb.LPush(ctx, key, time.Now().Format(timeFormat)) - rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1)) - rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) - } -} - -func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) { - key := mark + c.ClientIP() - if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) { - c.Status(http.StatusTooManyRequests) - c.Abort() - return - } -} - -func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) { - if common.RedisEnabled { - return func(c *gin.Context) { - redisRateLimiter(c, maxRequestNum, duration, mark) - } - } - - // It's safe to call multi times. - inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) - return func(c *gin.Context) { - memoryRateLimiter(c, maxRequestNum, duration, mark) - } -} - -func GlobalWebRateLimit() func(c *gin.Context) { - return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW") -} - -func GlobalAPIRateLimit() func(c *gin.Context) { - return rateLimitFactory(common.GlobalApiRateLimitNum, common.GlobalApiRateLimitDuration, "GA") -} - -func CriticalRateLimit() func(c *gin.Context) { - return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT") -} diff --git a/openflare-server/internal/middleware/relay-auth.go b/openflare-server/internal/middleware/relay-auth.go deleted file mode 100644 index d62da3cd..00000000 --- a/openflare-server/internal/middleware/relay-auth.go +++ /dev/null @@ -1,28 +0,0 @@ -package middleware - -import ( - "github.com/gin-gonic/gin" - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/service" -) - -// RelayAuth authenticates Relay requests using the shared agent token, -// and verifies the node is a tunnel_relay type. -func RelayAuth() func(c *gin.Context) { - return func(c *gin.Context) { - token := c.GetHeader("X-Agent-Token") - node, err := service.AuthenticateAccessToken(token) - if err != nil { - response.RespondUnauthorized(c, "无权进行此操作,Agent Token 无效") - c.Abort() - return - } - if node.NodeType != "tunnel_relay" { - response.RespondForbidden(c, "此节点不是 TunnelRelay 类型") - c.Abort() - return - } - c.Set("relay_node", node) - c.Next() - } -} diff --git a/openflare-server/internal/middleware/tunnel-auth.go b/openflare-server/internal/middleware/tunnel-auth.go deleted file mode 100644 index c9347760..00000000 --- a/openflare-server/internal/middleware/tunnel-auth.go +++ /dev/null @@ -1,30 +0,0 @@ -package middleware - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/common/response" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-gonic/gin" -) - -// TunnelAuth authenticates OpenFlared client requests using the per-node -// tunnel_token carried in the X-Tunnel-Token header, and verifies the node is -// of the tunnel_client type. -func TunnelAuth() func(c *gin.Context) { - return func(c *gin.Context) { - token := c.GetHeader("X-Tunnel-Token") - node, err := service.AuthenticateAccessToken(token) - if err != nil { - response.RespondUnauthorized(c, "无权进行此操作,Tunnel Token 无效") - c.Abort() - return - } - if node.NodeType != "tunnel_client" { - response.RespondForbidden(c, "此节点不是 TunnelClient 类型") - c.Abort() - return - } - c.Set("flared_node", node) - c.Next() - } -} diff --git a/Wavelet/internal/model/access_token.go b/openflare-server/internal/model/access_token.go similarity index 100% rename from Wavelet/internal/model/access_token.go rename to openflare-server/internal/model/access_token.go diff --git a/openflare-server/internal/model/acme_account.go b/openflare-server/internal/model/acme_account.go deleted file mode 100644 index 863319e2..00000000 --- a/openflare-server/internal/model/acme_account.go +++ /dev/null @@ -1,41 +0,0 @@ -package model - -import "time" - -type AcmeAccount struct { - ID uint `json:"id" gorm:"primaryKey"` - Email string `json:"email" gorm:"size:255"` - URL string `json:"url" gorm:"size:255"` - PrivateKey string `json:"-" gorm:"type:text;not null"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func GetAcmeAccountByID(id uint) (*AcmeAccount, error) { - account := &AcmeAccount{} - err := DB.First(account, id).Error - return account, err -} - -func GetDefaultAcmeAccount() (*AcmeAccount, error) { - account := &AcmeAccount{} - err := DB.Order("id asc").First(account).Error - if err != nil { - // Auto-create a default account placeholder if none exists - account.Email = "admin@openflare.dev" - err = DB.Create(account).Error - } - return account, err -} - -func (account *AcmeAccount) Insert() error { - return DB.Create(account).Error -} - -func (account *AcmeAccount) Update() error { - return DB.Save(account).Error -} - -func (account *AcmeAccount) Delete() error { - return DB.Delete(account).Error -} diff --git a/openflare-server/internal/model/apply_log.go b/openflare-server/internal/model/apply_log.go deleted file mode 100644 index cb5f2e74..00000000 --- a/openflare-server/internal/model/apply_log.go +++ /dev/null @@ -1,81 +0,0 @@ -package model - -import ( - "time" - - "gorm.io/gorm" -) - -type ApplyLogQuery struct { - NodeID string - PageNo int - PageSize int -} - -type ApplyLog struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - Version string `json:"version" gorm:"size:32;not null"` - Result string `json:"result" gorm:"size:32;not null"` - Message string `json:"message" gorm:"type:text"` - Checksum string `json:"checksum" gorm:"size:64;not null;default:''"` - MainConfigChecksum string `json:"main_config_checksum" gorm:"size:64;not null;default:''"` - RouteConfigChecksum string `json:"route_config_checksum" gorm:"size:64;not null;default:''"` - SupportFileCount int `json:"support_file_count" gorm:"not null;default:0"` - CreatedAt time.Time `json:"created_at"` -} - -func ListApplyLogs(query ApplyLogQuery) (logs []*ApplyLog, err error) { - db := DB.Order("id desc") - if query.NodeID != "" { - db = db.Where("node_id = ?", query.NodeID) - } - if query.PageSize > 0 { - offset := 0 - if query.PageNo > 1 { - offset = (query.PageNo - 1) * query.PageSize - } - db = db.Limit(query.PageSize).Offset(offset) - } - err = db.Find(&logs).Error - return logs, err -} - -func CountApplyLogs(nodeID string) (total int64, err error) { - query := DB.Model(&ApplyLog{}) - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - err = query.Count(&total).Error - return total, err -} - -func GetLatestApplyLogsByNodeIDs(nodeIDs []string) (map[string]*ApplyLog, error) { - result := make(map[string]*ApplyLog) - if len(nodeIDs) == 0 { - return result, nil - } - - var logs []*ApplyLog - subQuery := DB.Model(&ApplyLog{}). - Select("MAX(id) AS id"). - Where("node_id IN ?", nodeIDs). - Group("node_id") - if err := DB.Where("id IN (?)", subQuery).Find(&logs).Error; err != nil { - return nil, err - } - for _, log := range logs { - result[log.NodeID] = log - } - return result, nil -} - -func DeleteAllApplyLogs() (deleted int64, err error) { - result := DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ApplyLog{}) - return result.RowsAffected, result.Error -} - -func DeleteApplyLogsBefore(before time.Time) (deleted int64, err error) { - result := DB.Where("created_at < ?", before).Delete(&ApplyLog{}) - return result.RowsAffected, result.Error -} diff --git a/openflare-server/internal/model/auth_source.go b/openflare-server/internal/model/auth_source.go index 4e3faa65..d1d65d74 100644 --- a/openflare-server/internal/model/auth_source.go +++ b/openflare-server/internal/model/auth_source.go @@ -1,31 +1,35 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package model import ( + "context" "errors" "regexp" "strings" "time" - "github.com/rain-kl/openflare/pkg/utils" - + "github.com/Rain-kl/Wavelet/internal/db" "gorm.io/gorm" ) +// 认证源类型 const ( - AuthSourceTypeGitHub = "github" - AuthSourceTypeOIDC = "oidc" + AuthSourceTypeOIDC = "oidc" ) var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`) +// AuthSource 认证源实体 type AuthSource struct { - ID uint `json:"id"` + ID uint64 `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` - Type string `json:"type" gorm:"index;size:20;not null"` + Type string `json:"type" gorm:"size:20;not null"` DisplayName string `json:"display_name" gorm:"size:100"` IsActive bool `json:"is_active" gorm:"index;not null;default:false"` - ClientID string `json:"client_id" gorm:"column:client_id;size:255"` - ClientSecret string `json:"-" gorm:"column:client_secret;size:1024"` + ClientID string `json:"client_id" gorm:"size:255"` + ClientSecret string `json:"-" gorm:"size:1024"` OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"` Scopes string `json:"scopes" gorm:"size:255"` IconURL string `json:"icon_url" gorm:"size:1024"` @@ -34,21 +38,32 @@ type AuthSource struct { ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"` } -type ExternalAccount struct { - ID uint `json:"id"` - AuthSourceID uint `json:"auth_source_id" gorm:"uniqueIndex:idx_external_account_source_external;index;not null"` - UserID int `json:"user_id" gorm:"index;not null"` - ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_account_source_external;size:255;not null"` - ExternalUsername string `json:"external_username" gorm:"size:255"` - Email string `json:"email" gorm:"size:255"` - AuthSource AuthSource `json:"-" gorm:"constraint:OnDelete:CASCADE"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// TableName 表名 +func (AuthSource) TableName() string { + return "w_auth_sources" } +// ExternalAccount 外部账号绑定实体 +type ExternalAccount struct { + ID uint64 `json:"id" gorm:"primaryKey"` + AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"` + UserID uint64 `json:"user_id" gorm:"index;not null"` + ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"` + ExternalUsername string `json:"external_username" gorm:"size:255"` + Email string `json:"email" gorm:"size:255"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 表名 +func (ExternalAccount) TableName() string { + return "w_external_accounts" +} + +// ExternalAccountView 外部帐号绑定视图(脱敏展示用) type ExternalAccountView struct { - ID uint `json:"id"` - AuthSourceID uint `json:"auth_source_id"` + ID uint64 `json:"id"` + AuthSourceID uint64 `json:"auth_source_id"` AuthSourceName string `json:"auth_source_name"` AuthSourceType string `json:"auth_source_type"` AuthSourceLabel string `json:"auth_source_label"` @@ -57,115 +72,117 @@ type ExternalAccountView struct { CreatedAt time.Time `json:"created_at"` } +// Normalize 对认证源字段进行标准化处理 func (source *AuthSource) Normalize() { - source.Type = strings.ToLower(source.Type) - utils.TrimStringFields( - &source.Name, - &source.Type, - &source.DisplayName, - &source.ClientID, - &source.ClientSecret, - &source.OpenIDDiscoveryURL, - &source.Scopes, - &source.IconURL, - ) + source.Type = strings.ToLower(strings.TrimSpace(source.Type)) + source.Name = strings.TrimSpace(source.Name) + source.DisplayName = strings.TrimSpace(source.DisplayName) + source.ClientID = strings.TrimSpace(source.ClientID) + source.ClientSecret = strings.TrimSpace(source.ClientSecret) + source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL) + source.Scopes = strings.TrimSpace(source.Scopes) + source.IconURL = strings.TrimSpace(source.IconURL) if source.DisplayName == "" { source.DisplayName = source.Name } if source.Type == AuthSourceTypeOIDC && source.Scopes == "" { source.Scopes = "openid profile email" } - if source.Type == AuthSourceTypeGitHub && source.Scopes == "" { - source.Scopes = "user:email" - } } +// Validate 校验认证源字段合法性 func (source *AuthSource) Validate() error { source.Normalize() if source.Name == "" { - return errors.New("认证源名称不能为空") + return errors.New(errAuthSourceNameRequired) } if !authSourceNamePattern.MatchString(source.Name) { - return errors.New("认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头") + return errors.New(errAuthSourceNameInvalid) } - switch source.Type { - case AuthSourceTypeGitHub: - case AuthSourceTypeOIDC: - if source.OpenIDDiscoveryURL == "" { - return errors.New("OIDC 认证源必须配置 Discovery URL") - } - default: - return errors.New("认证源类型仅支持 github 或 oidc") + if source.Type != AuthSourceTypeOIDC { + return errors.New(errAuthSourceTypeUnsupported) } - if source.IsActive { - if source.ClientID == "" || source.ClientSecret == "" { - return errors.New("启用认证源前必须配置 Client ID 和 Client Secret") - } + if source.OpenIDDiscoveryURL == "" { + return errors.New(errAuthSourceDiscoveryURLRequired) + } + if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { + return errors.New(errAuthSourceClientCredentialsRequired) } return nil } +// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志 func (source *AuthSource) Sanitize() { source.ClientSecretConfigured = source.ClientSecret != "" source.ClientSecret = "" } -func GetAuthSources() ([]AuthSource, error) { +// GetAuthSources 获取所有认证源(已脱敏) +func GetAuthSources(ctx context.Context) ([]AuthSource, error) { var sources []AuthSource - err := DB.Order("id asc").Find(&sources).Error - for index := range sources { - sources[index].Sanitize() + if err := db.DB(ctx).Order("id asc").Find(&sources).Error; err != nil { + return nil, err } - return sources, err + for i := range sources { + sources[i].Sanitize() + } + return sources, nil } -func GetActiveAuthSources() ([]AuthSource, error) { +// GetActiveAuthSources 获取所有已启用的认证源(已脱敏) +func GetActiveAuthSources(ctx context.Context) ([]AuthSource, error) { var sources []AuthSource - err := DB.Where("is_active = ?", true).Order("id asc").Find(&sources).Error - for index := range sources { - sources[index].Sanitize() + if err := db.DB(ctx).Where("is_active = ?", true).Order("id asc").Find(&sources).Error; err != nil { + return nil, err } - return sources, err + for i := range sources { + sources[i].Sanitize() + } + return sources, nil } -func GetAuthSourceByID(id uint) (*AuthSource, error) { +// GetAuthSourceByID 根据 ID 获取认证源 +func GetAuthSourceByID(ctx context.Context, id uint64) (*AuthSource, error) { if id == 0 { - return nil, errors.New("认证源 ID 不能为空") + return nil, errors.New(errAuthSourceIDRequired) } var source AuthSource - if err := DB.First(&source, "id = ?", id).Error; err != nil { + if err := db.DB(ctx).First(&source, "id = ?", id).Error; err != nil { return nil, err } source.ClientSecretConfigured = source.ClientSecret != "" return &source, nil } -func GetAuthSourceByName(name string) (*AuthSource, error) { +// GetAuthSourceByName 根据名称获取认证源(名称比较不区分大小写) +func GetAuthSourceByName(ctx context.Context, name string) (*AuthSource, error) { name = strings.TrimSpace(name) if name == "" { - return nil, errors.New("认证源名称不能为空") + return nil, errors.New(errAuthSourceNameRequired) } var source AuthSource - if err := DB.First(&source, "name = ?", name).Error; err != nil { + if err := db.DB(ctx).First(&source, "LOWER(name) = LOWER(?)", name).Error; err != nil { return nil, err } source.ClientSecretConfigured = source.ClientSecret != "" return &source, nil } -func CreateAuthSource(source *AuthSource) error { +// CreateAuthSource 创建认证源 +func CreateAuthSource(ctx context.Context, source *AuthSource) error { if err := source.Validate(); err != nil { return err } - return DB.Create(source).Error + return db.DB(ctx).Create(source).Error } -func UpdateAuthSource(source *AuthSource, keepSecret bool) error { +// UpdateAuthSource 更新认证源,keepSecret 为 true 时保留原密钥 +func UpdateAuthSource(ctx context.Context, source *AuthSource, keepSecret bool) error { if source.ID == 0 { - return errors.New("认证源 ID 不能为空") + return errors.New(errAuthSourceIDRequired) } var current AuthSource - if err := DB.First(¤t, "id = ?", source.ID).Error; err != nil { + if err := db.DB(ctx).First(¤t, "id = ?", source.ID).Error; err != nil { return err } if keepSecret { @@ -174,7 +191,7 @@ func UpdateAuthSource(source *AuthSource, keepSecret bool) error { if err := source.Validate(); err != nil { return err } - return DB.Model(¤t).Updates(map[string]any{ + return db.DB(ctx).Model(¤t).Updates(map[string]any{ "name": source.Name, "type": source.Type, "display_name": source.DisplayName, @@ -187,8 +204,9 @@ func UpdateAuthSource(source *AuthSource, keepSecret bool) error { }).Error } -func ToggleAuthSource(id uint, isActive bool) error { - source, err := GetAuthSourceByID(id) +// ToggleAuthSource 切换认证源启用状态 +func ToggleAuthSource(ctx context.Context, id uint64, isActive bool) error { + source, err := GetAuthSourceByID(ctx, id) if err != nil { return err } @@ -196,14 +214,15 @@ func ToggleAuthSource(id uint, isActive bool) error { if err := source.Validate(); err != nil { return err } - return DB.Model(&AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error + return db.DB(ctx).Model(&AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error } -func DeleteAuthSource(id uint) error { +// DeleteAuthSource 删除认证源及其关联的外部帐号绑定 +func DeleteAuthSource(ctx context.Context, id uint64) error { if id == 0 { - return errors.New("认证源 ID 不能为空") + return errors.New(errAuthSourceIDRequired) } - return DB.Transaction(func(tx *gorm.DB) error { + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil { return err } @@ -211,47 +230,76 @@ func DeleteAuthSource(id uint) error { }) } -func FindExternalAccount(sourceID uint, externalID string) (*ExternalAccount, error) { +// FindExternalAccount 查找外部帐号绑定记录 +func FindExternalAccount(ctx context.Context, sourceID uint64, externalID string) (*ExternalAccount, error) { var account ExternalAccount - err := DB.Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error - if err != nil { + if err := db.DB(ctx).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil { return nil, err } return &account, nil } -func LinkExternalAccount(account *ExternalAccount) error { - if account.AuthSourceID == 0 || account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { - return errors.New("外部账号绑定信息不完整") +// BindExternalAccount 绑定外部帐号(已存在时更新用户名和邮箱) +func BindExternalAccount(ctx context.Context, account *ExternalAccount) error { + if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { + return errors.New(errExternalAccountBindingIncomplete) } account.ExternalID = strings.TrimSpace(account.ExternalID) account.ExternalUsername = strings.TrimSpace(account.ExternalUsername) account.Email = strings.TrimSpace(account.Email) - return DB.Where(ExternalAccount{ - AuthSourceID: account.AuthSourceID, - ExternalID: account.ExternalID, - }).FirstOrCreate(account).Error + + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { + var current ExternalAccount + err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(¤t).Error + if err == nil { + if current.UserID != account.UserID { + return errors.New(errExternalAccountAlreadyBoundToAnother) + } + return tx.Model(¤t).Updates(map[string]any{ + "external_username": account.ExternalUsername, + "email": account.Email, + }).Error + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(account).Error + }) } -func ListExternalAccountsByUserID(userID int) ([]ExternalAccountView, error) { - if userID <= 0 { - return nil, errors.New("用户 ID 不能为空") +// ListExternalAccountsByUserID 获取指定用户的所有外部帐号绑定视图 +func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]ExternalAccountView, error) { + if userID == 0 { + return nil, errors.New(errUserIDRequired) } var accounts []ExternalAccount - if err := DB.Preload("AuthSource").Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil { + if err := db.DB(ctx).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil { return nil, err } views := make([]ExternalAccountView, 0, len(accounts)) for _, account := range accounts { - label := account.AuthSource.DisplayName - if label == "" { - label = account.AuthSource.Name + var name, sourceType, label string + if account.AuthSourceID == 0 { + name = "default" + sourceType = "oidc" + label = "历史认证源" + } else { + source, err := GetAuthSourceByID(ctx, account.AuthSourceID) + if err != nil { + continue + } + name = source.Name + sourceType = source.Type + label = source.DisplayName + if label == "" { + label = source.Name + } } views = append(views, ExternalAccountView{ ID: account.ID, AuthSourceID: account.AuthSourceID, - AuthSourceName: account.AuthSource.Name, - AuthSourceType: account.AuthSource.Type, + AuthSourceName: name, + AuthSourceType: sourceType, AuthSourceLabel: label, ExternalUsername: account.ExternalUsername, Email: account.Email, @@ -261,19 +309,10 @@ func ListExternalAccountsByUserID(userID int) ([]ExternalAccountView, error) { return views, nil } -func DeleteExternalAccountForUser(id uint, userID int) error { - if id == 0 { - return errors.New("绑定记录 ID 不能为空") +// DeleteExternalAccountForUser 删除指定用户的外部帐号绑定 +func DeleteExternalAccountForUser(ctx context.Context, id uint64, userID uint64) error { + if id == 0 || userID == 0 { + return errors.New(errExternalAccountBindingIDRequired) } - if userID <= 0 { - return errors.New("用户 ID 不能为空") - } - result := DB.Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return errors.New("绑定记录不存在") - } - return nil + return db.DB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error } diff --git a/openflare-server/internal/model/config_version.go b/openflare-server/internal/model/config_version.go deleted file mode 100644 index ca290485..00000000 --- a/openflare-server/internal/model/config_version.go +++ /dev/null @@ -1,45 +0,0 @@ -package model - -import "time" - -type ConfigVersionSummary struct { - ID uint `json:"id"` - Version string `json:"version"` - Checksum string `json:"checksum"` - IsActive bool `json:"is_active"` - CreatedBy string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` -} - -type ConfigVersion struct { - ID uint `json:"id" gorm:"primaryKey"` - Version string `json:"version" gorm:"uniqueIndex;size:32;not null"` - SnapshotJSON string `json:"snapshot_json" gorm:"type:text;not null"` - MainConfig string `json:"main_config" gorm:"type:text;not null;default:''"` - RenderedConfig string `json:"rendered_config" gorm:"type:text;not null"` - SupportFilesJSON string `json:"support_files_json" gorm:"type:text;not null;default:'[]'"` - Checksum string `json:"checksum" gorm:"size:64;not null"` - IsActive bool `json:"is_active" gorm:"not null;default:false;index"` - CreatedBy string `json:"created_by" gorm:"size:64;not null"` - CreatedAt time.Time `json:"created_at"` -} - -func ListConfigVersionSummaries() (versions []*ConfigVersionSummary, err error) { - err = DB.Model(&ConfigVersion{}). - Select("id", "version", "checksum", "is_active", "created_by", "created_at"). - Order("id desc"). - Find(&versions).Error - return versions, err -} - -func GetConfigVersionByID(id uint) (*ConfigVersion, error) { - version := &ConfigVersion{} - err := DB.First(version, id).Error - return version, err -} - -func GetActiveConfigVersion() (*ConfigVersion, error) { - version := &ConfigVersion{} - err := DB.Where("is_active = ?", true).Order("id desc").First(version).Error - return version, err -} diff --git a/openflare-server/internal/model/database_schema_version.go b/openflare-server/internal/model/database_schema_version.go deleted file mode 100644 index cb356324..00000000 --- a/openflare-server/internal/model/database_schema_version.go +++ /dev/null @@ -1,27 +0,0 @@ -package model - -import ( - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model/migrate" -) - -const ( - legacyDatabaseSchemaVersion = migrate.BaseDatabaseSchemaVersion - legacyMigrationTerminalVersion = 17 - databaseSchemaVersionRowID = 1 -) - -// currentDatabaseSchemaVersion tracks the current physical schema validated by the -// legacy validator set. Goose owns only post-v17 migrations, and none exist yet. -var currentDatabaseSchemaVersion = legacyMigrationTerminalVersion - -type DatabaseSchemaVersion struct { - ID uint `json:"id" gorm:"primaryKey"` - Version int `json:"version" gorm:"not null"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (DatabaseSchemaVersion) TableName() string { - return "database_schema_versions" -} diff --git a/openflare-server/internal/model/dns_account.go b/openflare-server/internal/model/dns_account.go deleted file mode 100644 index 0d8e7295..00000000 --- a/openflare-server/internal/model/dns_account.go +++ /dev/null @@ -1,35 +0,0 @@ -package model - -import "time" - -type DnsAccount struct { - ID uint `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:255;not null"` - Type string `json:"type" gorm:"size:64;not null"` - Authorization string `json:"-" gorm:"type:text;not null"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func ListDnsAccounts() (accounts []*DnsAccount, err error) { - err = DB.Order("id desc").Find(&accounts).Error - return accounts, err -} - -func GetDnsAccountByID(id uint) (*DnsAccount, error) { - account := &DnsAccount{} - err := DB.First(account, id).Error - return account, err -} - -func (account *DnsAccount) Insert() error { - return DB.Create(account).Error -} - -func (account *DnsAccount) Update() error { - return DB.Save(account).Error -} - -func (account *DnsAccount) Delete() error { - return DB.Delete(account).Error -} diff --git a/Wavelet/internal/model/errs.go b/openflare-server/internal/model/errs.go similarity index 100% rename from Wavelet/internal/model/errs.go rename to openflare-server/internal/model/errs.go diff --git a/openflare-server/internal/model/goose/bridge.go b/openflare-server/internal/model/goose/bridge.go deleted file mode 100644 index de999ad1..00000000 --- a/openflare-server/internal/model/goose/bridge.go +++ /dev/null @@ -1,326 +0,0 @@ -package goose - -import ( - "database/sql" - "errors" - "fmt" - "io" - "log/slog" - "os" - - "gorm.io/gorm" -) - -type BridgeContext interface { - Context - AutoMigrateLegacySchemaMetadata(db *gorm.DB) error - InitializeFreshDatabaseSchema(db *gorm.DB, backend string) error - IsDatabaseEmpty(db *gorm.DB) (bool, error) - RepairCurrentSchemaState(db *gorm.DB, backend string) error - SaveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error - UpgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error - ValidateCurrentDatabaseSchema(db *gorm.DB, backend string) error -} - -type schemaMigrationState int - -const ( - schemaMigrationStateFresh schemaMigrationState = iota - schemaMigrationStateLegacyOnly - schemaMigrationStateGooseOnly - schemaMigrationStateLegacyBootstrap - schemaMigrationStateMixed -) - -func detectSchemaState(db *gorm.DB, ctx BridgeContext) (schemaMigrationState, error) { - hasLegacyTable := db.Migrator().HasTable("database_schema_versions") - hasGooseTable := db.Migrator().HasTable("goose_db_version") - - switch { - case hasLegacyTable && hasGooseTable: - return schemaMigrationStateMixed, nil - case hasLegacyTable: - return schemaMigrationStateLegacyOnly, nil - case hasGooseTable: - return schemaMigrationStateGooseOnly, nil - } - - empty, err := ctx.IsDatabaseEmpty(db) - if err != nil { - return 0, err - } - if empty { - return schemaMigrationStateFresh, nil - } - return schemaMigrationStateLegacyBootstrap, nil -} - -func LoadDatabaseVersion(db *gorm.DB) (int, bool, error) { - if db == nil || !db.Migrator().HasTable("goose_db_version") { - return 0, false, nil - } - - var version int64 - err := db.Table("goose_db_version"). - Where("is_applied = ?", true). - Order("version_id DESC"). - Select("version_id"). - Limit(1). - Row(). - Scan(&version) - if errors.Is(err, sql.ErrNoRows) { - return 0, false, nil - } - if err != nil { - return 0, false, err - } - return int(version), true, nil -} - -func loadLegacyDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) { - if db == nil || !db.Migrator().HasTable("database_schema_versions") { - return 0, false, nil - } - - var version int - err := db.Table("database_schema_versions"). - Where("id = ?", 1). - Select("version"). - Limit(1). - Row(). - Scan(&version) - if errors.Is(err, sql.ErrNoRows) { - return 0, false, nil - } - if err != nil { - return 0, false, err - } - return version, true, nil -} - -func bootstrapLegacySchemaVersion(db *gorm.DB, ctx BridgeContext) error { - if err := ctx.AutoMigrateLegacySchemaMetadata(db); err != nil { - return err - } - version, exists, err := loadLegacyDatabaseSchemaVersion(db) - if err != nil { - return err - } - if exists { - if int64(version) > LegacyBridgeVersion { - return fmt.Errorf("legacy schema version %d is newer than supported terminal version %d", version, LegacyBridgeVersion) - } - return nil - } - return ctx.SaveLegacyDatabaseSchemaVersion(db, 7) -} - -func upgradeLegacyToTerminal(db *gorm.DB, backend string, ctx BridgeContext) error { - if err := bootstrapLegacySchemaVersion(db, ctx); err != nil { - return err - } - version, exists, err := loadLegacyDatabaseSchemaVersion(db) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("legacy schema version record is missing after bootstrap") - } - return ctx.UpgradeLegacyDatabaseSchema(db, backend, version) -} - -func validateGooseBridgeState(db *gorm.DB) error { - version, exists, err := LoadDatabaseVersion(db) - if err != nil { - return err - } - if !exists { - return nil - } - if int64(version) < LegacyBridgeVersion { - return fmt.Errorf("goose schema version %d is below legacy bridge baseline %d", version, LegacyBridgeVersion) - } - if int64(version) > CurrentTargetVersion() { - return fmt.Errorf("goose schema version %d is newer than application target version %d", version, CurrentTargetVersion()) - } - return nil -} - -func finalizeLegacyToGooseBridge(db *gorm.DB) error { - gooseVersion, exists, err := LoadDatabaseVersion(db) - if err != nil { - return err - } - if !exists || int64(gooseVersion) < LegacyBridgeVersion { - return nil - } - if !db.Migrator().HasTable("database_schema_versions") { - return nil - } - if err := db.Exec("DROP TABLE IF EXISTS database_schema_versions").Error; err != nil { - return fmt.Errorf("drop legacy schema versions table failed: %w", err) - } - slog.Info("completed legacy-to-goose migration bridge", "goose_version", gooseVersion) - return nil -} - -func ValidateRegisteredSchema(db *gorm.DB) error { - if err := validateNodeCapabilitiesJSON(db); err != nil { - return err - } - return nil -} - -func EnsureDatabaseSchemaUpToDate(db *gorm.DB, backend string, ctx BridgeContext) (returnedErr error) { - if backend == "sqlite" { - backupPath, restore, err := backupSQLiteDatabase(db) - if err != nil { - slog.Warn("failed to backup sqlite database before migration", "error", err) - } else if backupPath != "" { - defer func() { - if returnedErr != nil { - restore() - } else { - os.Remove(backupPath) - } - }() - } - } - - var startDesc string - legacyVer, hasLegacy, _ := loadLegacyDatabaseSchemaVersion(db) - gooseVer, hasGoose, _ := LoadDatabaseVersion(db) - if hasGoose { - startDesc = fmt.Sprintf("goose version %d", gooseVer) - } else if hasLegacy { - startDesc = fmt.Sprintf("legacy version %d", legacyVer) - } else { - startDesc = "none (fresh database)" - } - - state, err := detectSchemaState(db, ctx) - if err != nil { - return err - } - - switch state { - case schemaMigrationStateFresh: - if err := ctx.InitializeFreshDatabaseSchema(db, backend); err != nil { - return err - } - case schemaMigrationStateLegacyOnly: - if err := upgradeLegacyToTerminal(db, backend, ctx); err != nil { - return err - } - case schemaMigrationStateGooseOnly: - if err := validateGooseBridgeState(db); err != nil { - return err - } - case schemaMigrationStateLegacyBootstrap: - if err := upgradeLegacyToTerminal(db, backend, ctx); err != nil { - return err - } - case schemaMigrationStateMixed: - legacyVersion, exists, err := loadLegacyDatabaseSchemaVersion(db) - if err != nil { - return err - } - if exists && int64(legacyVersion) != LegacyBridgeVersion { - return fmt.Errorf("incomplete mixed migration state: legacy schema version %d does not match bridge terminal version %d", legacyVersion, LegacyBridgeVersion) - } - if err := validateGooseBridgeState(db); err != nil { - return err - } - default: - return fmt.Errorf("unknown schema migration state: %d", state) - } - - if err := runMigrations(db, backend, ctx); err != nil { - return err - } - if err := finalizeLegacyToGooseBridge(db); err != nil { - return err - } - if err := ctx.RepairCurrentSchemaState(db, backend); err != nil { - return err - } - if err := ctx.ValidateCurrentDatabaseSchema(db, backend); err != nil { - return err - } - if err := ValidateRegisteredSchema(db); err != nil { - return err - } - - endVer, _, _ := LoadDatabaseVersion(db) - if hasGoose && int64(gooseVer) == int64(endVer) { - slog.Info("database schema is already up to date", "version", endVer) - } else { - slog.Info("database migration completed successfully", "from", startDesc, "to", fmt.Sprintf("goose version %d", endVer)) - } - return nil -} - -func backupSQLiteDatabase(db *gorm.DB) (string, func(), error) { - var dbList []struct { - Seq int - Name string - File string - } - if err := db.Raw("PRAGMA database_list").Scan(&dbList).Error; err != nil { - return "", nil, err - } - var dbPath string - for _, item := range dbList { - if item.Name == "main" && item.File != "" { - dbPath = item.File - break - } - } - if dbPath == "" { - return "", nil, nil - } - - backupPath := dbPath + ".bak" - - src, err := os.Open(dbPath) - if err != nil { - return "", nil, err - } - defer src.Close() - - dst, err := os.Create(backupPath) - if err != nil { - return "", nil, err - } - defer dst.Close() - - if _, err := io.Copy(dst, src); err != nil { - return "", nil, err - } - dst.Sync() - - restoreFunc := func() { - src, err := os.Open(backupPath) - if err != nil { - slog.Error("failed to open sqlite backup for restore", "error", err) - return - } - defer src.Close() - - dst, err := os.OpenFile(dbPath, os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - slog.Error("failed to open sqlite db for restore", "error", err) - return - } - defer dst.Close() - - if _, err := io.Copy(dst, src); err != nil { - slog.Error("failed to restore sqlite backup", "error", err) - } else { - dst.Sync() - slog.Warn("restored sqlite database from backup due to migration failure") - } - } - - return backupPath, restoreFunc, nil -} diff --git a/openflare-server/internal/model/goose/goose_202606020001_add_node_capabilities_json.go b/openflare-server/internal/model/goose/goose_202606020001_add_node_capabilities_json.go deleted file mode 100644 index 44b9505a..00000000 --- a/openflare-server/internal/model/goose/goose_202606020001_add_node_capabilities_json.go +++ /dev/null @@ -1,50 +0,0 @@ -package goose - -import ( - "encoding/json" - "fmt" - - presslygoose "github.com/pressly/goose/v3" - "gorm.io/gorm" -) - -const versionNodeCapabilitiesJSON int64 = 202606020001 - -// migration202606020001 adds a future-proof JSON field for node capability -// summaries after the legacy v17 migration bridge. -func migration202606020001(backend string, ctx Context) *presslygoose.Migration { - return newGORMMigration( - versionNodeCapabilitiesJSON, - "202606020001_add_node_capabilities_json.go", - backend, - ctx, - migrateNodeCapabilitiesJSON, - ) -} - -func migrateNodeCapabilitiesJSON(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - emptyJSON, err := json.Marshal([]string{}) - if err != nil { - return fmt.Errorf("marshal default node capabilities: %w", err) - } - if err := db.Exec( - `UPDATE nodes SET capabilities_json = ? WHERE capabilities_json IS NULL OR TRIM(capabilities_json) = ''`, - string(emptyJSON), - ).Error; err != nil { - return fmt.Errorf("backfill nodes.capabilities_json: %w", err) - } - return validateNodeCapabilitiesJSON(db) -} - -func validateNodeCapabilitiesJSON(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("database handle is nil") - } - if !db.Migrator().HasColumn("nodes", "capabilities_json") { - return fmt.Errorf("column nodes.capabilities_json is missing") - } - return nil -} diff --git a/openflare-server/internal/model/goose/goose_202606030001_add_pages_static_hosting.go b/openflare-server/internal/model/goose/goose_202606030001_add_pages_static_hosting.go deleted file mode 100644 index dc230b1a..00000000 --- a/openflare-server/internal/model/goose/goose_202606030001_add_pages_static_hosting.go +++ /dev/null @@ -1,61 +0,0 @@ -package goose - -import ( - "fmt" - - presslygoose "github.com/pressly/goose/v3" - "gorm.io/gorm" -) - -const versionPagesStaticHosting int64 = 202606030001 - -// migration202606030001 adds OpenFlare Pages static hosting tables and the -// proxy_routes.pages_project_id binding used by the global release snapshot. -func migration202606030001(backend string, ctx Context) *presslygoose.Migration { - return newGORMMigration( - versionPagesStaticHosting, - "202606030001_add_pages_static_hosting.go", - backend, - ctx, - migratePagesStaticHosting, - ) -} - -func migratePagesStaticHosting(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - if err := db.Exec( - `UPDATE proxy_routes SET upstream_type = 'direct' WHERE upstream_type IS NULL OR TRIM(upstream_type) = ''`, - ).Error; err != nil { - return fmt.Errorf("backfill proxy_routes.upstream_type: %w", err) - } - return validatePagesStaticHosting(db) -} - -func validatePagesStaticHosting(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("database handle is nil") - } - for _, table := range []string{"pages_projects", "pages_deployments", "pages_deployment_files"} { - if !db.Migrator().HasTable(table) { - return fmt.Errorf("table %s is missing", table) - } - } - for _, column := range []string{"upstream_type", "pages_project_id"} { - if !db.Migrator().HasColumn("proxy_routes", column) { - return fmt.Errorf("column proxy_routes.%s is missing", column) - } - } - for _, column := range []string{"slug", "active_deployment_id", "spa_fallback_enabled", "spa_fallback_path"} { - if !db.Migrator().HasColumn("pages_projects", column) { - return fmt.Errorf("column pages_projects.%s is missing", column) - } - } - for _, column := range []string{"project_id", "checksum", "artifact_path"} { - if !db.Migrator().HasColumn("pages_deployments", column) { - return fmt.Errorf("column pages_deployments.%s is missing", column) - } - } - return nil -} diff --git a/openflare-server/internal/model/goose/goose_202606030002_add_pages_spa_fallback_path.go b/openflare-server/internal/model/goose/goose_202606030002_add_pages_spa_fallback_path.go deleted file mode 100644 index d8bef72f..00000000 --- a/openflare-server/internal/model/goose/goose_202606030002_add_pages_spa_fallback_path.go +++ /dev/null @@ -1,37 +0,0 @@ -package goose - -import ( - "fmt" - - presslygoose "github.com/pressly/goose/v3" - "gorm.io/gorm" -) - -const versionPagesSPAFallbackPath int64 = 202606030002 - -// migration202606030002 adds a configurable SPA fallback path for Pages -// projects. Existing projects keep the previous /index.html behavior. -func migration202606030002(backend string, ctx Context) *presslygoose.Migration { - return newGORMMigration( - versionPagesSPAFallbackPath, - "202606030002_add_pages_spa_fallback_path.go", - backend, - ctx, - migratePagesSPAFallbackPath, - ) -} - -func migratePagesSPAFallbackPath(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - if err := db.Exec( - `UPDATE pages_projects SET spa_fallback_path = '/index.html' WHERE spa_fallback_path IS NULL OR TRIM(spa_fallback_path) = ''`, - ).Error; err != nil { - return fmt.Errorf("backfill pages_projects.spa_fallback_path: %w", err) - } - if !db.Migrator().HasColumn("pages_projects", "spa_fallback_path") { - return fmt.Errorf("column pages_projects.spa_fallback_path is missing") - } - return nil -} diff --git a/openflare-server/internal/model/goose/goose_202606030003_drop_proxy_route_legacy_pow.go b/openflare-server/internal/model/goose/goose_202606030003_drop_proxy_route_legacy_pow.go deleted file mode 100644 index c8f1fdd8..00000000 --- a/openflare-server/internal/model/goose/goose_202606030003_drop_proxy_route_legacy_pow.go +++ /dev/null @@ -1,41 +0,0 @@ -package goose - -import ( - "fmt" - - presslygoose "github.com/pressly/goose/v3" - "gorm.io/gorm" -) - -const versionDropProxyRouteLegacyPoW int64 = 202606030003 - -// migration202606030003 drops the legacy pow_enabled and pow_config columns -// from proxy_routes table, since PoW is now entirely managed under WAF rule groups. -func migration202606030003(backend string, ctx Context) *presslygoose.Migration { - return newGORMMigration( - versionDropProxyRouteLegacyPoW, - "202606030003_drop_proxy_route_legacy_pow.go", - backend, - ctx, - migrateDropProxyRouteLegacyPoW, - ) -} - -func migrateDropProxyRouteLegacyPoW(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - // Drop pow_enabled column if exists - if db.Migrator().HasColumn("proxy_routes", "pow_enabled") { - if err := db.Exec("ALTER TABLE proxy_routes DROP COLUMN pow_enabled").Error; err != nil { - return fmt.Errorf("drop proxy_routes.pow_enabled: %w", err) - } - } - // Drop pow_config column if exists - if db.Migrator().HasColumn("proxy_routes", "pow_config") { - if err := db.Exec("ALTER TABLE proxy_routes DROP COLUMN pow_config").Error; err != nil { - return fmt.Errorf("drop proxy_routes.pow_config: %w", err) - } - } - return nil -} diff --git a/openflare-server/internal/model/goose/goose_202606040004_add_pages_features_and_cleanup.go b/openflare-server/internal/model/goose/goose_202606040004_add_pages_features_and_cleanup.go deleted file mode 100644 index 1711ce07..00000000 --- a/openflare-server/internal/model/goose/goose_202606040004_add_pages_features_and_cleanup.go +++ /dev/null @@ -1,64 +0,0 @@ -package goose - -import ( - "fmt" - - presslygoose "github.com/pressly/goose/v3" - "gorm.io/gorm" -) - -const versionPagesFeaturesAndCleanup int64 = 202606040004 - -// migration202606040004 merges migrations 202606030004, 202606040001, 202606040002, and 202606040003. -// It adds Pages API proxying fields and RootDir/EntryFile to Pages projects, -// backfills default entry_file to 'index.html', and ensures unused fields (root_dir, entry_file) -// are dropped from Pages deployments. -func migration202606040004(backend string, ctx Context) *presslygoose.Migration { - return newGORMMigration( - versionPagesFeaturesAndCleanup, - "202606040004_add_pages_features_and_cleanup.go", - backend, - ctx, - migratePagesFeaturesAndCleanup, - ) -} - -func migratePagesFeaturesAndCleanup(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - - // 1. Verify Pages projects columns - cols := []string{ - "api_proxy_enabled", "api_proxy_path", "api_proxy_pass", "api_proxy_rewrite", - "root_dir", "entry_file", - } - for _, col := range cols { - if !db.Migrator().HasColumn("pages_projects", col) { - return fmt.Errorf("column pages_projects.%s is missing", col) - } - } - - // 2. Backfill pages_projects.entry_file to 'index.html' if empty - type PagesProject struct { - ID uint `gorm:"primaryKey"` - EntryFile string `gorm:"size:512;not null;default:'index.html'"` - } - if err := db.Model(&PagesProject{}).Where("entry_file = '' OR entry_file IS NULL").Update("entry_file", "index.html").Error; err != nil { - return fmt.Errorf("failed to backfill pages_projects.entry_file: %w", err) - } - - // 3. Drop unused fields root_dir and entry_file from pages_deployments if they exist - if db.Migrator().HasColumn("pages_deployments", "root_dir") { - if err := db.Exec("ALTER TABLE pages_deployments DROP COLUMN root_dir").Error; err != nil { - return fmt.Errorf("failed to drop pages_deployments.root_dir: %w", err) - } - } - if db.Migrator().HasColumn("pages_deployments", "entry_file") { - if err := db.Exec("ALTER TABLE pages_deployments DROP COLUMN entry_file").Error; err != nil { - return fmt.Errorf("failed to drop pages_deployments.entry_file: %w", err) - } - } - - return nil -} diff --git a/openflare-server/internal/model/goose/migrations.go b/openflare-server/internal/model/goose/migrations.go deleted file mode 100644 index 35d5410e..00000000 --- a/openflare-server/internal/model/goose/migrations.go +++ /dev/null @@ -1,75 +0,0 @@ -package goose - -import ( - "context" - "database/sql" - "fmt" - - presslygoose "github.com/pressly/goose/v3" - "gorm.io/gorm" -) - -const LegacyBridgeVersion int64 = 17 - -type migrationFunc func(ctx Context, db *gorm.DB, backend string) error - -func newBaselineMigration() *presslygoose.Migration { - migration := presslygoose.NewGoMigration(LegacyBridgeVersion, nil, nil) - migration.Source = fmt.Sprintf("%05d_legacy_terminal_baseline.go", LegacyBridgeVersion) - return migration -} - -func newGORMMigration(version int64, source string, backend string, ctx Context, up migrationFunc) *presslygoose.Migration { - migration := presslygoose.NewGoMigration(version, &presslygoose.GoFunc{ - RunDB: func(_ context.Context, sqlDB *sql.DB) error { - gormDB, err := openGORMDB(ctx, sqlDB, backend) - if err != nil { - return err - } - if backend == "postgres" { - return gormDB.Transaction(func(tx *gorm.DB) error { - return up(ctx, tx, backend) - }) - } - return up(ctx, gormDB, backend) - }, - }, nil) - migration.Source = source - return migration -} - -func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration { - return []*presslygoose.Migration{ - migration202606020001(backend, ctx), - migration202606030001(backend, ctx), - migration202606030002(backend, ctx), - migration202606030003(backend, ctx), - migration202606040004(backend, ctx), - } -} - -func buildMigrations(backend string, ctx Context) []*presslygoose.Migration { - migrations := []*presslygoose.Migration{newBaselineMigration()} - migrations = append(migrations, registeredMigrations(backend, ctx)...) - return migrations -} - -func CurrentTargetVersion() int64 { - var maxVersion int64 = LegacyBridgeVersion - for _, migration := range buildMigrations("sqlite", noopContext{}) { - if migration.Version > maxVersion { - maxVersion = migration.Version - } - } - return maxVersion -} - -type noopContext struct{} - -func (noopContext) ApplyCurrentSchema(db *gorm.DB, backend string) error { - return nil -} - -func (noopContext) RegisterSharding(db *gorm.DB, backend string) error { - return nil -} diff --git a/openflare-server/internal/model/goose/provider.go b/openflare-server/internal/model/goose/provider.go deleted file mode 100644 index 45544a53..00000000 --- a/openflare-server/internal/model/goose/provider.go +++ /dev/null @@ -1,81 +0,0 @@ -package goose - -import ( - "context" - "database/sql" - "fmt" - - "github.com/glebarez/sqlite" - presslygoose "github.com/pressly/goose/v3" - "gorm.io/driver/postgres" - "gorm.io/gorm" - "gorm.io/gorm/schema" -) - -type Context interface { - ApplyCurrentSchema(db *gorm.DB, backend string) error - RegisterSharding(db *gorm.DB, backend string) error -} - -func dialectForBackend(backend string) (presslygoose.Dialect, error) { - switch backend { - case "postgres": - return presslygoose.DialectPostgres, nil - case "sqlite": - return presslygoose.DialectSQLite3, nil - default: - return "", fmt.Errorf("unsupported database backend: %s", backend) - } -} - -func openGORMDB(ctx Context, db *sql.DB, backend string) (*gorm.DB, error) { - var dialector gorm.Dialector - switch backend { - case "postgres": - dialector = postgres.New(postgres.Config{Conn: db}) - case "sqlite": - dialector = &sqlite.Dialector{Conn: db} - default: - return nil, fmt.Errorf("unsupported database backend: %s", backend) - } - - gormDB, err := gorm.Open(dialector, &gorm.Config{ - NamingStrategy: schema.NamingStrategy{}, - }) - if err != nil { - return nil, err - } - if err := ctx.RegisterSharding(gormDB, backend); err != nil { - return nil, err - } - return gormDB, nil -} - -func buildProvider(db *gorm.DB, backend string, ctx Context) (*presslygoose.Provider, error) { - sqlDB, err := db.DB() - if err != nil { - return nil, err - } - dialect, err := dialectForBackend(backend) - if err != nil { - return nil, err - } - return presslygoose.NewProvider( - dialect, - sqlDB, - nil, - presslygoose.WithDisableGlobalRegistry(true), - presslygoose.WithGoMigrations(buildMigrations(backend, ctx)...), - ) -} - -func runMigrations(db *gorm.DB, backend string, ctx Context) error { - provider, err := buildProvider(db, backend, ctx) - if err != nil { - return fmt.Errorf("build goose provider: %w", err) - } - if _, err := provider.Up(context.Background()); err != nil { - return fmt.Errorf("goose up failed: %w", err) - } - return nil -} diff --git a/openflare-server/internal/model/main.go b/openflare-server/internal/model/main.go deleted file mode 100644 index 02c18af7..00000000 --- a/openflare-server/internal/model/main.go +++ /dev/null @@ -1,387 +0,0 @@ -package model - -import ( - "fmt" - "log/slog" - "os" - "reflect" - "strings" - "sync" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/utils/security" - - "github.com/glebarez/sqlite" - "gorm.io/driver/postgres" - "gorm.io/gorm" - "gorm.io/gorm/schema" -) - -var DB *gorm.DB - -type dbModel struct { - value any - tableName string - hasIDPK bool -} - -func registeredModels() []any { - return []any{ - &User{}, - &AuthSource{}, - &ExternalAccount{}, - &Option{}, - &Origin{}, - &ProxyRoute{}, - &PagesProject{}, - &PagesDeployment{}, - &PagesDeploymentFile{}, - &ConfigVersion{}, - &Node{}, - - &NodeSystemProfile{}, - &ApplyLog{}, - &NodeMetricSnapshot{}, - &NodeRequestReport{}, - &NodeAccessLog{}, - &NodeHealthEvent{}, - &NodeObservationOpenresty{}, - &NodeObservationFrps{}, - &NodeObservationFrpc{}, - &TLSCertificate{}, - &ManagedDomain{}, - &AcmeAccount{}, - &DnsAccount{}, - &WAFRuleGroup{}, - &WAFIPGroup{}, - &WAFRuleGroupBinding{}, - } -} - -func currentSchemaMetadataModels() []any { - return nil -} - -func legacySchemaMetadataModels() []any { - return []any{ - &DatabaseSchemaVersion{}, - } -} - -func schemaMetadataModels() []any { - models := make([]any, 0, len(currentSchemaMetadataModels())+len(legacySchemaMetadataModels())) - models = append(models, currentSchemaMetadataModels()...) - models = append(models, legacySchemaMetadataModels()...) - return models -} - -func buildDBModels() ([]dbModel, error) { - models := registeredModels() - result := make([]dbModel, 0, len(models)) - namer := schema.NamingStrategy{} - cache := &sync.Map{} - for _, item := range models { - parsed, err := schema.Parse(item, cache, namer) - if err != nil { - return nil, err - } - hasIDPK := len(parsed.PrimaryFields) == 1 && parsed.PrimaryFields[0].DBName == "id" - result = append(result, dbModel{ - value: item, - tableName: parsed.Table, - hasIDPK: hasIDPK, - }) - } - return result, nil -} - -func createRootAccountIfNeed() error { - var user User - //if user.Status != common.UserStatusEnabled { - if err := DB.First(&user).Error; err != nil { - slog.Info("no user exists, create a root user", "username", "root") - hashedPassword, err := security.Password2Hash("123456") - if err != nil { - return err - } - rootUser := User{ - Username: "root", - Password: hashedPassword, - Role: common.RoleRootUser, - Status: common.UserStatusEnabled, - DisplayName: "Root User", - } - DB.Create(&rootUser) - } - return nil -} - -func CountTable(tableName string) (num int64) { - DB.Table(tableName).Count(&num) - return -} - -func openDatabase() (*gorm.DB, string, error) { - if common.SQLDSN != "" { - db, err := gorm.Open(postgres.Open(common.SQLDSN), &gorm.Config{}) - if err != nil { - return nil, "", err - } - return db, "postgres", nil - } - db, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{}) - if err != nil { - return nil, "", err - } - slog.Info("database DSN not set, using SQLite as database", "sqlite_path", common.SQLitePath) - return db, "sqlite", nil -} - -func autoMigrateAll(db *gorm.DB) error { - return autoMigrateAllExcept(db, nil) -} - -func autoMigrateAllExcept(db *gorm.DB, excludedTables map[string]bool) error { - models := registeredModels() - for i, item := range models { - name := fmt.Sprintf("%T", item) - tableName, err := tableNameForModel(item) - if err != nil { - return fmt.Errorf("resolve table name for %s failed: %w", name, err) - } - if excludedTables[tableName] { - slog.Info("autoMigrateAll: skipped model", "index", fmt.Sprintf("%d/%d", i+1, len(models)), "model", name, "table", tableName) - continue - } - slog.Info("autoMigrateAll: migrating model", "index", fmt.Sprintf("%d/%d", i+1, len(models)), "model", name) - if err := db.AutoMigrate(item); err != nil { - return fmt.Errorf("AutoMigrate %s failed: %w", name, err) - } - slog.Info("autoMigrateAll: migrated model", "model", name) - } - return nil -} - -func tableNameForModel(item any) (string, error) { - namer := schema.NamingStrategy{} - cache := &sync.Map{} - parsed, err := schema.Parse(item, cache, namer) - if err != nil { - return "", err - } - return parsed.Table, nil -} - -func isDatabaseEmpty(db *gorm.DB) (bool, error) { - models, err := buildDBModels() - if err != nil { - return false, err - } - for _, item := range models { - if isShardedObservabilityTable(item.tableName) { - for _, table := range observabilityShardTables(item.tableName) { - if !db.Migrator().HasTable(table) { - continue - } - var count int64 - if err := db.Table(table).Limit(1).Count(&count).Error; err != nil { - return false, err - } - if count > 0 { - return false, nil - } - } - continue - } - if !db.Migrator().HasTable(item.value) { - continue - } - var count int64 - if err := db.Model(item.value).Limit(1).Count(&count).Error; err != nil { - return false, err - } - if count > 0 { - return false, nil - } - } - return true, nil -} - -func sqliteSourceExists() bool { - info, err := os.Stat(common.SQLitePath) - if err != nil { - return false - } - return !info.IsDir() -} - -func migrateSQLiteDataIfNeeded(target *gorm.DB, backend string) error { - if backend != "postgres" { - return nil - } - empty, err := isDatabaseEmpty(target) - if err != nil { - return err - } - if !empty { - slog.Info("skip sqlite migration because target database already has data", "backend", backend) - return nil - } - if !sqliteSourceExists() { - slog.Info("skip sqlite migration because sqlite source file was not found", "sqlite_path", common.SQLitePath) - return nil - } - - source, err := gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ - PrepareStmt: true, - }) - if err != nil { - return fmt.Errorf("open sqlite source database failed: %w", err) - } - sourceSQLDB, err := source.DB() - if err != nil { - return fmt.Errorf("get sqlite source database handle failed: %w", err) - } - defer func() { - _ = sourceSQLDB.Close() - }() - - models, err := buildDBModels() - if err != nil { - return err - } - - slog.Info("starting sqlite to postgres database migration", "sqlite_path", common.SQLitePath) - err = target.Transaction(func(tx *gorm.DB) error { - for _, item := range models { - if err := migrateTableData(source, tx, item); err != nil { - return err - } - if item.hasIDPK { - if err := resetPostgresSequence(tx, item.tableName); err != nil { - return err - } - } - } - return nil - }) - if err != nil { - return err - } - slog.Info("sqlite to postgres database migration completed", "sqlite_path", common.SQLitePath) - return nil -} - -func migrateTableData(source *gorm.DB, target *gorm.DB, item dbModel) error { - if !source.Migrator().HasTable(item.value) { - slog.Info("database migration progress", "table", item.tableName, "migrated", 0, "total", 0, "status", "skipped_missing_source_table") - return nil - } - var total int64 - if err := source.Model(item.value).Count(&total).Error; err != nil { - return fmt.Errorf("count sqlite table %s failed: %w", item.tableName, err) - } - slog.Info("database migration progress", "table", item.tableName, "migrated", 0, "total", total, "status", "starting") - if total == 0 { - slog.Info("database migration progress", "table", item.tableName, "migrated", 0, "total", total, "status", "completed") - return nil - } - - modelType := reflect.TypeOf(item.value).Elem() - sliceType := reflect.SliceOf(modelType) - migrated := int64(0) - offset := 0 - const batchSize = 200 - - for { - batchPtr := reflect.New(sliceType) - query := source.Model(item.value).Limit(batchSize).Offset(offset) - if item.hasIDPK { - query = query.Order("id ASC") - } - if err := query.Find(batchPtr.Interface()).Error; err != nil { - return fmt.Errorf("read sqlite table %s failed: %w", item.tableName, err) - } - batchLen := batchPtr.Elem().Len() - if batchLen == 0 { - break - } - if isShardedObservabilityTable(item.tableName) { - for index := 0; index < batchLen; index++ { - record := batchPtr.Elem().Index(index) - if err := target.Create(record.Addr().Interface()).Error; err != nil { - return fmt.Errorf("write target sharded table %s failed: %w", item.tableName, err) - } - } - } else { - if err := target.Create(batchPtr.Interface()).Error; err != nil { - return fmt.Errorf("write target table %s failed: %w", item.tableName, err) - } - } - migrated += int64(batchLen) - offset += batchLen - slog.Info("database migration progress", "table", item.tableName, "migrated", migrated, "total", total, "status", "running") - } - - slog.Info("database migration progress", "table", item.tableName, "migrated", migrated, "total", total, "status", "completed") - return nil -} - -func resetPostgresSequence(db *gorm.DB, tableName string) error { - sql := fmt.Sprintf( - "SELECT setval(pg_get_serial_sequence('%s', 'id'), COALESCE(MAX(id), 1), MAX(id) IS NOT NULL) FROM \"%s\"", - tableName, - tableName, - ) - return db.Exec(sql).Error -} - -func InitBenchmarkDB(dsn string) error { - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) - if err != nil { - return err - } - sqlDB, err := db.DB() - if err != nil { - return err - } - sqlDB.SetMaxOpenConns(20) - sqlDB.SetMaxIdleConns(10) - DB = db - if err = registerSharding(db, "postgres"); err != nil { - return err - } - return ensureDatabaseSchemaUpToDate(db, "postgres") -} - -func InitDB() (err error) { - db, backend, err := openDatabase() - if err != nil { - slog.Error("open database failed", "error", err) - os.Exit(1) - } - DB = db - if err = registerSharding(db, backend); err != nil { - return err - } - if err = ensureDatabaseSchemaUpToDate(db, backend); err != nil { - return err - } - return createRootAccountIfNeed() -} - -func CloseDB() error { - sqlDB, err := DB.DB() - if err != nil { - return err - } - err = sqlDB.Close() - return err -} - -func IsUniqueConstraintError(err error) bool { - if err == nil { - return false - } - return strings.Contains(strings.ToLower(err.Error()), "unique") -} diff --git a/openflare-server/internal/model/main_test.go b/openflare-server/internal/model/main_test.go deleted file mode 100644 index 9cc49793..00000000 --- a/openflare-server/internal/model/main_test.go +++ /dev/null @@ -1,886 +0,0 @@ -package model - -import ( - "encoding/json" - "go/ast" - "go/parser" - "go/token" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" -) - -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() - - db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite db: %v", err) - } - sqlDB, err := db.DB() - if err != nil { - t.Fatalf("get sql db: %v", err) - } - t.Cleanup(func() { - _ = sqlDB.Close() - }) - return db -} - -func openTestSQLiteDB(t *testing.T, name string) *gorm.DB { - t.Helper() - - db := openBareTestSQLiteDB(t, name) - if err := autoMigrateAll(db); err != nil { - t.Fatalf("auto migrate db: %v", err) - } - return db -} - -func findDBModelByTableName(t *testing.T, tableName string) dbModel { - t.Helper() - - models, err := buildDBModels() - if err != nil { - t.Fatalf("build db models: %v", err) - } - for _, item := range models { - if item.tableName == tableName { - return item - } - } - t.Fatalf("db model not found for table %s", tableName) - return dbModel{} -} - -func expectedCurrentDatabaseVersion() int { - return int(currentGooseTargetVersion()) -} - -func TestIsDatabaseEmpty(t *testing.T) { - db := openTestSQLiteDB(t, "empty.db") - - empty, err := isDatabaseEmpty(db) - if err != nil { - t.Fatalf("isDatabaseEmpty returned error: %v", err) - } - if !empty { - t.Fatal("expected database to be empty") - } - - if err := db.Create(&User{ - Username: "alice", - Password: "secret", - DisplayName: "Alice", - Role: 1, - Status: 1, - }).Error; err != nil { - t.Fatalf("seed user: %v", err) - } - - empty, err = isDatabaseEmpty(db) - if err != nil { - t.Fatalf("isDatabaseEmpty after seed returned error: %v", err) - } - if empty { - t.Fatal("expected database to be non-empty") - } -} - -func TestMigrateTableDataCopiesRows(t *testing.T) { - source := openTestSQLiteDB(t, "source.db") - target := openTestSQLiteDB(t, "target.db") - - user := User{ - Id: 1, - Username: "root", - Password: "hashed", - DisplayName: "Root User", - Role: 100, - Status: 1, - } - option := Option{ - Key: "AgentHeartbeatInterval", - Value: "10000", - } - - if err := source.Create(&user).Error; err != nil { - t.Fatalf("seed source user: %v", err) - } - if err := source.Create(&option).Error; err != nil { - t.Fatalf("seed source option: %v", err) - } - - if err := migrateTableData(source, target, findDBModelByTableName(t, "users")); err != nil { - t.Fatalf("migrate users: %v", err) - } - if err := migrateTableData(source, target, findDBModelByTableName(t, "options")); err != nil { - t.Fatalf("migrate options: %v", err) - } - - var gotUser User - if err := target.First(&gotUser, 1).Error; err != nil { - t.Fatalf("query migrated user: %v", err) - } - if gotUser.Username != user.Username || gotUser.DisplayName != user.DisplayName { - t.Fatalf("unexpected migrated user: %+v", gotUser) - } - - var gotOption Option - if err := target.First(&gotOption, "key = ?", option.Key).Error; err != nil { - t.Fatalf("query migrated option: %v", err) - } - if gotOption.Value != option.Value { - t.Fatalf("unexpected migrated option value: %s", gotOption.Value) - } -} - -func TestRegisterShardingAutoMigratesShardTables(t *testing.T) { - db := openBareTestSQLiteDB(t, "sharded.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := autoMigrateAll(db); err != nil { - t.Fatalf("auto migrate db: %v", err) - } - - for _, table := range []string{ - "node_metric_snapshots_00", - "node_metric_snapshots_09", - "node_request_reports_00", - "node_request_reports_09", - "node_access_logs_00", - "node_access_logs_09", - } { - if !db.Migrator().HasTable(table) { - t.Fatalf("expected sharded table %s to exist", table) - } - } -} - -func TestUpgradeDatabaseSchemaV15ToV16AppliesCompressedReleaseSchema(t *testing.T) { - db := openBareTestSQLiteDB(t, "v16.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := autoMigrateLegacySchemaMetadata(db); err != nil { - t.Fatalf("auto migrate legacy schema metadata: %v", err) - } - if err := applyCurrentSchema(db, "sqlite"); err != nil { - t.Fatalf("apply current schema: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil { - t.Fatalf("failed to add legacy pow_enabled: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil { - t.Fatalf("failed to add legacy pow_config: %v", err) - } - if err := ensureDefaultWAFRuleGroup(db); err != nil { - t.Fatalf("ensure default waf rule group: %v", err) - } - if err := saveDatabaseSchemaVersion(db, 15); err != nil { - t.Fatalf("save schema version: %v", err) - } - if err := upgradeDatabaseSchema(db, "sqlite", 15); err != nil { - t.Fatalf("upgrade schema: %v", err) - } - if !db.Migrator().HasTable(&WAFIPGroup{}) { - t.Fatal("expected waf_ip_groups table") - } - if !db.Migrator().HasColumn(&WAFRuleGroup{}, "ip_whitelist_groups") { - t.Fatal("expected waf_rule_groups.ip_whitelist_groups column") - } - if !db.Migrator().HasColumn(&Node{}, "access_token") { - t.Fatal("expected nodes.access_token column") - } - if !db.Migrator().HasColumn(&Node{}, "version") { - t.Fatal("expected nodes.version column") - } - if !db.Migrator().HasColumn(&Node{}, "ext_version") { - t.Fatal("expected nodes.ext_version column") - } - if !db.Migrator().HasColumn(&ProxyRoute{}, "tunnel_node_id") { - t.Fatal("expected proxy_routes.tunnel_node_id column") - } - if db.Migrator().HasTable("tunnels") { - t.Fatal("expected pre-release tunnels table to be absent") - } - version, ok, err := loadDatabaseSchemaVersion(db) - if err != nil { - t.Fatalf("load schema version: %v", err) - } - if !ok || version != currentDatabaseSchemaVersion { - t.Fatalf("unexpected schema version: got %d ok=%v want %d", version, ok, currentDatabaseSchemaVersion) - } -} - -func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testing.T) { - db := openTestSQLiteDB(t, "legacy-health-events.db") - - if err := db.Exec("ALTER TABLE node_health_events ADD COLUMN raw_json TEXT").Error; err != nil { - t.Fatalf("add raw_json column: %v", err) - } - rawJSON, err := json.Marshal(map[string]any{ - "event_type": "sync_error", - "metadata": map[string]string{ - "reason": "checksum_mismatch", - "scope": "routes", - }, - }) - if err != nil { - t.Fatalf("marshal raw json: %v", err) - } - event := &NodeHealthEvent{ - NodeID: "node-legacy", - EventType: "sync_error", - Severity: "warning", - Status: "active", - Message: "checksum mismatch", - FirstTriggeredAt: time.Now().Add(-time.Minute), - LastTriggeredAt: time.Now(), - ReportedAt: time.Now(), - } - if err := db.Create(event).Error; err != nil { - t.Fatalf("create health event: %v", err) - } - if err := db.Exec("UPDATE node_health_events SET raw_json = ? WHERE id = ?", string(rawJSON), event.ID).Error; err != nil { - t.Fatalf("seed legacy raw_json: %v", err) - } - - if err := migrateObservabilityLegacyColumns(db); err != nil { - t.Fatalf("migrateObservabilityLegacyColumns: %v", err) - } - - var got NodeHealthEvent - if err := db.First(&got, event.ID).Error; err != nil { - t.Fatalf("query health event: %v", err) - } - if got.MetadataJSON == "" { - t.Fatal("expected metadata_json to be backfilled") - } -} - -func TestEnsureDatabaseSchemaUpToDateInitializesFreshDatabase(t *testing.T) { - db := openBareTestSQLiteDB(t, "fresh-schema.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err) - } - - version, exists, err := loadDatabaseSchemaVersion(db) - if err != nil { - t.Fatalf("loadDatabaseSchemaVersion: %v", err) - } - if !exists { - t.Fatal("expected database schema version to be recorded") - } - if version != expectedCurrentDatabaseVersion() { - t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion()) - } - if db.Migrator().HasTable(&DatabaseSchemaVersion{}) { - t.Fatal("expected fresh database to avoid legacy database_schema_versions table") - } - if !db.Migrator().HasTable("goose_db_version") { - t.Fatal("expected fresh database to initialize goose_db_version") - } - if !db.Migrator().HasColumn(&Node{}, "capabilities_json") { - t.Fatal("expected fresh database to apply goose migration nodes.capabilities_json") - } -} - -func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) { - db := openBareTestSQLiteDB(t, "legacy-schema.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := autoMigrateAll(db); err != nil { - t.Fatalf("auto migrate db: %v", err) - } - // Add legacy PoW columns manually to proxy_routes table to simulate legacy schema v9-v17 state - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil { - t.Fatalf("failed to add legacy pow_enabled: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil { - t.Fatalf("failed to add legacy pow_config: %v", err) - } - if err := db.Create(&User{ - Username: "legacy", - Password: "secret", - DisplayName: "Legacy User", - Role: 1, - Status: 1, - }).Error; err != nil { - t.Fatalf("seed legacy user: %v", err) - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err) - } - - version, exists, err := loadDatabaseSchemaVersion(db) - if err != nil { - t.Fatalf("loadDatabaseSchemaVersion: %v", err) - } - if !exists { - t.Fatal("expected legacy database to gain a schema version record") - } - if version != expectedCurrentDatabaseVersion() { - t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion()) - } - if db.Migrator().HasTable(&DatabaseSchemaVersion{}) { - t.Fatal("expected legacy database_schema_versions table to be removed after bridging to goose") - } - if !db.Migrator().HasTable("goose_db_version") { - t.Fatal("expected legacy upgrade to initialize goose_db_version") - } - if !db.Migrator().HasColumn(&Node{}, "capabilities_json") { - t.Fatal("expected legacy upgrade to apply goose migration nodes.capabilities_json") - } -} - -func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) { - db := openBareTestSQLiteDB(t, "legacy-origins.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := applyCurrentSchema(db, "sqlite"); err != nil { - t.Fatalf("applyCurrentSchema: %v", err) - } - now := time.Now().UTC() - route := &ProxyRoute{ - Domain: "app.example.com", - OriginURL: "https://origin-a.internal:8443/api", - Upstreams: `["https://origin-a.internal:8443/api"]`, - Enabled: true, - CreatedAt: now, - UpdatedAt: now, - } - if err := db.Create(route).Error; err != nil { - t.Fatalf("seed proxy route: %v", err) - } - if err := db.Exec(`DELETE FROM origins`).Error; err != nil { - t.Fatalf("clear origins: %v", err) - } - if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Update("origin_id", nil).Error; err != nil { - t.Fatalf("clear route origin_id: %v", err) - } - - if err := backfillOriginsFromProxyRoutes(db); err != nil { - t.Fatalf("backfillOriginsFromProxyRoutes: %v", err) - } - - if !db.Migrator().HasTable(&Origin{}) { - t.Fatal("expected origins table to exist") - } - if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") { - t.Fatal("expected proxy_routes.origin_id column to exist") - } - - reloadedRoute := &ProxyRoute{} - if err := db.First(reloadedRoute, route.ID).Error; err != nil { - t.Fatalf("query proxy route: %v", err) - } - if reloadedRoute.OriginID == nil || *reloadedRoute.OriginID == 0 { - t.Fatal("expected migrated route to be linked to a backfilled origin") - } - - origin := &Origin{} - if err := db.First(origin, *reloadedRoute.OriginID).Error; err != nil { - t.Fatalf("query origin: %v", err) - } - if origin.Address != "origin-a.internal" { - t.Fatalf("unexpected backfilled origin address: %s", origin.Address) - } -} - -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 := autoMigrateLegacySchemaMetadata(db); err != nil { - t.Fatalf("auto migrate legacy 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) - } - // Add legacy PoW columns manually to proxy_routes table to simulate legacy schema v9-v17 state - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil { - t.Fatalf("failed to add legacy pow_enabled: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil { - t.Fatalf("failed to add legacy pow_config: %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") - - err := runDatabaseSchemaMigration(db, "sqlite", databaseSchemaMigration{ - fromVersion: legacyDatabaseSchemaVersion, - toVersion: 11, - migrate: func(tx *gorm.DB, backend string) error { - return autoMigrateLegacySchemaMetadata(tx) - }, - validate: func(tx *gorm.DB, backend string) error { - return gorm.ErrInvalidDB - }, - }) - if err == nil { - t.Fatal("expected migration validation to fail") - } - - _, exists, loadErr := loadDatabaseSchemaVersion(db) - if loadErr != nil { - t.Fatalf("loadDatabaseSchemaVersion: %v", loadErr) - } - if exists { - t.Fatal("expected schema version to remain unset after failed validation") - } -} - -func TestEnsureDatabaseSchemaUpToDateAddsNodeIPManualOverride(t *testing.T) { - db := openBareTestSQLiteDB(t, "node-ip-manual-override-migration.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := applyCurrentSchema(db, "sqlite"); err != nil { - t.Fatalf("apply current schema: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil { - t.Fatalf("failed to add legacy pow_enabled: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil { - t.Fatalf("failed to add legacy pow_config: %v", err) - } - if err := ensureDefaultWAFRuleGroup(db); err != nil { - t.Fatalf("ensure default waf rule group: %v", err) - } - if err := db.Migrator().DropColumn(&Node{}, "ip_manual_override"); err != nil { - t.Fatalf("drop ip_manual_override column: %v", err) - } - if db.Migrator().HasColumn(&Node{}, "ip_manual_override") { - t.Fatal("expected test database to simulate schema v14 without ip_manual_override") - } - if err := saveDatabaseSchemaVersion(db, 14); err != nil { - t.Fatalf("save schema version: %v", err) - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err) - } - - if !db.Migrator().HasColumn(&Node{}, "ip_manual_override") { - t.Fatal("expected migration to add nodes.ip_manual_override") - } - version, exists, err := loadDatabaseSchemaVersion(db) - if err != nil { - t.Fatalf("loadDatabaseSchemaVersion: %v", err) - } - if !exists { - t.Fatal("expected schema version record to exist") - } - if version != expectedCurrentDatabaseVersion() { - t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion()) - } - if !db.Migrator().HasColumn(&Node{}, "capabilities_json") { - t.Fatal("expected migration chain to include nodes.capabilities_json") - } -} - -func TestEnsureDatabaseSchemaUpToDateV16BackfillsNodeColumnsWhenNewColumnsAlreadyExist(t *testing.T) { - db := openBareTestSQLiteDB(t, "node-v16-existing-target-columns.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := applyCurrentSchema(db, "sqlite"); err != nil { - t.Fatalf("apply current schema: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_enabled BOOLEAN NOT NULL DEFAULT 0").Error; err != nil { - t.Fatalf("failed to add legacy pow_enabled: %v", err) - } - if err := db.Exec("ALTER TABLE proxy_routes ADD COLUMN pow_config TEXT NOT NULL DEFAULT '{}'").Error; err != nil { - t.Fatalf("failed to add legacy pow_config: %v", err) - } - if err := ensureDefaultWAFRuleGroup(db); err != nil { - t.Fatalf("ensure default waf rule group: %v", err) - } - for _, stmt := range []string{ - `ALTER TABLE nodes ADD COLUMN agent_token text`, - `ALTER TABLE nodes ADD COLUMN agent_version text`, - `ALTER TABLE nodes ADD COLUMN nginx_version text`, - `ALTER TABLE nodes ADD COLUMN relay_version text`, - `ALTER TABLE nodes ADD COLUMN relay_frp_version text`, - `ALTER TABLE nodes ADD COLUMN relay_frps_connections integer`, - `ALTER TABLE nodes ADD COLUMN relay_frps_proxy_count integer`, - } { - if err := db.Exec(stmt).Error; err != nil { - t.Fatalf("prepare legacy node column with %q: %v", stmt, err) - } - } - now := time.Now() - if err := db.Exec(` - INSERT INTO nodes ( - node_id, name, ip, access_token, version, ext_version, - agent_token, agent_version, nginx_version, - status, last_seen_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, "node-v16", "Node v16", "127.0.0.1", "", "", "", "legacy-token", "v2.0.0", "openresty/1.25.3", "offline", now, now, now).Error; err != nil { - t.Fatalf("seed node with legacy columns: %v", err) - } - if err := saveDatabaseSchemaVersion(db, 15); err != nil { - t.Fatalf("save schema version: %v", err) - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err) - } - - var node Node - if err := db.Where("node_id = ?", "node-v16").First(&node).Error; err != nil { - t.Fatalf("query migrated node: %v", err) - } - if node.AccessToken != "legacy-token" { - t.Fatalf("unexpected access_token: got %q", node.AccessToken) - } - if node.Version != "v2.0.0" { - t.Fatalf("unexpected version: got %q", node.Version) - } - if node.ExtVersion != "openresty/1.25.3" { - t.Fatalf("unexpected ext_version: got %q", node.ExtVersion) - } - for _, column := range []string{ - "agent_token", - "agent_version", - "nginx_version", - "relay_version", - "relay_frp_version", - "relay_frps_connections", - "relay_frps_proxy_count", - } { - exists, err := databaseColumnExists(db, "nodes", column) - if err != nil { - t.Fatalf("inspect legacy nodes.%s: %v", column, err) - } - if exists { - t.Fatalf("expected migration to drop legacy nodes.%s column", column) - } - } - version, exists, err := loadDatabaseSchemaVersion(db) - if err != nil { - t.Fatalf("loadDatabaseSchemaVersion: %v", err) - } - if !exists { - t.Fatal("expected schema version record to exist") - } - if version != expectedCurrentDatabaseVersion() { - t.Fatalf("unexpected schema version: got %d want %d", version, expectedCurrentDatabaseVersion()) - } - if !db.Migrator().HasColumn(&Node{}, "capabilities_json") { - t.Fatal("expected v16 upgrade path to apply goose migration nodes.capabilities_json") - } -} - -func TestEnsureDatabaseSchemaUpToDateV16DropsLegacyNodeColumnsWhenAlreadyCurrent(t *testing.T) { - db := openBareTestSQLiteDB(t, "node-v16-current-legacy-columns.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := applyCurrentSchema(db, "sqlite"); err != nil { - t.Fatalf("apply current schema: %v", err) - } - for _, stmt := range []string{ - `ALTER TABLE nodes ADD COLUMN agent_token text`, - `ALTER TABLE nodes ADD COLUMN agent_version text`, - `ALTER TABLE nodes ADD COLUMN nginx_version text`, - } { - if err := db.Exec(stmt).Error; err != nil { - t.Fatalf("prepare legacy node column with %q: %v", stmt, err) - } - } - if err := saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion); err != nil { - t.Fatalf("save schema version: %v", err) - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err) - } - - for _, column := range []string{"agent_token", "agent_version", "nginx_version"} { - exists, err := databaseColumnExists(db, "nodes", column) - if err != nil { - t.Fatalf("inspect legacy nodes.%s: %v", column, err) - } - if exists { - t.Fatalf("expected current-schema cleanup to drop legacy nodes.%s column", column) - } - } - if db.Migrator().HasTable(&DatabaseSchemaVersion{}) { - t.Fatal("expected current-schema legacy version table to be removed after goose bridge") - } - if !db.Migrator().HasTable("goose_db_version") { - t.Fatal("expected current-schema goose_db_version table to exist") - } - if !db.Migrator().HasColumn(&Node{}, "capabilities_json") { - t.Fatal("expected current-schema repair to preserve goose column nodes.capabilities_json") - } -} - -func TestEnsureDatabaseSchemaUpToDateKeepsGooseOnlyDatabaseOnReentry(t *testing.T) { - db := openBareTestSQLiteDB(t, "goose-only-reentry.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("first ensureDatabaseSchemaUpToDate: %v", err) - } - if db.Migrator().HasTable(&DatabaseSchemaVersion{}) { - t.Fatal("expected first initialization to avoid legacy table") - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("second ensureDatabaseSchemaUpToDate: %v", err) - } - - if db.Migrator().HasTable(&DatabaseSchemaVersion{}) { - t.Fatal("expected goose-only database to remain free of legacy version table") - } - version, exists, err := loadGooseDatabaseVersion(db) - if err != nil { - t.Fatalf("loadGooseDatabaseVersion: %v", err) - } - if !exists { - t.Fatal("expected goose-only database to keep goose version record") - } - if version != expectedCurrentDatabaseVersion() { - t.Fatalf("unexpected goose version: got %d want %d", version, expectedCurrentDatabaseVersion()) - } - if !db.Migrator().HasColumn(&Node{}, "capabilities_json") { - t.Fatal("expected goose-only database to keep nodes.capabilities_json") - } -} - -func TestAllRegisteredMigrationsHaveValidationDefined(t *testing.T) { - ctx := databaseSchemaMigrationContext{} - for _, migration := range databaseSchemaMigrations() { - err := ctx.ValidateDatabaseSchemaVersion(nil, "sqlite", migration.toVersion) - if err != nil && strings.Contains(err.Error(), "is not defined") { - t.Fatalf("Validation is not defined in migrations.go for registered migration version v%d: %v", migration.toVersion, err) - } - } -} - -func TestAllGORMModelsAreRegistered(t *testing.T) { - // 1. Gather all registered model names - registeredNames := make(map[string]bool) - for _, item := range registeredModels() { - name := reflect.TypeOf(item).Elem().Name() - registeredNames[name] = true - } - for _, item := range schemaMetadataModels() { - name := reflect.TypeOf(item).Elem().Name() - registeredNames[name] = true - } - - // 2. Parse all .go files in model/ package - fset := token.NewFileSet() - pkgs, err := parser.ParseDir(fset, ".", func(info os.FileInfo) bool { - // Only parse .go files, exclude _test.go files and subdirectories - return !info.IsDir() && strings.HasSuffix(info.Name(), ".go") && !strings.HasSuffix(info.Name(), "_test.go") - }, 0) - if err != nil { - t.Fatalf("failed to parse directory: %v", err) - } - - for _, pkg := range pkgs { - for _, file := range pkg.Files { - for _, decl := range file.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.TYPE { - continue - } - for _, spec := range genDecl.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - structType, ok := typeSpec.Type.(*ast.StructType) - if !ok { - continue - } - - // Verify if this struct has any field with a `gorm:"..."` tag - isGORMModel := false - for _, field := range structType.Fields.List { - if field.Tag != nil && strings.Contains(field.Tag.Value, "gorm:") { - isGORMModel = true - break - } - } - - if isGORMModel { - structName := typeSpec.Name.Name - if !registeredNames[structName] { - t.Errorf("Model struct %q is defined with GORM tags but is NOT registered in registeredModels() or schemaMetadataModels() in model/main.go!", structName) - } - } - } - } - } - } -} - -func TestEnsureDatabaseSchemaUpToDateDropsPagesDeploymentUnusedFields(t *testing.T) { - db := openBareTestSQLiteDB(t, "drop-pages-deployment-unused-fields.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("first ensureDatabaseSchemaUpToDate: %v", err) - } - - // Verify columns do not exist - if db.Migrator().HasColumn("pages_deployments", "root_dir") { - t.Fatal("expected root_dir column to be absent initially") - } - if db.Migrator().HasColumn("pages_deployments", "entry_file") { - t.Fatal("expected entry_file column to be absent initially") - } - - // Manually add columns to simulate old state - if err := db.Exec("ALTER TABLE pages_deployments ADD COLUMN root_dir TEXT").Error; err != nil { - t.Fatalf("failed to add root_dir column: %v", err) - } - if err := db.Exec("ALTER TABLE pages_deployments ADD COLUMN entry_file TEXT").Error; err != nil { - t.Fatalf("failed to add entry_file column: %v", err) - } - - // Verify columns were added - if !db.Migrator().HasColumn("pages_deployments", "root_dir") { - t.Fatal("expected root_dir column to be present after manual add") - } - if !db.Migrator().HasColumn("pages_deployments", "entry_file") { - t.Fatal("expected entry_file column to be present after manual add") - } - - // Remove the migration record from goose_db_version table - const versionToRerun = 202606040004 - if err := db.Exec("DELETE FROM goose_db_version WHERE version_id = ?", versionToRerun).Error; err != nil { - t.Fatalf("failed to delete migration record: %v", err) - } - - // Run migration again - if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil { - t.Fatalf("second ensureDatabaseSchemaUpToDate: %v", err) - } - - // Verify columns were dropped successfully - if db.Migrator().HasColumn("pages_deployments", "root_dir") { - t.Fatal("expected root_dir column to be dropped after migration rerun") - } - if db.Migrator().HasColumn("pages_deployments", "entry_file") { - t.Fatal("expected entry_file column to be dropped after migration rerun") - } -} diff --git a/openflare-server/internal/model/managed_domain.go b/openflare-server/internal/model/managed_domain.go deleted file mode 100644 index 2dacc761..00000000 --- a/openflare-server/internal/model/managed_domain.go +++ /dev/null @@ -1,41 +0,0 @@ -package model - -import "time" - -type ManagedDomain struct { - ID uint `json:"id" gorm:"primaryKey"` - Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"` - CertID *uint `json:"cert_id"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - Remark string `json:"remark" gorm:"size:255"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func ListManagedDomains() (domains []*ManagedDomain, err error) { - err = DB.Order("id desc").Find(&domains).Error - return domains, err -} - -func ListEnabledManagedDomainsWithCertificate() (domains []*ManagedDomain, err error) { - err = DB.Where("enabled = ? AND cert_id IS NOT NULL", true).Order("id desc").Find(&domains).Error - return domains, err -} - -func GetManagedDomainByID(id uint) (*ManagedDomain, error) { - domain := &ManagedDomain{} - err := DB.First(domain, id).Error - return domain, err -} - -func (domain *ManagedDomain) Insert() error { - return DB.Create(domain).Error -} - -func (domain *ManagedDomain) Update() error { - return DB.Save(domain).Error -} - -func (domain *ManagedDomain) Delete() error { - return DB.Delete(domain).Error -} diff --git a/openflare-server/internal/model/migrate/initial.go b/openflare-server/internal/model/migrate/initial.go deleted file mode 100644 index 997d0b8e..00000000 --- a/openflare-server/internal/model/migrate/initial.go +++ /dev/null @@ -1,4 +0,0 @@ -package migrate - -// Versions 1 through 7 are treated as the historical baseline. There are no -// supported deployments below v8, so new upgrades start from this base version. diff --git a/openflare-server/internal/model/migrate/migration.go b/openflare-server/internal/model/migrate/migration.go deleted file mode 100644 index 3ae90306..00000000 --- a/openflare-server/internal/model/migrate/migration.go +++ /dev/null @@ -1,54 +0,0 @@ -package migrate - -import ( - "sort" - - "gorm.io/gorm" -) - -const BaseDatabaseSchemaVersion = 7 - -type Context interface { - ApplyCurrentSchema(db *gorm.DB, backend string) error - ApplyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...string) error - BackfillOriginsFromProxyRoutes(db *gorm.DB) error - BackfillProxyRouteSiteFields(db *gorm.DB) error - EnsureProxyRouteSiteNameUniqueIndex(db *gorm.DB) error - BackfillProxyRouteCertificateFields(db *gorm.DB) error - BackfillProxyRouteDomainCertificateFields(db *gorm.DB) error - EnsureDefaultGitHubAuthSource(db *gorm.DB) error - EnsureDefaultWAFRuleGroup(db *gorm.DB) error - DropLegacyNodeColumns(db *gorm.DB, backend string) error - ValidateDatabaseSchemaVersion(db *gorm.DB, backend string, version int) error -} - -type Migration struct { - FromVersion int - ToVersion int - Migrate func(ctx Context, db *gorm.DB, backend string) error - Validate func(ctx Context, db *gorm.DB, backend string) error -} - -var registeredMigrations []Migration - -func Register(migration Migration) { - registeredMigrations = append(registeredMigrations, migration) -} - -func Migrations() []Migration { - migrations := append([]Migration{}, registeredMigrations...) - sort.Slice(migrations, func(i int, j int) bool { - return migrations[i].FromVersion < migrations[j].FromVersion - }) - return migrations -} - -func CurrentVersion() int { - version := BaseDatabaseSchemaVersion - for _, migration := range registeredMigrations { - if migration.ToVersion > version { - version = migration.ToVersion - } - } - return version -} diff --git a/openflare-server/internal/model/migrate/migration_test.go b/openflare-server/internal/model/migrate/migration_test.go deleted file mode 100644 index b679f789..00000000 --- a/openflare-server/internal/model/migrate/migration_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package migrate - -import "testing" - -func TestMigrationsAreContinuousFromBaseVersion(t *testing.T) { - migrations := Migrations() - if len(migrations) == 0 { - t.Fatal("expected at least one registered migration") - } - expectedFrom := BaseDatabaseSchemaVersion - for _, migration := range migrations { - if migration.FromVersion != expectedFrom { - t.Fatalf("expected migration from v%d, got v%d -> v%d", expectedFrom, migration.FromVersion, migration.ToVersion) - } - if migration.ToVersion != migration.FromVersion+1 { - t.Fatalf("expected one-step migration, got v%d -> v%d", migration.FromVersion, migration.ToVersion) - } - expectedFrom = migration.ToVersion - } - if CurrentVersion() != expectedFrom { - t.Fatalf("unexpected current version: got %d want %d", CurrentVersion(), expectedFrom) - } -} diff --git a/openflare-server/internal/model/migrate/v10.go b/openflare-server/internal/model/migrate/v10.go deleted file mode 100644 index 9436acc8..00000000 --- a/openflare-server/internal/model/migrate/v10.go +++ /dev/null @@ -1,26 +0,0 @@ -// v10 升级内容:新增可配置认证源与第三方账号绑定,并迁移旧 GitHub 登录配置。 -// 背景说明:登录体系从固定 GitHub OAuth 字段演进为通用认证源模型,需要创建 auth_sources、external_accounts,并把旧用户 GitHub 绑定迁移到新表。 -package migrate - -import "gorm.io/gorm" - -func init() { - Register(V10()) -} - -func V10() Migration { - return Migration{ - FromVersion: 9, - ToVersion: 10, - Migrate: migrateV10, - Validate: validateV10, - } -} - -func migrateV10(ctx Context, db *gorm.DB, backend string) error { - return ctx.EnsureDefaultGitHubAuthSource(db) -} - -func validateV10(ctx Context, db *gorm.DB, backend string) error { - return ctx.ValidateDatabaseSchemaVersion(db, backend, 10) -} diff --git a/openflare-server/internal/model/migrate/v11.go b/openflare-server/internal/model/migrate/v11.go deleted file mode 100644 index a0213da0..00000000 --- a/openflare-server/internal/model/migrate/v11.go +++ /dev/null @@ -1,26 +0,0 @@ -// v11 升级内容:新增 ACME 账户、DNS 账户,并扩展证书 provider 字段。 -// 背景说明:证书申请能力从单一手工导入扩展到自动签发,需要持久化 ACME/DNS 凭据,并标记证书来源。 -package migrate - -import "gorm.io/gorm" - -func init() { - Register(V11()) -} - -func V11() Migration { - return Migration{ - FromVersion: 10, - ToVersion: 11, - Migrate: migrateV11, - Validate: validateV11, - } -} - -func migrateV11(ctx Context, db *gorm.DB, backend string) error { - return nil -} - -func validateV11(ctx Context, db *gorm.DB, backend string) error { - return ctx.ValidateDatabaseSchemaVersion(db, backend, 11) -} diff --git a/openflare-server/internal/model/migrate/v12.go b/openflare-server/internal/model/migrate/v12.go deleted file mode 100644 index 38b85687..00000000 --- a/openflare-server/internal/model/migrate/v12.go +++ /dev/null @@ -1,26 +0,0 @@ -// v12 升级内容:为 proxy_routes 增加 Basic Auth 相关字段。 -// 背景说明:站点级访问控制需要支持基础认证,因此在代理路由配置中持久化 Basic Auth 开关与凭据配置。 -package migrate - -import "gorm.io/gorm" - -func init() { - Register(V12()) -} - -func V12() Migration { - return Migration{ - FromVersion: 11, - ToVersion: 12, - Migrate: migrateV12, - Validate: validateV12, - } -} - -func migrateV12(ctx Context, db *gorm.DB, backend string) error { - return nil -} - -func validateV12(ctx Context, db *gorm.DB, backend string) error { - return ctx.ValidateDatabaseSchemaVersion(db, backend, 12) -} diff --git a/openflare-server/internal/model/migrate/v13.go b/openflare-server/internal/model/migrate/v13.go deleted file mode 100644 index 7aa43dab..00000000 --- a/openflare-server/internal/model/migrate/v13.go +++ /dev/null @@ -1,26 +0,0 @@ -// v13 升级内容:新增 WAF 规则组与站点绑定表,并创建默认全局规则组。 -// 背景说明:WAF 配置从零散站点字段演进为可复用规则组,需要全局规则组作为默认入口,并支持站点与规则组绑定。 -package migrate - -import "gorm.io/gorm" - -func init() { - Register(V13()) -} - -func V13() Migration { - return Migration{ - FromVersion: 12, - ToVersion: 13, - Migrate: migrateV13, - Validate: validateV13, - } -} - -func migrateV13(ctx Context, db *gorm.DB, backend string) error { - return ctx.EnsureDefaultWAFRuleGroup(db) -} - -func validateV13(ctx Context, db *gorm.DB, backend string) error { - return ctx.ValidateDatabaseSchemaVersion(db, backend, 13) -} diff --git a/openflare-server/internal/model/migrate/v14.go b/openflare-server/internal/model/migrate/v14.go deleted file mode 100644 index 6df310a1..00000000 --- a/openflare-server/internal/model/migrate/v14.go +++ /dev/null @@ -1,26 +0,0 @@ -// v14 升级内容:为 WAF 规则组增加 PoW 策略字段。 -// 背景说明:PoW 能力从站点路由侧沉淀到 WAF 规则组中,便于统一按规则组管理人机挑战策略。 -package migrate - -import "gorm.io/gorm" - -func init() { - Register(V14()) -} - -func V14() Migration { - return Migration{ - FromVersion: 13, - ToVersion: 14, - Migrate: migrateV14, - Validate: validateV14, - } -} - -func migrateV14(ctx Context, db *gorm.DB, backend string) error { - return ctx.EnsureDefaultWAFRuleGroup(db) -} - -func validateV14(ctx Context, db *gorm.DB, backend string) error { - return ctx.ValidateDatabaseSchemaVersion(db, backend, 14) -} diff --git a/openflare-server/internal/model/migrate/v15.go b/openflare-server/internal/model/migrate/v15.go deleted file mode 100644 index b4771b86..00000000 --- a/openflare-server/internal/model/migrate/v15.go +++ /dev/null @@ -1,52 +0,0 @@ -// v15 升级内容:为 nodes 增加 ip_manual_override 字段。 -// 背景说明:管理端手动指定节点 IP 后,Agent 心跳不应继续覆盖该值,因此需要在节点表中记录 IP 是否由管理端锁定。 -package migrate - -import ( - "fmt" - - "gorm.io/gorm" -) - -type nodeV15 struct { - IPManualOverride bool `gorm:"column:ip_manual_override;not null;default:false"` -} - -func init() { - Register(V15()) -} - -func V15() Migration { - return Migration{ - FromVersion: 14, - ToVersion: 15, - Migrate: migrateV15, - Validate: validateV15, - } -} - -func (nodeV15) TableName() string { - return "nodes" -} - -func migrateV15(ctx Context, db *gorm.DB, backend string) error { - if db == nil { - return fmt.Errorf("database handle is nil") - } - if !db.Migrator().HasColumn(&nodeV15{}, "ip_manual_override") { - if err := db.Migrator().AddColumn(&nodeV15{}, "IPManualOverride"); err != nil { - return fmt.Errorf("add nodes.ip_manual_override: %w", err) - } - } - return nil -} - -func validateV15(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 14); err != nil { - return err - } - if db == nil || !db.Migrator().HasColumn(&nodeV15{}, "ip_manual_override") { - return fmt.Errorf("column nodes.ip_manual_override is missing") - } - return nil -} diff --git a/openflare-server/internal/model/migrate/v16.go b/openflare-server/internal/model/migrate/v16.go deleted file mode 100644 index 4567d80a..00000000 --- a/openflare-server/internal/model/migrate/v16.go +++ /dev/null @@ -1,185 +0,0 @@ -// v16 is the first database migration after the V15 formal release baseline. -// It folds the previously drafted v16-v21 schema work into a single official -// upgrade: tunnel-relay fields, WAF IP groups, current node identity/version -// columns, and split node observation tables. The migration also backfills -// legacy node columns and removes obsolete pre-release tunnel metadata when -// present, so V15 deployments can upgrade directly to the new formal schema. -package migrate - -import ( - "fmt" - "log/slog" - - "gorm.io/gorm" -) - -func (nodeV16) TableName() string { - return "nodes" -} - -func (tunnelV16) TableName() string { - return "tunnels" -} - -func (proxyRouteV16) TableName() string { - return "proxy_routes" -} - -type nodeV16 struct{} - -type tunnelV16 struct{} - -type proxyRouteV16 struct{} - -type wafIPGroupV16 struct{} - -type wafRuleGroupV16 struct{} - -func (wafIPGroupV16) TableName() string { - return "waf_ip_groups" -} - -func (wafRuleGroupV16) TableName() string { - return "waf_rule_groups" -} - -func init() { - Register(V16()) -} - -func V16() Migration { - return Migration{ - FromVersion: 15, - ToVersion: 16, - Migrate: migrateV16, - Validate: validateV16, - } -} - -func migrateV16(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - - migrator := db.Migrator() - if migrator.HasColumn(&nodeV16{}, "agent_token") { - if err := db.Exec(`UPDATE nodes SET access_token = agent_token WHERE access_token IS NULL OR access_token = ''`).Error; err != nil { - return fmt.Errorf("backfill nodes.access_token from agent_token: %w", err) - } - } - if migrator.HasColumn(&nodeV16{}, "agent_version") { - if err := db.Exec(`UPDATE nodes SET version = agent_version WHERE version = '' OR version IS NULL`).Error; err != nil { - return fmt.Errorf("backfill nodes.version from agent_version: %w", err) - } - } - if migrator.HasColumn(&nodeV16{}, "nginx_version") { - if err := db.Exec(`UPDATE nodes SET ext_version = nginx_version WHERE ext_version IS NULL OR ext_version = ''`).Error; err != nil { - return fmt.Errorf("backfill nodes.ext_version from nginx_version: %w", err) - } - } - if err := ctx.DropLegacyNodeColumns(db, backend); err != nil { - return err - } - - if err := db.Exec("UPDATE nodes SET node_type = 'edge_node' WHERE node_type = '' OR node_type IS NULL").Error; err != nil { - return fmt.Errorf("backfill nodes.node_type: %w", err) - } - if err := db.Exec("UPDATE proxy_routes SET upstream_type = 'direct' WHERE upstream_type = '' OR upstream_type IS NULL").Error; err != nil { - return fmt.Errorf("backfill proxy_routes.upstream_type: %w", err) - } - - if migrator.HasColumn(&proxyRouteV16{}, "tunnel_id") { - if err := db.Model(&proxyRouteV16{}).Where("upstream_type = ?", "tunnel").Update("upstream_type", "direct").Error; err != nil { - return fmt.Errorf("reset pre-release tunnel proxy routes: %w", err) - } - // Drop the legacy index idx_proxy_routes_tunnel_id if it exists, to avoid errors on dropping the tunnel_id column (especially on SQLite). - if migrator.HasIndex(&proxyRouteV16{}, "idx_proxy_routes_tunnel_id") { - if err := migrator.DropIndex(&proxyRouteV16{}, "idx_proxy_routes_tunnel_id"); err != nil { - return fmt.Errorf("drop index idx_proxy_routes_tunnel_id failed: %w", err) - } - } - if err := migrator.DropColumn(&proxyRouteV16{}, "tunnel_id"); err != nil { - return fmt.Errorf("drop pre-release proxy_routes.tunnel_id: %w", err) - } - } - if migrator.HasTable(&tunnelV16{}) { - if err := migrator.DropTable(&tunnelV16{}); err != nil { - return fmt.Errorf("drop pre-release tunnels table: %w", err) - } - slog.Info("dropped pre-release tunnels table during v16 migration") - } - - return nil -} - -func validateV16(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 15); err != nil { - return err - } - if db == nil { - return fmt.Errorf("database handle is nil") - } - - migrator := db.Migrator() - for _, column := range []string{ - "access_token", - "version", - "ext_version", - "node_type", - "relay_bind_port", - "relay_vhost_http_port", - "relay_auth_token", - "relay_agent_access_addr", - "relay_client_access_addr", - "relay_client_proxy_url", - "relay_status", - } { - if !migrator.HasColumn(&nodeV16{}, column) { - return fmt.Errorf("column nodes.%s is missing", column) - } - } - for _, column := range []string{ - "upstream_type", - "tunnel_node_id", - "tunnel_target_addr", - "tunnel_target_protocol", - } { - if !migrator.HasColumn(&proxyRouteV16{}, column) { - return fmt.Errorf("column proxy_routes.%s is missing", column) - } - } - if migrator.HasColumn(&proxyRouteV16{}, "tunnel_id") { - return fmt.Errorf("column proxy_routes.tunnel_id should not exist in v16") - } - if migrator.HasTable(&tunnelV16{}) { - return fmt.Errorf("table tunnels should not exist in v16") - } - for _, column := range []string{ - "agent_token", - "agent_version", - "nginx_version", - "relay_version", - "relay_frp_version", - "relay_frps_connections", - "relay_frps_proxy_count", - } { - if migrator.HasColumn(&nodeV16{}, column) { - return fmt.Errorf("column nodes.%s should not exist in v16", column) - } - } - if !migrator.HasTable(&wafIPGroupV16{}) { - return fmt.Errorf("table waf_ip_groups is missing") - } - for _, column := range []string{ - "ip_whitelist_groups", - "ip_blacklist_groups", - } { - if !migrator.HasColumn(&wafRuleGroupV16{}, column) { - return fmt.Errorf("column waf_rule_groups.%s is missing", column) - } - } - if !migrator.HasColumn(&wafIPGroupV16{}, "ext_ips") { - return fmt.Errorf("column waf_ip_groups.ext_ips is missing") - } - return nil -} diff --git a/openflare-server/internal/model/migrate/v17.go b/openflare-server/internal/model/migrate/v17.go deleted file mode 100644 index adcd114f..00000000 --- a/openflare-server/internal/model/migrate/v17.go +++ /dev/null @@ -1,58 +0,0 @@ -package migrate - -import ( - "fmt" - - "gorm.io/gorm" -) - -type nodeV17 struct{} - -func (nodeV17) TableName() string { - return "nodes" -} - -func init() { - Register(V17()) -} - -func V17() Migration { - return Migration{ - FromVersion: 16, - ToVersion: 17, - Migrate: migrateV17, - Validate: validateV17, - } -} - -func migrateV17(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - return nil -} - -func validateV17(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 16); err != nil { - return err - } - if db == nil { - return fmt.Errorf("database handle is nil") - } - - migrator := db.Migrator() - if !migrator.HasColumn(&nodeV17{}, "relay_web_server_enabled") { - return fmt.Errorf("column nodes.relay_web_server_enabled is missing") - } - - // Validate columns on a sharded partition table - for _, shard := range []string{"node_observation_frps_00"} { - for _, column := range []string{"frps_client_count", "frps_proxies"} { - if !migrator.HasColumn(shard, column) { - return fmt.Errorf("column %s.%s is missing", shard, column) - } - } - } - - return nil -} diff --git a/openflare-server/internal/model/migrate/v8.go b/openflare-server/internal/model/migrate/v8.go deleted file mode 100644 index 178b57ef..00000000 --- a/openflare-server/internal/model/migrate/v8.go +++ /dev/null @@ -1,41 +0,0 @@ -// v8 升级内容:为 proxy_routes 增加域名级证书绑定字段 domain_cert_ids,并回填已有站点的证书映射。 -// 背景说明:v1-v7 已作为历史初始基线合并;v8 是当前保留逐版本升级链的起点,用于把早期站点级证书列表扩展为每个域名可独立绑定证书。 -package migrate - -import "gorm.io/gorm" - -func init() { - Register(V8()) -} - -func V8() Migration { - return Migration{ - FromVersion: 7, - ToVersion: 8, - Migrate: migrateV8, - Validate: validateV8, - } -} - -func migrateV8(ctx Context, db *gorm.DB, backend string) error { - if err := ctx.ApplyCurrentSchema(db, backend); err != nil { - return err - } - if err := ctx.BackfillOriginsFromProxyRoutes(db); err != nil { - return err - } - if err := ctx.BackfillProxyRouteSiteFields(db); err != nil { - return err - } - if err := ctx.EnsureProxyRouteSiteNameUniqueIndex(db); err != nil { - return err - } - if err := ctx.BackfillProxyRouteCertificateFields(db); err != nil { - return err - } - return ctx.BackfillProxyRouteDomainCertificateFields(db) -} - -func validateV8(ctx Context, db *gorm.DB, backend string) error { - return ctx.ValidateDatabaseSchemaVersion(db, backend, 8) -} diff --git a/openflare-server/internal/model/migrate/v9.go b/openflare-server/internal/model/migrate/v9.go deleted file mode 100644 index 8c659a54..00000000 --- a/openflare-server/internal/model/migrate/v9.go +++ /dev/null @@ -1,29 +0,0 @@ -// v9 升级内容:为 proxy_routes 增加 PoW 防护配置字段。 -// 背景说明:反向代理站点需要支持 Proof-of-Work 抗机器人能力,因此在路由配置中持久化 PoW 开关与策略,并沿用 v8 的证书与站点字段回填。 -package migrate - -import "gorm.io/gorm" - -func init() { - Register(V9()) -} - -func V9() Migration { - return Migration{ - FromVersion: 8, - ToVersion: 9, - Migrate: migrateV9, - Validate: validateV9, - } -} - -func migrateV9(ctx Context, db *gorm.DB, backend string) error { - if err := migrateV8(ctx, db, backend); err != nil { - return err - } - return nil -} - -func validateV9(ctx Context, db *gorm.DB, backend string) error { - return ctx.ValidateDatabaseSchemaVersion(db, backend, 9) -} diff --git a/openflare-server/internal/model/migrations.go b/openflare-server/internal/model/migrations.go deleted file mode 100644 index 797d50d0..00000000 --- a/openflare-server/internal/model/migrations.go +++ /dev/null @@ -1,1286 +0,0 @@ -package model - -import ( - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "log/slog" - "net" - "net/url" - "strings" - "sync" - - schemamigrate "github.com/rain-kl/openflare/openflare-server/internal/model/migrate" - - "gorm.io/gorm" - "gorm.io/gorm/schema" -) - -type databaseSchemaMigration struct { - fromVersion int - toVersion int - migrate func(db *gorm.DB, backend string) error - validate func(db *gorm.DB, backend string) error -} - -type databaseSchemaMigrationContext struct{} - -func (databaseSchemaMigrationContext) ApplyCurrentSchema(db *gorm.DB, backend string) error { - return applyCurrentSchema(db, backend) -} - -func (databaseSchemaMigrationContext) ApplyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...string) error { - return applyCurrentSchemaExcept(db, backend, excludedTables...) -} - -func (databaseSchemaMigrationContext) BackfillOriginsFromProxyRoutes(db *gorm.DB) error { - return backfillOriginsFromProxyRoutes(db) -} - -func (databaseSchemaMigrationContext) BackfillProxyRouteSiteFields(db *gorm.DB) error { - return backfillProxyRouteSiteFields(db) -} - -func (databaseSchemaMigrationContext) EnsureProxyRouteSiteNameUniqueIndex(db *gorm.DB) error { - return ensureProxyRouteSiteNameUniqueIndex(db) -} - -func (databaseSchemaMigrationContext) BackfillProxyRouteCertificateFields(db *gorm.DB) error { - return backfillProxyRouteCertificateFields(db) -} - -func (databaseSchemaMigrationContext) BackfillProxyRouteDomainCertificateFields(db *gorm.DB) error { - return backfillProxyRouteDomainCertificateFields(db) -} - -func (databaseSchemaMigrationContext) EnsureDefaultGitHubAuthSource(db *gorm.DB) error { - return ensureDefaultGitHubAuthSource(db) -} - -func (databaseSchemaMigrationContext) EnsureDefaultWAFRuleGroup(db *gorm.DB) error { - return ensureDefaultWAFRuleGroup(db) -} - -func (databaseSchemaMigrationContext) DropLegacyNodeColumns(db *gorm.DB, backend string) error { - return dropLegacyNodeColumns(db, backend) -} - -func validateAllModelsSchema(db *gorm.DB) error { - models := registeredModels() - namer := schema.NamingStrategy{} - cache := &sync.Map{} - migrator := db.Migrator() - - for _, model := range models { - parsed, err := schema.Parse(model, cache, namer) - if err != nil { - return fmt.Errorf("parse model schema failed: %w", err) - } - - if isShardedObservabilityTable(parsed.Table) { - for _, table := range observabilityShardTables(parsed.Table) { - if !migrator.HasTable(table) { - return fmt.Errorf("sharded table %s is missing", table) - } - for _, field := range parsed.Fields { - if field.DBName != "" && !field.IgnoreMigration { - if !migrator.HasColumn(table, field.DBName) { - return fmt.Errorf("sharded column %s.%s is missing", table, field.DBName) - } - } - } - } - continue - } - - if !migrator.HasTable(model) { - return fmt.Errorf("table %s is missing", parsed.Table) - } - - for _, field := range parsed.Fields { - if field.DBName != "" && !field.IgnoreMigration { - if !migrator.HasColumn(model, field.DBName) { - return fmt.Errorf("column %s.%s is missing", parsed.Table, field.DBName) - } - } - } - } - return nil -} - -func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB, backend string, version int) error { - if db == nil { - return fmt.Errorf("database handle is nil") - } - switch version { - case 7: - return validateDatabaseSchemaV7(db, backend) - case 8: - return validateDatabaseSchemaV8(db, backend) - case 9, 10, 11, 12, 14, 15, 17: - return nil - case 13: - return validateDatabaseSchemaV13(db, backend) - case 16: - return validateDatabaseSchemaV16(db, backend) - default: - return fmt.Errorf("database schema validation for v%d is not defined", version) - } -} - -func autoMigrateCurrentSchemaMetadata(db *gorm.DB) error { - for _, item := range currentSchemaMetadataModels() { - if err := db.AutoMigrate(item); err != nil { - return err - } - } - return nil -} - -func autoMigrateLegacySchemaMetadata(db *gorm.DB) error { - for _, item := range legacySchemaMetadataModels() { - if err := db.AutoMigrate(item); err != nil { - return err - } - } - return nil -} - -func migrateProxyRouteEnableHTTPSColumn(db *gorm.DB) error { - if !db.Migrator().HasTable(&ProxyRoute{}) { - return nil - } - if db.Migrator().HasColumn(&ProxyRoute{}, "enable_https") || !db.Migrator().HasColumn(&ProxyRoute{}, "enable_http_s") { - return nil - } - return db.Migrator().RenameColumn(&ProxyRoute{}, "enable_http_s", "enable_https") -} - -func migrateTextColumns(db *gorm.DB, backend string) error { - if backend != "postgres" { - return nil - } - type textColumn struct { - model any - table string - column string - } - columns := []textColumn{ - {model: &Node{}, table: "nodes", column: "openresty_message"}, - {model: &Node{}, table: "nodes", column: "last_error"}, - {model: &ApplyLog{}, table: "apply_logs", column: "message"}, - {model: &NodeHealthEvent{}, table: "node_health_events", column: "message"}, - } - for _, item := range columns { - if !db.Migrator().HasTable(item.model) || !db.Migrator().HasColumn(item.model, item.column) { - continue - } - sql := fmt.Sprintf(`ALTER TABLE "%s" ALTER COLUMN "%s" TYPE text`, item.table, item.column) - if err := db.Exec(sql).Error; err != nil { - return fmt.Errorf("migrate column %s.%s to text failed: %w", item.table, item.column, err) - } - } - return nil -} - -func migrateObservabilityLegacyColumns(db *gorm.DB) error { - if db == nil { - return nil - } - if !db.Migrator().HasTable(&NodeHealthEvent{}) || !db.Migrator().HasColumn(&NodeHealthEvent{}, "raw_json") { - return nil - } - type legacyHealthEventRaw struct { - ID uint - RawJSON string - MetadataJSON string - } - type legacyHealthEventPayload struct { - Metadata map[string]string `json:"metadata"` - } - - var rows []legacyHealthEventRaw - if err := db.Model(&NodeHealthEvent{}). - Select("id, raw_json, metadata_json"). - Where("raw_json <> '' AND (metadata_json IS NULL OR metadata_json = '')"). - Find(&rows).Error; err != nil { - return fmt.Errorf("query legacy node health event raw_json failed: %w", err) - } - for _, row := range rows { - var payload legacyHealthEventPayload - if err := json.Unmarshal([]byte(row.RawJSON), &payload); err != nil { - continue - } - if len(payload.Metadata) == 0 { - continue - } - metadataJSON, err := json.Marshal(payload.Metadata) - if err != nil { - continue - } - if err := db.Model(&NodeHealthEvent{}). - Where("id = ?", row.ID). - Update("metadata_json", string(metadataJSON)).Error; err != nil { - return fmt.Errorf("migrate node health event metadata_json failed: %w", err) - } - } - return nil -} - -func applyCurrentSchema(db *gorm.DB, backend string) error { - return applyCurrentSchemaExcept(db, backend) -} - -func databaseColumnExists(db *gorm.DB, tableName string, columnName string) (bool, error) { - columnTypes, err := db.Migrator().ColumnTypes(tableName) - if err != nil { - return false, err - } - for _, columnType := range columnTypes { - if strings.EqualFold(columnType.Name(), columnName) { - return true, nil - } - } - return false, nil -} - -func dropLegacyNodeColumns(db *gorm.DB, backend string) error { - if db == nil || !db.Migrator().HasTable(&Node{}) { - return nil - } - // Drop the legacy index idx_nodes_agent_token if it exists, to avoid errors on dropping the agent_token column (especially on SQLite). - if db.Migrator().HasIndex(&Node{}, "idx_nodes_agent_token") { - if err := db.Migrator().DropIndex(&Node{}, "idx_nodes_agent_token"); err != nil { - return fmt.Errorf("drop index idx_nodes_agent_token failed: %w", err) - } - } - legacyColumns := []struct { - column string - }{ - {column: "agent_token"}, - {column: "agent_version"}, - {column: "nginx_version"}, - {column: "relay_version"}, - {column: "relay_frp_version"}, - {column: "relay_frps_connections"}, - {column: "relay_frps_proxy_count"}, - } - for _, item := range legacyColumns { - exists, err := databaseColumnExists(db, "nodes", item.column) - if err != nil { - return fmt.Errorf("inspect legacy nodes.%s failed: %w", item.column, err) - } - if !exists { - continue - } - if err := db.Exec(fmt.Sprintf(`ALTER TABLE "nodes" DROP COLUMN "%s"`, item.column)).Error; err != nil { - return fmt.Errorf("drop legacy nodes.%s failed: %w", item.column, err) - } - } - _ = backend - return nil -} - -func applyCurrentSchemaExcept(db *gorm.DB, backend string, excludedTables ...string) error { - excluded := make(map[string]bool, len(excludedTables)) - for _, table := range excludedTables { - if table != "" { - excluded[table] = true - } - } - slog.Info("applyCurrentSchema: step 1/5 - auto migrate schema metadata") - if err := autoMigrateCurrentSchemaMetadata(db); err != nil { - return err - } - slog.Info("applyCurrentSchema: step 2/5 - migrate proxy route https column") - if err := migrateProxyRouteEnableHTTPSColumn(db); err != nil { - return err - } - slog.Info("applyCurrentSchema: step 3/5 - auto migrate all models") - if err := autoMigrateAllExcept(db, excluded); err != nil { - return err - } - slog.Info("applyCurrentSchema: step 4/5 - migrate text columns") - if err := migrateTextColumns(db, backend); err != nil { - return err - } - slog.Info("applyCurrentSchema: step 5/5 - migrate observability legacy columns") - if err := migrateObservabilityLegacyColumns(db); err != nil { - return err - } - slog.Info("applyCurrentSchema: completed") - return nil -} - -func loadLegacyDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) { - if db == nil { - return 0, false, nil - } - if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) { - return 0, false, nil - } - var state DatabaseSchemaVersion - err := db.Where("id = ?", databaseSchemaVersionRowID).First(&state).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, false, nil - } - if err != nil { - return 0, false, err - } - return state.Version, true, nil -} - -func saveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error { - if err := autoMigrateLegacySchemaMetadata(db); err != nil { - return err - } - return db.Save(&DatabaseSchemaVersion{ - ID: databaseSchemaVersionRowID, - Version: version, - }).Error -} - -func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) { - version, exists, err := loadGooseDatabaseVersion(db) - if err != nil { - return 0, false, err - } - if exists { - return version, true, nil - } - return loadLegacyDatabaseSchemaVersion(db) -} - -func saveDatabaseSchemaVersion(db *gorm.DB, version int) error { - return saveLegacyDatabaseSchemaVersion(db, version) -} - -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 validateDatabaseSchemaV7(db *gorm.DB, backend string) error { - // Validate legacy sharded tables do not exist - for _, baseTable := range shardedObservabilityBaseTables() { - for _, table := range observabilityShardTables(baseTable) { - legacyTable := legacyObservabilityShardTableName(table) - if db.Migrator().HasTable(legacyTable) { - return fmt.Errorf("legacy sharded table %s still exists", legacyTable) - } - } - } - - // Fetch all proxy routes for data validation (site names, domains, certificates) - 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 and Site Name Validation - 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 - } - - // Certificate Mapping Validation - 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) - } - } - - _ = backend - return nil -} - -func validateDatabaseSchemaV8(db *gorm.DB, backend string) error { - 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) - } - } - _ = backend - 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 normalizeOriginAddressForMigration(raw string) string { - return strings.ToLower(strings.TrimSpace(raw)) -} - -func extractOriginAddressForMigration(rawURL string) string { - parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) - if err != nil { - return "" - } - return normalizeOriginAddressForMigration(parsed.Hostname()) -} - -func backfillOriginsFromProxyRoutes(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("database handle is nil") - } - if !db.Migrator().HasTable(&Origin{}) || !db.Migrator().HasTable(&ProxyRoute{}) { - return nil - } - - var routes []ProxyRoute - if err := db.Order("id asc").Find(&routes).Error; err != nil { - return fmt.Errorf("list proxy routes for origin backfill failed: %w", err) - } - - type originSeed struct { - ID uint - Address string - } - - originByAddress := make(map[string]originSeed) - var origins []Origin - if err := db.Order("id asc").Find(&origins).Error; err != nil { - return fmt.Errorf("list origins for backfill failed: %w", err) - } - for _, origin := range origins { - address := normalizeOriginAddressForMigration(origin.Address) - if address == "" { - continue - } - originByAddress[address] = originSeed{ID: origin.ID, Address: address} - } - - for _, route := range routes { - address := extractOriginAddressForMigration(route.OriginURL) - if address == "" { - continue - } - origin, ok := originByAddress[address] - if !ok { - name := address - if ip := net.ParseIP(address); ip != nil { - name = ip.String() - } - record := Origin{ - Name: name, - Address: address, - Remark: "", - } - if err := db.Create(&record).Error; err != nil { - return fmt.Errorf("create origin for address %s failed: %w", address, err) - } - origin = originSeed{ID: record.ID, Address: address} - originByAddress[address] = origin - } - if route.OriginID != nil && *route.OriginID == origin.ID { - continue - } - if err := db.Model(&ProxyRoute{}). - Where("id = ?", route.ID). - Update("origin_id", origin.ID).Error; err != nil { - return fmt.Errorf("backfill proxy route %d origin_id failed: %w", route.ID, err) - } - } - - return nil -} - -func ensureDefaultGitHubAuthSource(db *gorm.DB) error { - if db == nil || !db.Migrator().HasTable(&AuthSource{}) || !db.Migrator().HasTable(&ExternalAccount{}) { - return nil - } - - var githubUserCount int64 - if db.Migrator().HasColumn(&User{}, "github_id") { - if err := db.Model(&User{}).Where("github_id <> ''").Count(&githubUserCount).Error; err != nil { - return fmt.Errorf("count legacy github users failed: %w", err) - } - } - - optionMap := map[string]string{} - if db.Migrator().HasTable(&Option{}) { - var options []Option - if err := db.Find(&options).Error; err != nil { - return fmt.Errorf("query options for github auth source migration failed: %w", err) - } - for _, option := range options { - optionMap[option.Key] = option.Value - } - } - - clientID := strings.TrimSpace(optionMap["GitHubClientId"]) - clientSecret := strings.TrimSpace(optionMap["GitHubClientSecret"]) - enabled := optionMap["GitHubOAuthEnabled"] == "true" && clientID != "" && clientSecret != "" - if githubUserCount == 0 && clientID == "" && clientSecret == "" { - return nil - } - - source := AuthSource{} - err := db.Where("type = ? AND name = ?", AuthSourceTypeGitHub, "GitHub").First(&source).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - source = AuthSource{ - Name: "GitHub", - Type: AuthSourceTypeGitHub, - DisplayName: "GitHub", - IsActive: enabled, - ClientID: clientID, - ClientSecret: clientSecret, - Scopes: "user:email", - } - if err := db.Create(&source).Error; err != nil { - return fmt.Errorf("create default github auth source failed: %w", err) - } - } else if err != nil { - return fmt.Errorf("query default github auth source failed: %w", err) - } else { - updates := map[string]any{} - if source.ClientID == "" && clientID != "" { - updates["client_id"] = clientID - } - if source.ClientSecret == "" && clientSecret != "" { - updates["client_secret"] = clientSecret - } - if source.Scopes == "" { - updates["scopes"] = "user:email" - } - if enabled && !source.IsActive { - updates["is_active"] = true - } - if len(updates) > 0 { - if err := db.Model(&source).Updates(updates).Error; err != nil { - return fmt.Errorf("update default github auth source failed: %w", err) - } - } - } - - if githubUserCount == 0 { - return nil - } - - var users []User - if err := db.Select("id", "github_id", "username", "email").Where("github_id <> ''").Find(&users).Error; err != nil { - return fmt.Errorf("query legacy github users failed: %w", err) - } - for _, user := range users { - account := ExternalAccount{ - AuthSourceID: source.ID, - UserID: user.Id, - ExternalID: user.GitHubId, - ExternalUsername: user.GitHubId, - Email: user.Email, - } - if err := db.Where(ExternalAccount{ - AuthSourceID: source.ID, - ExternalID: user.GitHubId, - }).FirstOrCreate(&account).Error; err != nil { - return fmt.Errorf("migrate github external account for user %d failed: %w", user.Id, err) - } - } - return nil -} - -func ensureDefaultWAFRuleGroup(db *gorm.DB) error { - if db == nil { - return fmt.Errorf("database handle is nil") - } - if !db.Migrator().HasTable(&WAFRuleGroup{}) { - return nil - } - var count int64 - if err := db.Model(&WAFRuleGroup{}).Where("is_global = ?", true).Count(&count).Error; err != nil { - return fmt.Errorf("count global waf rule groups failed: %w", err) - } - if count > 0 { - return nil - } - group := WAFRuleGroup{ - Name: "全局规则组", - Enabled: true, - IsGlobal: true, - BlockStatusCode: 418, - IPWhitelist: "[]", - IPBlacklist: "[]", - IPWhitelistGroups: "[]", - IPBlacklistGroups: "[]", - CountryWhitelist: "[]", - CountryBlacklist: "[]", - RegionWhitelist: "[]", - RegionBlacklist: "[]", - PoWEnabled: false, - PoWConfig: "{}", - BlockResponseBody: "", - } - if err := db.Create(&group).Error; err != nil { - return fmt.Errorf("create default waf rule group failed: %w", err) - } - return nil -} - -func validateDatabaseSchemaV13(db *gorm.DB, backend string) error { - var count int64 - if err := db.Model(&WAFRuleGroup{}).Where("is_global = ?", true).Count(&count).Error; err != nil { - return fmt.Errorf("count global waf rule groups failed: %w", err) - } - if count != 1 { - return fmt.Errorf("expected exactly one global waf rule group, got %d", count) - } - _ = backend - return nil -} - -func validateDatabaseSchemaV16(db *gorm.DB, backend string) error { - migrator := db.Migrator() - if migrator.HasTable("tunnels") { - return fmt.Errorf("table tunnels should not exist in v16") - } - if migrator.HasColumn(&ProxyRoute{}, "tunnel_id") { - return fmt.Errorf("column proxy_routes.tunnel_id should not exist in v16") - } - for _, column := range []string{ - "agent_token", - "agent_version", - "nginx_version", - "relay_version", - "relay_frp_version", - "relay_frps_connections", - "relay_frps_proxy_count", - } { - exists, err := databaseColumnExists(db, "nodes", column) - if err != nil { - return fmt.Errorf("inspect legacy nodes.%s failed: %w", column, err) - } - if exists { - return fmt.Errorf("column nodes.%s should not exist in v16", column) - } - } - _ = backend - return nil -} - -func databaseSchemaMigrations() []databaseSchemaMigration { - ctx := databaseSchemaMigrationContext{} - migrations := []databaseSchemaMigration{} - for _, item := range schemamigrate.Migrations() { - external := item - migrations = append(migrations, databaseSchemaMigration{ - fromVersion: external.FromVersion, - toVersion: external.ToVersion, - migrate: func(db *gorm.DB, backend string) error { - return external.Migrate(ctx, db, backend) - }, - validate: func(db *gorm.DB, backend string) error { - return validateExternalDatabaseSchema(ctx, db, backend, external.ToVersion) - }, - }) - } - return migrations -} - -func validateExternalDatabaseSchema(ctx databaseSchemaMigrationContext, db *gorm.DB, backend string, targetVersion int) error { - if targetVersion <= schemamigrate.BaseDatabaseSchemaVersion { - return ctx.ValidateDatabaseSchemaVersion(db, backend, targetVersion) - } - for _, migration := range schemamigrate.Migrations() { - if migration.ToVersion == targetVersion { - return migration.Validate(ctx, db, backend) - } - } - return nil -} - -func validateCurrentDatabaseSchema(db *gorm.DB, backend string) error { - if err := validateAllModelsSchema(db); err != nil { - return err - } - return validateExternalDatabaseSchema(databaseSchemaMigrationContext{}, db, backend, currentDatabaseSchemaVersion) -} - -func databaseSchemaMigrationMap() map[int]databaseSchemaMigration { - migrations := make(map[int]databaseSchemaMigration, len(databaseSchemaMigrations())) - for _, item := range databaseSchemaMigrations() { - migrations[item.fromVersion] = item - } - return migrations -} - -func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseSchemaMigration) error { - if backend == "sqlite" { - if err := migration.migrate(db, backend); err != nil { - return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err) - } - if err := migration.validate(db, backend); err != nil { - return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err) - } - if err := saveLegacyDatabaseSchemaVersion(db, migration.toVersion); err != nil { - return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err) - } - return nil - } - - return db.Transaction(func(tx *gorm.DB) error { - if err := migration.migrate(tx, backend); err != nil { - return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err) - } - if err := migration.validate(tx, backend); err != nil { - return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err) - } - if err := saveLegacyDatabaseSchemaVersion(tx, migration.toVersion); err != nil { - return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err) - } - return nil - }) -} - -func upgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error { - if version > legacyMigrationTerminalVersion { - return fmt.Errorf("database schema version %d is newer than legacy migration terminal version %d", version, legacyMigrationTerminalVersion) - } - if version < legacyDatabaseSchemaVersion { - slog.Warn("database schema version is below supported baseline; treating it as historical initial schema", "version", version, "baseline", legacyDatabaseSchemaVersion) - version = legacyDatabaseSchemaVersion - } - if version == legacyMigrationTerminalVersion { - return nil - } - migrationMap := databaseSchemaMigrationMap() - for version < legacyMigrationTerminalVersion { - migration, ok := migrationMap[version] - if !ok { - return fmt.Errorf("database schema migration from v%d is not defined", version) - } - if err := runDatabaseSchemaMigration(db, backend, migration); err != nil { - return err - } - version = migration.toVersion - } - return nil -} - -func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error { - if err := applyCurrentSchema(db, backend); err != nil { - return err - } - if err := migrateSQLiteDataIfNeeded(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 - } - if err := backfillProxyRouteDomainCertificateFields(db); err != nil { - return err - } - if err := ensureDefaultGitHubAuthSource(db); err != nil { - return err - } - if err := ensureDefaultWAFRuleGroup(db); err != nil { - return err - } - return nil -} - -func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error { - return upgradeLegacyDatabaseSchema(db, backend, version) -} diff --git a/openflare-server/internal/model/node.go b/openflare-server/internal/model/node.go deleted file mode 100644 index 86ffcc85..00000000 --- a/openflare-server/internal/model/node.go +++ /dev/null @@ -1,91 +0,0 @@ -package model - -import "time" - -type Node struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"` - Name string `json:"name" gorm:"size:128;not null"` - IP string `json:"ip" gorm:"size:64;not null"` - IPManualOverride bool `json:"ip_manual_override" gorm:"not null;default:false"` - GeoName string `json:"geo_name" gorm:"size:128"` - GeoLatitude *float64 `json:"geo_latitude"` - GeoLongitude *float64 `json:"geo_longitude"` - GeoManualOverride bool `json:"geo_manual_override" gorm:"not null;default:false"` - AccessToken string `json:"-" gorm:"column:access_token;size:128;index"` - AutoUpdateEnabled bool `json:"auto_update_enabled" gorm:"not null;default:false"` - UpdateRequested bool `json:"update_requested" gorm:"not null;default:false"` - UpdateChannel string `json:"update_channel" gorm:"size:16;not null;default:'stable'"` - UpdateTag string `json:"update_tag" gorm:"size:64"` - RestartOpenrestyRequested bool `json:"restart_openresty_requested" gorm:"not null;default:false"` - Version string `json:"version" gorm:"size:64;not null;default:''"` - ExtVersion string `json:"ext_version" gorm:"size:64"` - OpenrestyStatus string `json:"openresty_status" gorm:"size:16;not null;default:'unknown'"` - OpenrestyMessage string `json:"openresty_message" gorm:"type:text"` - Status string `json:"status" gorm:"size:16;not null;default:'offline'"` - CurrentVersion string `json:"current_version" gorm:"size:32"` - LastSeenAt time.Time `json:"last_seen_at"` - LastError string `json:"last_error" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - // Node type: edge_node (default) | tunnel_relay | tunnel_client - NodeType string `json:"node_type" gorm:"size:32;not null;default:'edge_node'"` - // TunnelRelay specific fields - RelayBindPort int `json:"relay_bind_port" gorm:"not null;default:0"` - RelayVhostHTTPPort int `json:"relay_vhost_http_port" gorm:"not null;default:0"` - RelayAuthToken string `json:"-" gorm:"size:128"` - RelayAgentAccessAddr string `json:"relay_agent_access_addr" gorm:"size:255"` - RelayClientAccessAddr string `json:"relay_client_access_addr" gorm:"size:255"` - RelayClientProxyURL string `json:"relay_client_proxy_url" gorm:"size:512"` - CapabilitiesJSON string `json:"capabilities_json" gorm:"type:text;not null;default:'[]'"` - RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"` - RelayWebServerEnabled bool `json:"relay_web_server_enabled" gorm:"not null;default:false"` -} - -func ListNodes() (nodes []*Node, err error) { - err = DB.Order("id desc").Find(&nodes).Error - return nodes, err -} - -func ListNodesByNodeIDs(nodeIDs []string) (nodes []*Node, err error) { - if len(nodeIDs) == 0 { - return []*Node{}, nil - } - err = DB.Where("node_id IN ?", nodeIDs).Find(&nodes).Error - return nodes, err -} - -func GetNodeByNodeID(nodeID string) (*Node, error) { - node := &Node{} - err := DB.Where("node_id = ?", nodeID).First(node).Error - return node, err -} - -func GetNodeByID(id uint) (*Node, error) { - node := &Node{} - err := DB.First(node, id).Error - return node, err -} - -func GetNodeByAccessToken(token string) (*Node, error) { - node := &Node{} - err := DB.Where("access_token = ?", token).First(node).Error - return node, err -} - -func (node *Node) Insert() error { - return DB.Create(node).Error -} - -func (node *Node) Update() error { - return DB.Save(node).Error -} - -func (node *Node) Delete() error { - return DB.Delete(node).Error -} - -func ListNodesByType(nodeType string) (nodes []*Node, err error) { - err = DB.Where("node_type = ?", nodeType).Order("id desc").Find(&nodes).Error - return nodes, err -} diff --git a/openflare-server/internal/model/node_access_log.go b/openflare-server/internal/model/node_access_log.go deleted file mode 100644 index 4452bea7..00000000 --- a/openflare-server/internal/model/node_access_log.go +++ /dev/null @@ -1,664 +0,0 @@ -package model - -import ( - "fmt" - "sort" - "strings" - "sync" - "time" - - "gorm.io/gorm" -) - -type NodeAccessLog struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index:,composite:node_logged_at,priority:1;size:64;not null"` - LoggedAt time.Time `json:"logged_at" gorm:"index;index:,composite:node_logged_at,priority:2"` - RemoteAddr string `json:"remote_addr" gorm:"index;size:128"` - Region string `json:"region" gorm:"size:128"` - Host string `json:"host" gorm:"index;size:255"` - Path string `json:"path" gorm:"size:2048"` - StatusCode int `json:"status_code" gorm:"index"` - CreatedAt time.Time `json:"created_at"` -} - -type NodeAccessLogRegionCount struct { - Region string `json:"region"` - Count int64 `json:"count"` -} - -type NodeAccessLogQuery struct { - NodeID string - RemoteAddr string - Host string - Path string - Since time.Time - Until time.Time - Page int - PageSize int - SortBy string - SortOrder string -} - -type NodeAccessLogBucketQuery struct { - NodeID string - RemoteAddr string - Host string - Path string - Since time.Time - Page int - PageSize int - SortBy string - SortOrder string - FoldMinutes int -} - -type NodeAccessLogBucketRow struct { - BucketEpoch int64 `json:"bucket_epoch"` - RequestCount int64 `json:"request_count"` - UniqueIPCount int64 `json:"unique_ip_count"` - UniqueHostCount int64 `json:"unique_host_count"` - SuccessCount int64 `json:"success_count"` - ClientErrorCount int64 `json:"client_error_count"` - ServerErrorCount int64 `json:"server_error_count"` -} - -type NodeAccessLogBucketIPQuery struct { - NodeID string - RemoteAddr string - Host string - Path string - BucketStartedAt time.Time - FoldMinutes int - Page int - PageSize int - SortBy string - SortOrder string -} - -type NodeAccessLogBucketIPRow struct { - RemoteAddr string `json:"remote_addr"` - RequestCount int64 `json:"request_count"` - SuccessCount int64 `json:"success_count"` - ClientErrorCount int64 `json:"client_error_count"` - ServerErrorCount int64 `json:"server_error_count"` - LastSeenEpoch int64 `json:"last_seen_epoch"` -} - -type NodeAccessLogIPSummaryQuery struct { - NodeID string - RemoteAddr string - Host string - Since time.Time - Page int - PageSize int - SortBy string - SortOrder string -} - -type NodeAccessLogIPSummaryRow struct { - RemoteAddr string `json:"remote_addr"` - TotalRequests int64 `json:"total_requests"` - RecentRequests int64 `json:"recent_requests"` - LastSeenEpoch int64 `json:"last_seen_epoch"` -} - -type NodeAccessLogIPTrendQuery struct { - NodeID string - RemoteAddr string - Host string - Since time.Time - BucketMinutes int -} - -type NodeAccessLogTrendPointRow struct { - BucketEpoch int64 `json:"bucket_epoch"` - RequestCount int64 `json:"request_count"` -} - -func (log *NodeAccessLog) BeforeCreate(*gorm.DB) error { - return assignObservabilityID(&log.ID) -} - -func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err error) { - if query.PageSize > 0 { - return listNodeAccessLogsPaginatedAcrossShards(query) - } - return listNodeAccessLogsAcrossShards(query) -} - -func ListNodeAccessLogsForWAFIPGroup(query NodeAccessLogQuery) ([]*NodeAccessLog, error) { - return listNodeAccessLogsAcrossShards(query) -} - -func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) { - db := normalizeShardedDB(DB) - var countErr error - var distinctErr error - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - totalRecords, countErr = countNodeAccessLogRecordsAcrossShards(db, query) - }() - go func() { - defer wg.Done() - totalIPs, distinctErr = countDistinctNodeAccessLogIPsAcrossShards(db, query) - }() - wg.Wait() - if countErr != nil { - return 0, 0, countErr - } - if distinctErr != nil { - return 0, 0, distinctErr - } - return totalRecords, totalIPs, nil -} - -func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (items []*NodeAccessLogRegionCount, err error) { - logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{ - NodeID: nodeID, - Since: since, - }) - if err != nil { - return nil, err - } - counts := make(map[string]int64) - for _, item := range logs { - if item == nil { - continue - } - region := strings.TrimSpace(item.Region) - if region == "" { - continue - } - counts[region]++ - } - items = make([]*NodeAccessLogRegionCount, 0, len(counts)) - for region, count := range counts { - items = append(items, &NodeAccessLogRegionCount{ - Region: region, - Count: count, - }) - } - sort.Slice(items, func(i int, j int) bool { - if items[i].Count == items[j].Count { - return items[i].Region < items[j].Region - } - return items[i].Count > items[j].Count - }) - if limit > 0 && len(items) > limit { - items = items[:limit] - } - return items, nil -} - -func ListNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (items []*NodeAccessLogBucketRow, err error) { - rows, err := buildNodeAccessLogBucketRows(query) - if err != nil { - return nil, err - } - start, end := paginateBounds(len(rows), query.Page, query.PageSize) - if start >= len(rows) { - return []*NodeAccessLogBucketRow{}, nil - } - return rows[start:end], nil -} - -func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err error) { - rows, err := buildNodeAccessLogBucketRows(query) - if err != nil { - return 0, err - } - return int64(len(rows)), nil -} - -func ListNodeAccessLogBucketIPs(query NodeAccessLogBucketIPQuery) (items []*NodeAccessLogBucketIPRow, err error) { - rows, err := buildNodeAccessLogBucketIPRows(query) - if err != nil { - return nil, err - } - start, end := paginateBounds(len(rows), query.Page, query.PageSize) - if start >= len(rows) { - return []*NodeAccessLogBucketIPRow{}, nil - } - return rows[start:end], nil -} - -func CountNodeAccessLogBucketIPs(query NodeAccessLogBucketIPQuery) (total int64, err error) { - rows, err := buildNodeAccessLogBucketIPRows(query) - if err != nil { - return 0, err - } - return int64(len(rows)), nil -} - -func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) { - rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince) - if err != nil { - return nil, err - } - start, end := paginateBounds(len(rows), query.Page, query.PageSize) - if start >= len(rows) { - return []*NodeAccessLogIPSummaryRow{}, nil - } - return rows[start:end], nil -} - -func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int64, err error) { - rows, err := buildNodeAccessLogIPSummaryRows(query, time.Time{}) - if err != nil { - return 0, err - } - return int64(len(rows)), nil -} - -func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAccessLogTrendPointRow, err error) { - return queryIPTrendRows(query) -} - -func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) { - return deleteAcrossShards(DB, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB { - return tx.Where("logged_at < ?", before) - }) -} - -func DeleteAllNodeAccessLogs(db *gorm.DB) (deleted int64, err error) { - return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, nil) -} - -func NodeAccessLogExists(db *gorm.DB, record *NodeAccessLog) (bool, error) { - if record == nil { - return false, nil - } - db = normalizeShardedDB(db) - for _, table := range observabilityShardTables("node_access_logs") { - var count int64 - if err := db.Table(table). - Where( - "node_id = ? AND logged_at = ? AND remote_addr = ? AND host = ? AND path = ? AND status_code = ?", - record.NodeID, - record.LoggedAt, - record.RemoteAddr, - record.Host, - record.Path, - record.StatusCode, - ). - Limit(1). - Count(&count).Error; err != nil { - return false, err - } - if count > 0 { - return true, nil - } - } - return false, nil -} - -func DeleteNodeAccessLogsByNodeBefore(db *gorm.DB, nodeID string, before time.Time) (deleted int64, err error) { - return deleteAcrossShards(db, "node_access_logs", &NodeAccessLog{}, func(tx *gorm.DB) *gorm.DB { - return tx.Where("node_id = ? AND logged_at < ?", nodeID, before) - }) -} - -func buildNodeAccessLogFilterClause(query NodeAccessLogQuery) (string, []any) { - parts := make([]string, 0, 6) - args := make([]any, 0, 6) - if trimmed := strings.TrimSpace(query.NodeID); trimmed != "" { - parts = append(parts, "node_id = ?") - args = append(args, trimmed) - } - if trimmed := strings.TrimSpace(query.RemoteAddr); trimmed != "" { - parts = append(parts, "remote_addr LIKE ?") - args = append(args, trimmed+"%") - } - if trimmed := strings.TrimSpace(query.Host); trimmed != "" { - parts = append(parts, "host LIKE ?") - args = append(args, trimmed+"%") - } - if trimmed := strings.TrimSpace(query.Path); trimmed != "" { - parts = append(parts, "path LIKE ?") - args = append(args, trimmed+"%") - } - if !query.Since.IsZero() { - parts = append(parts, "logged_at >= ?") - args = append(args, query.Since) - } - if !query.Until.IsZero() { - parts = append(parts, "logged_at < ?") - args = append(args, query.Until) - } - if len(parts) == 0 { - return "TRUE", nil - } - return strings.Join(parts, " AND "), args -} - -func applyNodeAccessLogFilters(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB { - clause, args := buildNodeAccessLogFilterClause(query) - if clause == "TRUE" { - return db - } - return db.Where(clause, args...) -} - -func countNodeAccessLogRecordsAcrossShards(db *gorm.DB, query NodeAccessLogQuery) (int64, error) { - tables := observabilityShardTables("node_access_logs") - counts := make([]int64, len(tables)) - errs := make([]error, len(tables)) - - var wg sync.WaitGroup - for index, table := range tables { - wg.Add(1) - go func(index int, table string) { - defer wg.Done() - var count int64 - errs[index] = applyNodeAccessLogFilters(db.Table(table), query).Count(&count).Error - counts[index] = count - }(index, table) - } - wg.Wait() - - var total int64 - for index := range tables { - if errs[index] != nil { - return 0, errs[index] - } - total += counts[index] - } - return total, nil -} - -func countDistinctNodeAccessLogIPsAcrossShards(db *gorm.DB, query NodeAccessLogQuery) (int64, error) { - clause, args := buildNodeAccessLogFilterClause(query) - tables := observabilityShardTables("node_access_logs") - unionParts := make([]string, 0, len(tables)) - allArgs := make([]any, 0, len(args)*len(tables)) - for _, table := range tables { - unionParts = append(unionParts, fmt.Sprintf( - "SELECT TRIM(remote_addr) AS remote_addr FROM %s WHERE %s AND remote_addr <> ''", - table, - clause, - )) - allArgs = append(allArgs, args...) - } - sql := fmt.Sprintf(` -SELECT COUNT(*) FROM ( - SELECT remote_addr - FROM (%s) AS all_ips - GROUP BY remote_addr -) AS ips`, strings.Join(unionParts, " UNION ALL ")) - var total int64 - if err := db.Raw(sql, allArgs...).Scan(&total).Error; err != nil { - return 0, err - } - return total, nil -} - -func listNodeAccessLogsAcrossShards(query NodeAccessLogQuery) ([]*NodeAccessLog, error) { - items, err := queryAcrossShards("node_access_logs", func(tx *gorm.DB) ([]*NodeAccessLog, error) { - var shardRows []*NodeAccessLog - if err := applyNodeAccessLogFilters(tx, query).Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - sortNodeAccessLogs(items, query.SortBy, query.SortOrder) - return items, nil -} - -func listNodeAccessLogsPaginatedAcrossShards(query NodeAccessLogQuery) ([]*NodeAccessLog, error) { - fetchLimit := nodeAccessLogFetchLimit(query.Page, query.PageSize) - orderClause := nodeAccessLogOrderClause(query.SortBy, query.SortOrder) - - items := make([]*NodeAccessLog, 0, fetchLimit*observabilityShardCount) - db := normalizeShardedDB(DB) - for _, table := range observabilityShardTables("node_access_logs") { - var shardRows []*NodeAccessLog - tx := applyNodeAccessLogFilters(db.Table(table), query).Order(orderClause).Limit(fetchLimit) - if err := tx.Find(&shardRows).Error; err != nil { - return nil, err - } - items = append(items, shardRows...) - } - - sortNodeAccessLogs(items, query.SortBy, query.SortOrder) - start, end := paginateBounds(len(items), query.Page, query.PageSize) - if start >= len(items) { - return []*NodeAccessLog{}, nil - } - return items[start:end], nil -} - -func nodeAccessLogFetchLimit(page int, pageSize int) int { - if page < 0 { - page = 0 - } - if pageSize <= 0 { - return 0 - } - return (page + 1) * pageSize -} - -func nodeAccessLogOrderClause(sortBy string, sortOrder string) string { - direction := "DESC" - if normalizeSortOrder(sortOrder) == "asc" { - direction = "ASC" - } - column := "logged_at" - switch strings.TrimSpace(sortBy) { - case "status_code": - column = "status_code" - case "remote_addr": - column = "remote_addr" - case "host": - column = "host" - case "path": - column = "path" - } - if column == "logged_at" { - return column + " " + direction + ", id " + direction - } - return column + " " + direction + ", logged_at " + direction + ", id " + direction -} - -func sortNodeAccessLogBucketIPRows(items []*NodeAccessLogBucketIPRow, sortBy string, sortOrder string) { - desc := normalizeSortOrder(sortOrder) != "asc" - sort.Slice(items, func(i int, j int) bool { - left := items[i] - right := items[j] - if left == nil || right == nil { - return left != nil - } - var compare int - switch strings.TrimSpace(sortBy) { - case "last_seen_at": - compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch) - case "remote_addr": - compare = strings.Compare(left.RemoteAddr, right.RemoteAddr) - default: - compare = compareInt64(left.RequestCount, right.RequestCount) - } - if compare == 0 { - compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch) - } - if compare == 0 { - compare = strings.Compare(left.RemoteAddr, right.RemoteAddr) - } - if desc { - return compare > 0 - } - return compare < 0 - }) -} - -func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) { - desc := normalizeSortOrder(sortOrder) != "asc" - sort.Slice(items, func(i int, j int) bool { - left := items[i] - right := items[j] - if left == nil || right == nil { - return left != nil - } - var compare int - switch strings.TrimSpace(sortBy) { - case "status_code": - compare = compareInt(left.StatusCode, right.StatusCode) - case "remote_addr": - compare = strings.Compare(left.RemoteAddr, right.RemoteAddr) - case "host": - compare = strings.Compare(left.Host, right.Host) - case "path": - compare = strings.Compare(left.Path, right.Path) - default: - compare = compareTime(left.LoggedAt, right.LoggedAt) - } - if compare == 0 { - compare = compareTime(left.LoggedAt, right.LoggedAt) - } - if compare == 0 { - compare = compareUint(left.ID, right.ID) - } - if desc { - return compare > 0 - } - return compare < 0 - }) -} - -func sortNodeAccessLogBucketRows(items []*NodeAccessLogBucketRow, sortBy string, sortOrder string) { - desc := normalizeSortOrder(sortOrder) != "asc" - sort.Slice(items, func(i int, j int) bool { - left := items[i] - right := items[j] - if left == nil || right == nil { - return left != nil - } - var compare int - switch strings.TrimSpace(sortBy) { - case "request_count": - compare = compareInt64(left.RequestCount, right.RequestCount) - default: - compare = compareInt64(left.BucketEpoch, right.BucketEpoch) - } - if compare == 0 { - compare = compareInt64(left.BucketEpoch, right.BucketEpoch) - } - if desc { - return compare > 0 - } - return compare < 0 - }) -} - -func sortNodeAccessLogIPSummaryRows(items []*NodeAccessLogIPSummaryRow, sortBy string, sortOrder string) { - desc := normalizeSortOrder(sortOrder) != "asc" - sort.Slice(items, func(i int, j int) bool { - left := items[i] - right := items[j] - if left == nil || right == nil { - return left != nil - } - var compare int - switch strings.TrimSpace(sortBy) { - case "recent_requests": - compare = compareInt64(left.RecentRequests, right.RecentRequests) - case "last_seen_at": - compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch) - case "remote_addr": - compare = strings.Compare(left.RemoteAddr, right.RemoteAddr) - default: - compare = compareInt64(left.TotalRequests, right.TotalRequests) - } - if compare == 0 { - compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch) - } - if compare == 0 { - compare = strings.Compare(left.RemoteAddr, right.RemoteAddr) - } - if desc { - return compare > 0 - } - return compare < 0 - }) -} - -func paginateBounds(total int, page int, pageSize int) (int, int) { - if page < 0 { - page = 0 - } - if pageSize <= 0 { - return 0, total - } - start := page * pageSize - if start > total { - start = total - } - end := start + pageSize - if end > total { - end = total - } - return start, end -} - -func bucketEpochForTime(value time.Time, bucketMinutes int) int64 { - bucketSeconds := int64(bucketMinutes * 60) - if bucketSeconds <= 0 { - bucketSeconds = 180 - } - return (value.UTC().Unix() / bucketSeconds) * bucketSeconds -} - -func compareTime(left time.Time, right time.Time) int { - switch { - case left.After(right): - return 1 - case left.Before(right): - return -1 - default: - return 0 - } -} - -func compareInt(left int, right int) int { - switch { - case left > right: - return 1 - case left < right: - return -1 - default: - return 0 - } -} - -func compareInt64(left int64, right int64) int { - switch { - case left > right: - return 1 - case left < right: - return -1 - default: - return 0 - } -} - -func compareUint(left uint, right uint) int { - switch { - case left > right: - return 1 - case left < right: - return -1 - default: - return 0 - } -} - -func normalizeSortOrder(sortOrder string) string { - if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") { - return "asc" - } - return "desc" -} diff --git a/openflare-server/internal/model/node_access_log_agg.go b/openflare-server/internal/model/node_access_log_agg.go deleted file mode 100644 index 3cc0a074..00000000 --- a/openflare-server/internal/model/node_access_log_agg.go +++ /dev/null @@ -1,421 +0,0 @@ -package model - -import ( - "fmt" - "sort" - "strings" - "time" - - "gorm.io/gorm" -) - -type shardBucketAggregateRow struct { - BucketEpoch int64 `gorm:"column:bucket_epoch"` - RequestCount int64 `gorm:"column:request_count"` - SuccessCount int64 `gorm:"column:success_count"` - ClientErrorCount int64 `gorm:"column:client_error_count"` - ServerErrorCount int64 `gorm:"column:server_error_count"` -} - -type shardBucketDimensionRow struct { - BucketEpoch int64 `gorm:"column:bucket_epoch"` - Value string `gorm:"column:value"` -} - -type shardIPAggregateRow struct { - RemoteAddr string `gorm:"column:remote_addr"` - RequestCount int64 `gorm:"column:request_count"` - SuccessCount int64 `gorm:"column:success_count"` - ClientErrorCount int64 `gorm:"column:client_error_count"` - ServerErrorCount int64 `gorm:"column:server_error_count"` - LastSeenEpoch int64 `gorm:"column:last_seen_epoch"` -} - -type shardIPSummaryRow struct { - RemoteAddr string `gorm:"column:remote_addr"` - TotalRequests int64 `gorm:"column:total_requests"` - RecentRequests int64 `gorm:"column:recent_requests"` - LastSeenEpoch int64 `gorm:"column:last_seen_epoch"` -} - -type shardIPTrendRow struct { - BucketEpoch int64 `gorm:"column:bucket_epoch"` - RequestCount int64 `gorm:"column:request_count"` -} - -func buildNodeAccessLogBucketRows(query NodeAccessLogBucketQuery) ([]*NodeAccessLogBucketRow, error) { - db := normalizeShardedDB(DB) - filter := nodeAccessLogQueryFromBucket(query) - clause, args := buildNodeAccessLogFilterClause(filter) - bucketSeconds := int64(query.FoldMinutes * 60) - if bucketSeconds <= 0 { - bucketSeconds = 180 - } - bucketExpr := accessLogBucketEpochExpr(databaseDialect(db), bucketSeconds) - - type bucketAccumulator struct { - requestCount int64 - uniqueIPs map[string]struct{} - uniqueHosts map[string]struct{} - successCount int64 - clientErrorCount int64 - serverErrorCount int64 - } - accumulators := make(map[int64]*bucketAccumulator) - - for _, table := range observabilityShardTables("node_access_logs") { - var partials []shardBucketAggregateRow - sql := fmt.Sprintf(` -SELECT - %s AS bucket_epoch, - COUNT(*) AS request_count, - SUM(CASE WHEN status_code < 400 THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN status_code >= 400 AND status_code < 500 THEN 1 ELSE 0 END) AS client_error_count, - SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END) AS server_error_count -FROM %s -WHERE %s -GROUP BY bucket_epoch`, bucketExpr, table, clause) - if err := db.Raw(sql, args...).Scan(&partials).Error; err != nil { - return nil, err - } - for _, partial := range partials { - accumulator := accumulators[partial.BucketEpoch] - if accumulator == nil { - accumulator = &bucketAccumulator{ - uniqueIPs: make(map[string]struct{}), - uniqueHosts: make(map[string]struct{}), - } - accumulators[partial.BucketEpoch] = accumulator - } - accumulator.requestCount += partial.RequestCount - accumulator.successCount += partial.SuccessCount - accumulator.clientErrorCount += partial.ClientErrorCount - accumulator.serverErrorCount += partial.ServerErrorCount - } - - for _, column := range []string{"remote_addr", "host"} { - dimensions, err := queryBucketDimensionRows(db, table, clause, args, column, bucketExpr) - if err != nil { - return nil, err - } - for _, item := range dimensions { - accumulator := accumulators[item.BucketEpoch] - if accumulator == nil { - accumulator = &bucketAccumulator{ - uniqueIPs: make(map[string]struct{}), - uniqueHosts: make(map[string]struct{}), - } - accumulators[item.BucketEpoch] = accumulator - } - trimmed := strings.TrimSpace(item.Value) - if trimmed == "" { - continue - } - switch column { - case "remote_addr": - accumulator.uniqueIPs[trimmed] = struct{}{} - case "host": - accumulator.uniqueHosts[trimmed] = struct{}{} - } - } - } - } - - rows := make([]*NodeAccessLogBucketRow, 0, len(accumulators)) - for bucketEpoch, accumulator := range accumulators { - rows = append(rows, &NodeAccessLogBucketRow{ - BucketEpoch: bucketEpoch, - RequestCount: accumulator.requestCount, - UniqueIPCount: int64(len(accumulator.uniqueIPs)), - UniqueHostCount: int64(len(accumulator.uniqueHosts)), - SuccessCount: accumulator.successCount, - ClientErrorCount: accumulator.clientErrorCount, - ServerErrorCount: accumulator.serverErrorCount, - }) - } - sortNodeAccessLogBucketRows(rows, query.SortBy, query.SortOrder) - return rows, nil -} - -func queryBucketDimensionRows(db *gorm.DB, table string, clause string, args []any, column string, bucketExpr string) ([]shardBucketDimensionRow, error) { - var rows []shardBucketDimensionRow - sql := fmt.Sprintf(` -SELECT - %s AS bucket_epoch, - TRIM(%s) AS value -FROM %s -WHERE %s AND TRIM(%s) <> '' -GROUP BY bucket_epoch, TRIM(%s)`, bucketExpr, column, table, clause, column, column) - if err := db.Raw(sql, args...).Scan(&rows).Error; err != nil { - return nil, err - } - return rows, nil -} - -func buildNodeAccessLogBucketIPRows(query NodeAccessLogBucketIPQuery) ([]*NodeAccessLogBucketIPRow, error) { - if query.BucketStartedAt.IsZero() { - return []*NodeAccessLogBucketIPRow{}, nil - } - foldMinutes := query.FoldMinutes - if foldMinutes <= 0 { - foldMinutes = 3 - } - bucketStartedAt := query.BucketStartedAt.UTC() - filter := NodeAccessLogQuery{ - NodeID: query.NodeID, - RemoteAddr: query.RemoteAddr, - Host: query.Host, - Path: query.Path, - Since: bucketStartedAt, - Until: bucketStartedAt.Add(time.Duration(foldMinutes) * time.Minute), - } - rows, err := queryIPAggregateRows(filter, false) - if err != nil { - return nil, err - } - sortNodeAccessLogBucketIPRows(rows, query.SortBy, query.SortOrder) - return rows, nil -} - -func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) { - filter := NodeAccessLogQuery{ - NodeID: query.NodeID, - RemoteAddr: query.RemoteAddr, - Host: query.Host, - Since: query.Since, - } - db := normalizeShardedDB(DB) - clause, args := buildNodeAccessLogFilterClause(filter) - lastSeenExpr := accessLogEpochExpr(databaseDialect(db)) - - type accumulator struct { - totalRequests int64 - recentRequests int64 - lastSeenEpoch int64 - } - accumulators := make(map[string]*accumulator) - - for _, table := range observabilityShardTables("node_access_logs") { - recentClause := "0" - queryArgs := make([]any, 0, len(args)+1) - if !recentSince.IsZero() { - recentClause = "CASE WHEN logged_at >= ? THEN 1 ELSE 0 END" - queryArgs = append(queryArgs, recentSince) - } - queryArgs = append(queryArgs, args...) - var partials []shardIPSummaryRow - sql := fmt.Sprintf(` -SELECT - TRIM(remote_addr) AS remote_addr, - COUNT(*) AS total_requests, - SUM(%s) AS recent_requests, - MAX(%s) AS last_seen_epoch -FROM %s -WHERE %s AND TRIM(remote_addr) <> '' -GROUP BY TRIM(remote_addr)`, recentClause, lastSeenExpr, table, clause) - if err := db.Raw(sql, queryArgs...).Scan(&partials).Error; err != nil { - return nil, err - } - for _, partial := range partials { - remoteAddr := strings.TrimSpace(partial.RemoteAddr) - if remoteAddr == "" { - continue - } - acc := accumulators[remoteAddr] - if acc == nil { - acc = &accumulator{} - accumulators[remoteAddr] = acc - } - acc.totalRequests += partial.TotalRequests - acc.recentRequests += partial.RecentRequests - if partial.LastSeenEpoch > acc.lastSeenEpoch { - acc.lastSeenEpoch = partial.LastSeenEpoch - } - } - } - - rows := make([]*NodeAccessLogIPSummaryRow, 0, len(accumulators)) - for remoteAddr, acc := range accumulators { - rows = append(rows, &NodeAccessLogIPSummaryRow{ - RemoteAddr: remoteAddr, - TotalRequests: acc.totalRequests, - RecentRequests: acc.recentRequests, - LastSeenEpoch: acc.lastSeenEpoch, - }) - } - sortNodeAccessLogIPSummaryRows(rows, query.SortBy, query.SortOrder) - return rows, nil -} - -func queryIPAggregateRows(filter NodeAccessLogQuery, exactRemoteAddr bool) ([]*NodeAccessLogBucketIPRow, error) { - db := normalizeShardedDB(DB) - clause, args := buildNodeAccessLogFilterClause(filter) - lastSeenExpr := accessLogEpochExpr(databaseDialect(db)) - - type accumulator struct { - requestCount int64 - successCount int64 - clientErrorCount int64 - serverErrorCount int64 - lastSeenEpoch int64 - } - accumulators := make(map[string]*accumulator) - - for _, table := range observabilityShardTables("node_access_logs") { - queryClause := clause - queryArgs := append([]any{}, args...) - if exactRemoteAddr { - trimmed := strings.TrimSpace(filter.RemoteAddr) - if trimmed == "" { - return []*NodeAccessLogBucketIPRow{}, nil - } - queryClause = combineSQLClauses(queryClause, "TRIM(remote_addr) = ?") - queryArgs = append(queryArgs, trimmed) - } - var partials []shardIPAggregateRow - sql := fmt.Sprintf(` -SELECT - TRIM(remote_addr) AS remote_addr, - COUNT(*) AS request_count, - SUM(CASE WHEN status_code < 400 THEN 1 ELSE 0 END) AS success_count, - SUM(CASE WHEN status_code >= 400 AND status_code < 500 THEN 1 ELSE 0 END) AS client_error_count, - SUM(CASE WHEN status_code >= 500 THEN 1 ELSE 0 END) AS server_error_count, - MAX(%s) AS last_seen_epoch -FROM %s -WHERE %s AND TRIM(remote_addr) <> '' -GROUP BY TRIM(remote_addr)`, lastSeenExpr, table, queryClause) - if err := db.Raw(sql, queryArgs...).Scan(&partials).Error; err != nil { - return nil, err - } - for _, partial := range partials { - remoteAddr := strings.TrimSpace(partial.RemoteAddr) - if remoteAddr == "" { - continue - } - acc := accumulators[remoteAddr] - if acc == nil { - acc = &accumulator{} - accumulators[remoteAddr] = acc - } - acc.requestCount += partial.RequestCount - acc.successCount += partial.SuccessCount - acc.clientErrorCount += partial.ClientErrorCount - acc.serverErrorCount += partial.ServerErrorCount - if partial.LastSeenEpoch > acc.lastSeenEpoch { - acc.lastSeenEpoch = partial.LastSeenEpoch - } - } - } - - rows := make([]*NodeAccessLogBucketIPRow, 0, len(accumulators)) - for remoteAddr, acc := range accumulators { - rows = append(rows, &NodeAccessLogBucketIPRow{ - RemoteAddr: remoteAddr, - RequestCount: acc.requestCount, - SuccessCount: acc.successCount, - ClientErrorCount: acc.clientErrorCount, - ServerErrorCount: acc.serverErrorCount, - LastSeenEpoch: acc.lastSeenEpoch, - }) - } - return rows, nil -} - -func queryIPTrendRows(query NodeAccessLogIPTrendQuery) ([]*NodeAccessLogTrendPointRow, error) { - remoteAddr := strings.TrimSpace(query.RemoteAddr) - if remoteAddr == "" { - return []*NodeAccessLogTrendPointRow{}, nil - } - db := normalizeShardedDB(DB) - filter := NodeAccessLogQuery{ - NodeID: query.NodeID, - RemoteAddr: remoteAddr, - Host: query.Host, - Since: query.Since, - } - clause, args := buildNodeAccessLogFilterClause(filter) - bucketSeconds := int64(query.BucketMinutes * 60) - if bucketSeconds <= 0 { - bucketSeconds = 1800 - } - bucketExpr := accessLogBucketEpochExpr(databaseDialect(db), bucketSeconds) - queryClause := combineSQLClauses(clause, "TRIM(remote_addr) = ?") - queryArgs := append(append([]any{}, args...), remoteAddr) - - buckets := make(map[int64]int64) - for _, table := range observabilityShardTables("node_access_logs") { - var partials []shardIPTrendRow - sql := fmt.Sprintf(` -SELECT - %s AS bucket_epoch, - COUNT(*) AS request_count -FROM %s -WHERE %s -GROUP BY bucket_epoch`, bucketExpr, table, queryClause) - if err := db.Raw(sql, queryArgs...).Scan(&partials).Error; err != nil { - return nil, err - } - for _, partial := range partials { - buckets[partial.BucketEpoch] += partial.RequestCount - } - } - - items := make([]*NodeAccessLogTrendPointRow, 0, len(buckets)) - for bucketEpoch, requestCount := range buckets { - items = append(items, &NodeAccessLogTrendPointRow{ - BucketEpoch: bucketEpoch, - RequestCount: requestCount, - }) - } - sort.Slice(items, func(i int, j int) bool { - return items[i].BucketEpoch < items[j].BucketEpoch - }) - return items, nil -} - -func nodeAccessLogQueryFromBucket(query NodeAccessLogBucketQuery) NodeAccessLogQuery { - return NodeAccessLogQuery{ - NodeID: query.NodeID, - RemoteAddr: query.RemoteAddr, - Host: query.Host, - Path: query.Path, - Since: query.Since, - } -} - -func databaseDialect(db *gorm.DB) string { - if db == nil || db.Dialector == nil { - return "sqlite" - } - switch db.Dialector.Name() { - case "postgres": - return "postgres" - default: - return "sqlite" - } -} - -func accessLogBucketEpochExpr(dialect string, bucketSeconds int64) string { - switch dialect { - case "postgres": - return fmt.Sprintf("FLOOR(EXTRACT(EPOCH FROM logged_at AT TIME ZONE 'UTC') / %d) * %d", bucketSeconds, bucketSeconds) - default: - return fmt.Sprintf("(CAST(strftime('%%s', logged_at) AS INTEGER) / %d) * %d", bucketSeconds, bucketSeconds) - } -} - -func accessLogEpochExpr(dialect string) string { - switch dialect { - case "postgres": - return "FLOOR(EXTRACT(EPOCH FROM logged_at AT TIME ZONE 'UTC'))::bigint" - default: - return "CAST((julianday(logged_at) - 2440587.5) * 86400 AS INTEGER)" - } -} - -func combineSQLClauses(left string, right string) string { - if strings.TrimSpace(left) == "" || left == "TRUE" { - return right - } - return left + " AND " + right -} diff --git a/openflare-server/internal/model/node_access_log_test.go b/openflare-server/internal/model/node_access_log_test.go deleted file mode 100644 index 619cbd68..00000000 --- a/openflare-server/internal/model/node_access_log_test.go +++ /dev/null @@ -1,542 +0,0 @@ -package model - -import ( - "fmt" - "sort" - "strings" - "testing" - "time" -) - -func TestListNodeAccessLogsPaginatedAcrossShards(t *testing.T) { - db := openBareTestSQLiteDB(t, "node_access_log_pagination.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := autoMigrateAll(db); err != nil { - t.Fatalf("auto migrate db: %v", err) - } - previousDB := DB - DB = db - t.Cleanup(func() { - DB = previousDB - }) - - now := time.Now().UTC() - for index := range 15 { - record := &NodeAccessLog{ - NodeID: "node-page", - LoggedAt: now.Add(-time.Duration(index) * time.Minute), - RemoteAddr: fmt.Sprintf("203.0.113.%d", (index%5)+1), - Host: "example.com", - Path: fmt.Sprintf("/path-%02d", index), - StatusCode: 200, - } - if err := db.Create(record).Error; err != nil { - t.Fatalf("seed access log %d: %v", index, err) - } - } - - query := NodeAccessLogQuery{ - NodeID: "node-page", - Page: 1, - PageSize: 5, - SortBy: "logged_at", - SortOrder: "desc", - } - page, err := ListNodeAccessLogs(query) - if err != nil { - t.Fatalf("ListNodeAccessLogs failed: %v", err) - } - if len(page) != 5 { - t.Fatalf("expected 5 rows, got %d", len(page)) - } - if page[0].Path != "/path-05" || page[4].Path != "/path-09" { - t.Fatalf("unexpected page ordering: %+v", page) - } - - totalRecords, totalIPs, err := CountNodeAccessLogs(query) - if err != nil { - t.Fatalf("CountNodeAccessLogs failed: %v", err) - } - if totalRecords != 15 { - t.Fatalf("expected total_records=15, got %d", totalRecords) - } - if totalIPs != 5 { - t.Fatalf("expected total_ip=5, got %d", totalIPs) - } -} - -func TestNodeAccessLogOptimizedQueriesMatchReference(t *testing.T) { - db := openBareTestSQLiteDB(t, "node_access_log_correctness.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := autoMigrateAll(db); err != nil { - t.Fatalf("auto migrate db: %v", err) - } - previousDB := DB - DB = db - t.Cleanup(func() { - DB = previousDB - }) - - now := time.Now().UTC() - records := []*NodeAccessLog{ - {NodeID: "node-a", LoggedAt: now.Add(-5 * time.Minute), RemoteAddr: "1.1.1.1", Host: "a.example.com", Path: "/alpha", StatusCode: 200}, - {NodeID: "node-a", LoggedAt: now.Add(-4 * time.Minute), RemoteAddr: "2.2.2.2", Host: "a.example.com", Path: "/beta", StatusCode: 404}, - {NodeID: "node-b", LoggedAt: now.Add(-3 * time.Minute), RemoteAddr: "1.1.1.1", Host: "b.example.com", Path: "/gamma", StatusCode: 502}, - {NodeID: "node-b", LoggedAt: now.Add(-2 * time.Minute), RemoteAddr: " 3.3.3.3 ", Host: "b.example.com", Path: "/delta", StatusCode: 200}, - {NodeID: "node-b", LoggedAt: now.Add(-1 * time.Minute), RemoteAddr: "", Host: "b.example.com", Path: "/empty-ip", StatusCode: 200}, - } - for _, record := range records { - if err := db.Create(record).Error; err != nil { - t.Fatalf("seed access log: %v", err) - } - } - - baseQuery := NodeAccessLogQuery{ - Since: now.Add(-10 * time.Minute), - SortBy: "logged_at", - SortOrder: "desc", - } - reference, err := listNodeAccessLogsAcrossShards(baseQuery) - if err != nil { - t.Fatalf("reference list failed: %v", err) - } - referenceTotal, referenceIPs, err := countNodeAccessLogsReference(baseQuery) - if err != nil { - t.Fatalf("reference count failed: %v", err) - } - - totalRecords, totalIPs, err := CountNodeAccessLogs(baseQuery) - if err != nil { - t.Fatalf("CountNodeAccessLogs failed: %v", err) - } - if totalRecords != referenceTotal { - t.Fatalf("total_records mismatch: got %d want %d", totalRecords, referenceTotal) - } - if totalIPs != referenceIPs { - t.Fatalf("total_ip mismatch: got %d want %d", totalIPs, referenceIPs) - } - if totalRecords != int64(len(reference)) { - t.Fatalf("total_records should equal reference rows: got %d want %d", totalRecords, len(reference)) - } - - for page := range 3 { - query := baseQuery - query.Page = page - query.PageSize = 2 - pageRows, err := ListNodeAccessLogs(query) - if err != nil { - t.Fatalf("ListNodeAccessLogs page %d failed: %v", page, err) - } - start, end := paginateBounds(len(reference), page, query.PageSize) - if start >= len(reference) { - if len(pageRows) != 0 { - t.Fatalf("page %d expected empty slice, got %d rows", page, len(pageRows)) - } - continue - } - want := reference[start:end] - if !nodeAccessLogsEqual(pageRows, want) { - t.Fatalf("page %d mismatch:\n got=%+v\nwant=%+v", page, pageRows, want) - } - } - - filteredQuery := NodeAccessLogQuery{ - NodeID: "node-a", - Since: baseQuery.Since, - SortBy: "status_code", - SortOrder: "asc", - Page: 0, - PageSize: 10, - } - filteredReference, err := listNodeAccessLogsAcrossShards(filteredQuery) - if err != nil { - t.Fatalf("filtered reference list failed: %v", err) - } - filteredRows, err := ListNodeAccessLogs(filteredQuery) - if err != nil { - t.Fatalf("filtered ListNodeAccessLogs failed: %v", err) - } - if !nodeAccessLogsEqual(filteredRows, filteredReference) { - t.Fatalf("filtered list mismatch:\n got=%+v\nwant=%+v", filteredRows, filteredReference) - } - filteredTotal, filteredIPs, err := CountNodeAccessLogs(filteredQuery) - if err != nil { - t.Fatalf("filtered CountNodeAccessLogs failed: %v", err) - } - wantFilteredTotal, wantFilteredIPs, err := countNodeAccessLogsReference(filteredQuery) - if err != nil { - t.Fatalf("filtered reference count failed: %v", err) - } - if filteredTotal != wantFilteredTotal || filteredIPs != wantFilteredIPs { - t.Fatalf("filtered count mismatch: got (%d,%d) want (%d,%d)", filteredTotal, filteredIPs, wantFilteredTotal, wantFilteredIPs) - } -} - -func countNodeAccessLogsReference(query NodeAccessLogQuery) (int64, int64, error) { - all, err := listNodeAccessLogsAcrossShards(query) - if err != nil { - return 0, 0, err - } - ips := make(map[string]struct{}) - for _, item := range all { - if item == nil { - continue - } - if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" { - ips[trimmed] = struct{}{} - } - } - return int64(len(all)), int64(len(ips)), nil -} - -func nodeAccessLogsEqual(left []*NodeAccessLog, right []*NodeAccessLog) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] == nil || right[index] == nil { - if left[index] != right[index] { - return false - } - continue - } - if left[index].ID != right[index].ID || - left[index].NodeID != right[index].NodeID || - !left[index].LoggedAt.Equal(right[index].LoggedAt) || - left[index].RemoteAddr != right[index].RemoteAddr || - left[index].Host != right[index].Host || - left[index].Path != right[index].Path || - left[index].StatusCode != right[index].StatusCode { - return false - } - } - return true -} - -func TestNodeAccessLogAggregationsMatchReference(t *testing.T) { - db := openBareTestSQLiteDB(t, "node_access_log_agg.db") - if err := registerSharding(db, "sqlite"); err != nil { - t.Fatalf("register sharding: %v", err) - } - if err := autoMigrateAll(db); err != nil { - t.Fatalf("auto migrate db: %v", err) - } - previousDB := DB - DB = db - t.Cleanup(func() { - DB = previousDB - }) - - now := time.Date(2026, 3, 19, 8, 12, 30, 0, time.UTC) - records := []*NodeAccessLog{ - {NodeID: "node-folded", LoggedAt: now.Add(-4 * time.Minute), RemoteAddr: "203.0.113.1", Host: "alpha.example.com", Path: "/first", StatusCode: 200}, - {NodeID: "node-folded", LoggedAt: now.Add(-3 * time.Minute), RemoteAddr: "203.0.113.1", Host: "alpha.example.com", Path: "/second", StatusCode: 502}, - {NodeID: "node-folded", LoggedAt: now.Add(-2 * time.Minute), RemoteAddr: "203.0.113.2", Host: "beta.example.com", Path: "/third", StatusCode: 404}, - } - for _, record := range records { - if err := db.Create(record).Error; err != nil { - t.Fatalf("seed access log: %v", err) - } - } - - since := now.Add(-10 * time.Minute) - bucketRows, err := buildNodeAccessLogBucketRows(NodeAccessLogBucketQuery{ - NodeID: "node-folded", Since: since, FoldMinutes: 5, SortBy: "request_count", SortOrder: "desc", - }) - if err != nil { - t.Fatalf("buildNodeAccessLogBucketRows failed: %v", err) - } - referenceBuckets := referenceBucketRows(records, 5, "request_count", "desc") - if !bucketRowsEqual(bucketRows, referenceBuckets) { - t.Fatalf("bucket rows mismatch:\n got=%+v\nwant=%+v", bucketRows, referenceBuckets) - } - - if len(bucketRows) == 0 { - t.Fatal("expected bucket rows before bucket ip verification") - } - bucketStartedAt := time.Unix(bucketRows[0].BucketEpoch, 0).UTC() - bucketIPRows, err := buildNodeAccessLogBucketIPRows(NodeAccessLogBucketIPQuery{ - NodeID: "node-folded", BucketStartedAt: bucketStartedAt, FoldMinutes: 5, SortBy: "request_count", SortOrder: "desc", - }) - if err != nil { - t.Fatalf("buildNodeAccessLogBucketIPRows failed: %v", err) - } - referenceBucketIPs := referenceBucketIPRows(records, bucketStartedAt, 5, "request_count", "desc") - if !bucketIPRowsEqual(bucketIPRows, referenceBucketIPs) { - if len(bucketIPRows) > 0 && len(referenceBucketIPs) > 0 { - t.Fatalf("bucket ip rows mismatch:\n got=%+v\nwant=%+v", *bucketIPRows[0], *referenceBucketIPs[0]) - } - t.Fatalf("bucket ip rows mismatch:\n got=%+v\nwant=%+v", bucketIPRows, referenceBucketIPs) - } - - recentSince := now.Add(-150 * time.Minute) - summaryRows, err := buildNodeAccessLogIPSummaryRows(NodeAccessLogIPSummaryQuery{ - NodeID: "node-folded", Since: since, SortBy: "total_requests", SortOrder: "desc", - }, recentSince) - if err != nil { - t.Fatalf("buildNodeAccessLogIPSummaryRows failed: %v", err) - } - referenceSummaries := referenceIPSummaryRows(records, since, recentSince, "total_requests", "desc") - if !ipSummaryRowsEqual(summaryRows, referenceSummaries) { - t.Fatalf("ip summary rows mismatch:\n got=%+v\nwant=%+v", summaryRows, referenceSummaries) - } - - trendRows, err := queryIPTrendRows(NodeAccessLogIPTrendQuery{ - NodeID: "node-folded", RemoteAddr: "203.0.113.1", Since: since, BucketMinutes: 5, - }) - if err != nil { - t.Fatalf("queryIPTrendRows failed: %v", err) - } - referenceTrend := referenceIPTrendRows(records, "203.0.113.1", 5) - if !trendRowsEqual(trendRows, referenceTrend) { - t.Fatalf("trend rows mismatch:\n got=%+v\nwant=%+v", trendRows, referenceTrend) - } -} - -func referenceBucketRows(records []*NodeAccessLog, foldMinutes int, sortBy string, sortOrder string) []*NodeAccessLogBucketRow { - type bucketAccumulator struct { - requestCount int64 - uniqueIPs map[string]struct{} - uniqueHosts map[string]struct{} - successCount int64 - clientErrorCount int64 - serverErrorCount int64 - } - accumulators := make(map[int64]*bucketAccumulator) - for _, item := range records { - if item == nil { - continue - } - bucketEpoch := bucketEpochForTime(item.LoggedAt, foldMinutes) - accumulator := accumulators[bucketEpoch] - if accumulator == nil { - accumulator = &bucketAccumulator{ - uniqueIPs: make(map[string]struct{}), - uniqueHosts: make(map[string]struct{}), - } - accumulators[bucketEpoch] = accumulator - } - accumulator.requestCount++ - if trimmed := strings.TrimSpace(item.RemoteAddr); trimmed != "" { - accumulator.uniqueIPs[trimmed] = struct{}{} - } - if trimmed := strings.TrimSpace(item.Host); trimmed != "" { - accumulator.uniqueHosts[trimmed] = struct{}{} - } - switch { - case item.StatusCode < 400: - accumulator.successCount++ - case item.StatusCode < 500: - accumulator.clientErrorCount++ - default: - accumulator.serverErrorCount++ - } - } - rows := make([]*NodeAccessLogBucketRow, 0, len(accumulators)) - for bucketEpoch, accumulator := range accumulators { - rows = append(rows, &NodeAccessLogBucketRow{ - BucketEpoch: bucketEpoch, - RequestCount: accumulator.requestCount, - UniqueIPCount: int64(len(accumulator.uniqueIPs)), - UniqueHostCount: int64(len(accumulator.uniqueHosts)), - SuccessCount: accumulator.successCount, - ClientErrorCount: accumulator.clientErrorCount, - ServerErrorCount: accumulator.serverErrorCount, - }) - } - sortNodeAccessLogBucketRows(rows, sortBy, sortOrder) - return rows -} - -func referenceBucketIPRows(records []*NodeAccessLog, bucketStartedAt time.Time, foldMinutes int, sortBy string, sortOrder string) []*NodeAccessLogBucketIPRow { - type accumulator struct { - requestCount int64 - successCount int64 - clientErrorCount int64 - serverErrorCount int64 - lastSeenAt time.Time - } - accumulators := make(map[string]*accumulator) - until := bucketStartedAt.Add(time.Duration(foldMinutes) * time.Minute) - for _, item := range records { - if item == nil || item.LoggedAt.Before(bucketStartedAt) || !item.LoggedAt.Before(until) { - continue - } - remoteAddr := strings.TrimSpace(item.RemoteAddr) - if remoteAddr == "" { - continue - } - acc := accumulators[remoteAddr] - if acc == nil { - acc = &accumulator{} - accumulators[remoteAddr] = acc - } - acc.requestCount++ - switch { - case item.StatusCode < 400: - acc.successCount++ - case item.StatusCode < 500: - acc.clientErrorCount++ - default: - acc.serverErrorCount++ - } - if item.LoggedAt.After(acc.lastSeenAt) { - acc.lastSeenAt = item.LoggedAt - } - } - rows := make([]*NodeAccessLogBucketIPRow, 0, len(accumulators)) - for remoteAddr, acc := range accumulators { - rows = append(rows, &NodeAccessLogBucketIPRow{ - RemoteAddr: remoteAddr, - RequestCount: acc.requestCount, - SuccessCount: acc.successCount, - ClientErrorCount: acc.clientErrorCount, - ServerErrorCount: acc.serverErrorCount, - LastSeenEpoch: acc.lastSeenAt.Unix(), - }) - } - sortNodeAccessLogBucketIPRows(rows, sortBy, sortOrder) - return rows -} - -func referenceIPSummaryRows(records []*NodeAccessLog, since time.Time, recentSince time.Time, sortBy string, sortOrder string) []*NodeAccessLogIPSummaryRow { - type accumulator struct { - totalRequests int64 - recentRequests int64 - lastSeenAt time.Time - } - accumulators := make(map[string]*accumulator) - for _, item := range records { - if item == nil || item.LoggedAt.Before(since) { - continue - } - remoteAddr := strings.TrimSpace(item.RemoteAddr) - if remoteAddr == "" { - continue - } - acc := accumulators[remoteAddr] - if acc == nil { - acc = &accumulator{} - accumulators[remoteAddr] = acc - } - acc.totalRequests++ - if !recentSince.IsZero() && !item.LoggedAt.Before(recentSince) { - acc.recentRequests++ - } - if item.LoggedAt.After(acc.lastSeenAt) { - acc.lastSeenAt = item.LoggedAt - } - } - rows := make([]*NodeAccessLogIPSummaryRow, 0, len(accumulators)) - for remoteAddr, acc := range accumulators { - rows = append(rows, &NodeAccessLogIPSummaryRow{ - RemoteAddr: remoteAddr, - TotalRequests: acc.totalRequests, - RecentRequests: acc.recentRequests, - LastSeenEpoch: acc.lastSeenAt.Unix(), - }) - } - sortNodeAccessLogIPSummaryRows(rows, sortBy, sortOrder) - return rows -} - -func referenceIPTrendRows(records []*NodeAccessLog, remoteAddr string, bucketMinutes int) []*NodeAccessLogTrendPointRow { - buckets := make(map[int64]int64) - for _, item := range records { - if item == nil || strings.TrimSpace(item.RemoteAddr) != remoteAddr { - continue - } - buckets[bucketEpochForTime(item.LoggedAt, bucketMinutes)]++ - } - rows := make([]*NodeAccessLogTrendPointRow, 0, len(buckets)) - for bucketEpoch, requestCount := range buckets { - rows = append(rows, &NodeAccessLogTrendPointRow{BucketEpoch: bucketEpoch, RequestCount: requestCount}) - } - sort.Slice(rows, func(i int, j int) bool { return rows[i].BucketEpoch < rows[j].BucketEpoch }) - return rows -} - -func bucketRowsEqual(left []*NodeAccessLogBucketRow, right []*NodeAccessLogBucketRow) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] == nil || right[index] == nil { - if left[index] != right[index] { - return false - } - continue - } - if *left[index] != *right[index] { - return false - } - } - return true -} - -func bucketIPRowsEqual(left []*NodeAccessLogBucketIPRow, right []*NodeAccessLogBucketIPRow) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] == nil || right[index] == nil { - if left[index] != right[index] { - return false - } - continue - } - if *left[index] != *right[index] { - return false - } - } - return true -} - -func ipSummaryRowsEqual(left []*NodeAccessLogIPSummaryRow, right []*NodeAccessLogIPSummaryRow) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] == nil || right[index] == nil { - if left[index] != right[index] { - return false - } - continue - } - if *left[index] != *right[index] { - return false - } - } - return true -} - -func trendRowsEqual(left []*NodeAccessLogTrendPointRow, right []*NodeAccessLogTrendPointRow) bool { - if len(left) != len(right) { - return false - } - for index := range left { - if left[index] == nil || right[index] == nil { - if left[index] != right[index] { - return false - } - continue - } - if *left[index] != *right[index] { - return false - } - } - return true -} - -func TestNodeAccessLogOrderClauseMatchesSort(t *testing.T) { - if got := nodeAccessLogOrderClause("logged_at", "desc"); got != "logged_at DESC, id DESC" { - t.Fatalf("unexpected logged_at order clause: %q", got) - } - if got := nodeAccessLogOrderClause("status_code", "asc"); got != "status_code ASC, logged_at ASC, id ASC" { - t.Fatalf("unexpected status_code order clause: %q", got) - } -} diff --git a/openflare-server/internal/model/node_health_event.go b/openflare-server/internal/model/node_health_event.go deleted file mode 100644 index 88fdc0f7..00000000 --- a/openflare-server/internal/model/node_health_event.go +++ /dev/null @@ -1,47 +0,0 @@ -package model - -import "time" - -type NodeHealthEvent struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - EventType string `json:"event_type" gorm:"index;size:64;not null"` - Severity string `json:"severity" gorm:"size:16;not null"` - Status string `json:"status" gorm:"index;size:16;not null"` - Message string `json:"message" gorm:"type:text"` - FirstTriggeredAt time.Time `json:"first_triggered_at" gorm:"index"` - LastTriggeredAt time.Time `json:"last_triggered_at" gorm:"index"` - ReportedAt time.Time `json:"reported_at" gorm:"index"` - ResolvedAt *time.Time `json:"resolved_at" gorm:"index"` - MetadataJSON string `json:"metadata_json" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func GetActiveNodeHealthEvent(nodeID string, eventType string) (*NodeHealthEvent, error) { - event := &NodeHealthEvent{} - err := DB.Where("node_id = ? AND event_type = ? AND status = ?", nodeID, eventType, "active").First(event).Error - return event, err -} - -func ListNodeHealthEvents(nodeID string, activeOnly bool, limit int) (events []*NodeHealthEvent, err error) { - query := DB.Where("node_id = ?", nodeID).Order("last_triggered_at desc") - if activeOnly { - query = query.Where("status = ?", "active") - } - if limit > 0 { - query = query.Limit(limit) - } - err = query.Find(&events).Error - return events, err -} - -func ListActiveNodeHealthEvents() (events []*NodeHealthEvent, err error) { - err = DB.Where("status = ?", "active").Order("last_triggered_at desc").Find(&events).Error - return events, err -} - -func DeleteNodeHealthEvents(nodeID string) (deleted int64, err error) { - result := DB.Where("node_id = ?", nodeID).Delete(&NodeHealthEvent{}) - return result.RowsAffected, result.Error -} diff --git a/openflare-server/internal/model/node_metric_snapshot.go b/openflare-server/internal/model/node_metric_snapshot.go deleted file mode 100644 index b7a6f40a..00000000 --- a/openflare-server/internal/model/node_metric_snapshot.go +++ /dev/null @@ -1,107 +0,0 @@ -package model - -import ( - "time" - - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -type NodeMetricSnapshot struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - CapturedAt time.Time `json:"captured_at" gorm:"index"` - CPUUsagePercent float64 `json:"cpu_usage_percent"` - MemoryUsedBytes int64 `json:"memory_used_bytes"` - MemoryTotalBytes int64 `json:"memory_total_bytes"` - StorageUsedBytes int64 `json:"storage_used_bytes"` - StorageTotalBytes int64 `json:"storage_total_bytes"` - DiskReadBytes int64 `json:"disk_read_bytes"` - DiskWriteBytes int64 `json:"disk_write_bytes"` - NetworkRxBytes int64 `json:"network_rx_bytes"` - NetworkTxBytes int64 `json:"network_tx_bytes"` - CreatedAt time.Time `json:"created_at"` -} - -func (snapshot *NodeMetricSnapshot) GetID() uint { - return snapshot.ID -} - -func (snapshot *NodeMetricSnapshot) GetTime() time.Time { - return snapshot.CapturedAt -} - -func (snapshot *NodeMetricSnapshot) BeforeCreate(tx *gorm.DB) error { - return assignObservabilityID(&snapshot.ID) -} - -func (snapshot *NodeMetricSnapshot) Insert() error { - return DB.Create(snapshot).Error -} - -func ListNodeMetricSnapshots(nodeID string, since time.Time, limit int) (snapshots []*NodeMetricSnapshot, err error) { - rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) { - var shardRows []*NodeMetricSnapshot - query := tx.Order("captured_at desc, id desc") - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - if !since.IsZero() { - query = query.Where("captured_at >= ?", since) - } - if err := query.Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - return utils.SortAndLimitRecords(rows, limit), nil -} - -func ListMetricSnapshotsSince(since time.Time) (snapshots []*NodeMetricSnapshot, err error) { - rows, err := queryAcrossShards("node_metric_snapshots", func(tx *gorm.DB) ([]*NodeMetricSnapshot, error) { - var shardRows []*NodeMetricSnapshot - query := tx.Order("captured_at desc") - if !since.IsZero() { - query = query.Where("captured_at >= ?", since) - } - if err := query.Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - return utils.SortAndLimitRecords(rows, 0), nil -} - -func NodeMetricSnapshotExists(db *gorm.DB, nodeID string, capturedAt time.Time) (bool, error) { - db = normalizeShardedDB(db) - for _, table := range observabilityShardTables("node_metric_snapshots") { - var count int64 - if err := db.Table(table). - Where("node_id = ? AND captured_at = ?", nodeID, capturedAt). - Limit(1). - Count(&count).Error; err != nil { - return false, err - } - if count > 0 { - return true, nil - } - } - return false, nil -} - -func DeleteNodeMetricSnapshotsBefore(db *gorm.DB, before time.Time) (int64, error) { - return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, func(tx *gorm.DB) *gorm.DB { - return tx.Where("captured_at < ?", before) - }) -} - -func DeleteAllNodeMetricSnapshots(db *gorm.DB) (int64, error) { - return deleteAcrossShards(db, "node_metric_snapshots", &NodeMetricSnapshot{}, nil) -} diff --git a/openflare-server/internal/model/node_observation_frpc.go b/openflare-server/internal/model/node_observation_frpc.go deleted file mode 100644 index 5a5f6fcc..00000000 --- a/openflare-server/internal/model/node_observation_frpc.go +++ /dev/null @@ -1,61 +0,0 @@ -package model - -import ( - "time" - - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -type NodeObservationFrpc struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - CapturedAt time.Time `json:"captured_at" gorm:"index"` - TunnelStatus string `json:"tunnel_status" gorm:"size:16"` - ConnectedRelaysCount int `json:"connected_relays_count"` - CreatedAt time.Time `json:"created_at"` -} - -func (obs *NodeObservationFrpc) GetID() uint { - return obs.ID -} - -func (obs *NodeObservationFrpc) GetTime() time.Time { - return obs.CapturedAt -} - -func (obs *NodeObservationFrpc) BeforeCreate(tx *gorm.DB) error { - return assignObservabilityID(&obs.ID) -} - -func (obs *NodeObservationFrpc) Insert() error { - return DB.Create(obs).Error -} - -func ListNodeObservationFrpcs(nodeID string, since time.Time, limit int) (observations []*NodeObservationFrpc, err error) { - rows, err := queryAcrossShards("node_observation_frpcs", func(tx *gorm.DB) ([]*NodeObservationFrpc, error) { - var shardRows []*NodeObservationFrpc - query := tx.Order("captured_at desc, id desc") - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - if !since.IsZero() { - query = query.Where("captured_at >= ?", since) - } - if err := query.Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - return utils.SortAndLimitRecords(rows, limit), nil -} - -func DeleteNodeObservationFrpcsBefore(db *gorm.DB, before time.Time) (int64, error) { - return deleteAcrossShards(db, "node_observation_frpcs", &NodeObservationFrpc{}, func(tx *gorm.DB) *gorm.DB { - return tx.Where("captured_at < ?", before) - }) -} diff --git a/openflare-server/internal/model/node_observation_frps.go b/openflare-server/internal/model/node_observation_frps.go deleted file mode 100644 index 4f195c44..00000000 --- a/openflare-server/internal/model/node_observation_frps.go +++ /dev/null @@ -1,63 +0,0 @@ -package model - -import ( - "time" - - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -type NodeObservationFrps struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - CapturedAt time.Time `json:"captured_at" gorm:"index"` - FrpsConnections int `json:"frps_connections"` - FrpsProxyCount int `json:"frps_proxy_count"` - FrpsClientCount int `json:"frps_client_count"` - FrpsProxies string `json:"frps_proxies" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` -} - -func (obs *NodeObservationFrps) GetID() uint { - return obs.ID -} - -func (obs *NodeObservationFrps) GetTime() time.Time { - return obs.CapturedAt -} - -func (obs *NodeObservationFrps) BeforeCreate(tx *gorm.DB) error { - return assignObservabilityID(&obs.ID) -} - -func (obs *NodeObservationFrps) Insert() error { - return DB.Create(obs).Error -} - -func ListNodeObservationFrps(nodeID string, since time.Time, limit int) (observations []*NodeObservationFrps, err error) { - rows, err := queryAcrossShards("node_observation_frps", func(tx *gorm.DB) ([]*NodeObservationFrps, error) { - var shardRows []*NodeObservationFrps - query := tx.Order("captured_at desc, id desc") - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - if !since.IsZero() { - query = query.Where("captured_at >= ?", since) - } - if err := query.Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - return utils.SortAndLimitRecords(rows, limit), nil -} - -func DeleteNodeObservationFrpsBefore(db *gorm.DB, before time.Time) (int64, error) { - return deleteAcrossShards(db, "node_observation_frps", &NodeObservationFrps{}, func(tx *gorm.DB) *gorm.DB { - return tx.Where("captured_at < ?", before) - }) -} diff --git a/openflare-server/internal/model/node_observation_openresty.go b/openflare-server/internal/model/node_observation_openresty.go deleted file mode 100644 index a2cb5e8c..00000000 --- a/openflare-server/internal/model/node_observation_openresty.go +++ /dev/null @@ -1,62 +0,0 @@ -package model - -import ( - "time" - - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -type NodeObservationOpenresty struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - CapturedAt time.Time `json:"captured_at" gorm:"index"` - OpenrestyRxBytes int64 `json:"openresty_rx_bytes"` - OpenrestyTxBytes int64 `json:"openresty_tx_bytes"` - OpenrestyConnections int64 `json:"openresty_connections"` - CreatedAt time.Time `json:"created_at"` -} - -func (obs *NodeObservationOpenresty) GetID() uint { - return obs.ID -} - -func (obs *NodeObservationOpenresty) GetTime() time.Time { - return obs.CapturedAt -} - -func (obs *NodeObservationOpenresty) BeforeCreate(tx *gorm.DB) error { - return assignObservabilityID(&obs.ID) -} - -func (obs *NodeObservationOpenresty) Insert() error { - return DB.Create(obs).Error -} - -func ListNodeObservationOpenresty(nodeID string, since time.Time, limit int) (observations []*NodeObservationOpenresty, err error) { - rows, err := queryAcrossShards("node_observation_openresties", func(tx *gorm.DB) ([]*NodeObservationOpenresty, error) { - var shardRows []*NodeObservationOpenresty - query := tx.Order("captured_at desc, id desc") - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - if !since.IsZero() { - query = query.Where("captured_at >= ?", since) - } - if err := query.Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - return utils.SortAndLimitRecords(rows, limit), nil -} - -func DeleteNodeObservationOpenrestiesBefore(db *gorm.DB, before time.Time) (int64, error) { - return deleteAcrossShards(db, "node_observation_openresties", &NodeObservationOpenresty{}, func(tx *gorm.DB) *gorm.DB { - return tx.Where("captured_at < ?", before) - }) -} diff --git a/openflare-server/internal/model/node_request_report.go b/openflare-server/internal/model/node_request_report.go deleted file mode 100644 index 8c71c4fd..00000000 --- a/openflare-server/internal/model/node_request_report.go +++ /dev/null @@ -1,105 +0,0 @@ -package model - -import ( - "time" - - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -type NodeRequestReport struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - WindowStartedAt time.Time `json:"window_started_at" gorm:"index"` - WindowEndedAt time.Time `json:"window_ended_at" gorm:"index"` - RequestCount int64 `json:"request_count"` - ErrorCount int64 `json:"error_count"` - UniqueVisitorCount int64 `json:"unique_visitor_count"` - StatusCodesJSON string `json:"status_codes_json" gorm:"type:text"` - TopDomainsJSON string `json:"top_domains_json" gorm:"type:text"` - SourceCountriesJSON string `json:"source_countries_json" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` -} - -func (report *NodeRequestReport) GetID() uint { - return report.ID -} - -func (report *NodeRequestReport) GetTime() time.Time { - return report.WindowEndedAt -} - -func (report *NodeRequestReport) BeforeCreate(tx *gorm.DB) error { - return assignObservabilityID(&report.ID) -} - -func (report *NodeRequestReport) Insert() error { - return DB.Create(report).Error -} - -func ListNodeRequestReports(nodeID string, since time.Time, limit int) (reports []*NodeRequestReport, err error) { - rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) { - var shardRows []*NodeRequestReport - query := tx.Order("window_ended_at desc, id desc") - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - if !since.IsZero() { - query = query.Where("window_ended_at >= ?", since) - } - if err := query.Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - return utils.SortAndLimitRecords(rows, limit), nil -} - -func ListRequestReportsSince(since time.Time) (reports []*NodeRequestReport, err error) { - rows, err := queryAcrossShards("node_request_reports", func(tx *gorm.DB) ([]*NodeRequestReport, error) { - var shardRows []*NodeRequestReport - query := tx.Order("window_ended_at desc") - if !since.IsZero() { - query = query.Where("window_ended_at >= ?", since) - } - if err := query.Find(&shardRows).Error; err != nil { - return nil, err - } - return shardRows, nil - }) - if err != nil { - return nil, err - } - return utils.SortAndLimitRecords(rows, 0), nil -} - -func NodeRequestReportExists(db *gorm.DB, nodeID string, windowStartedAt time.Time, windowEndedAt time.Time) (bool, error) { - db = normalizeShardedDB(db) - for _, table := range observabilityShardTables("node_request_reports") { - var count int64 - if err := db.Table(table). - Where("node_id = ? AND window_started_at = ? AND window_ended_at = ?", nodeID, windowStartedAt, windowEndedAt). - Limit(1). - Count(&count).Error; err != nil { - return false, err - } - if count > 0 { - return true, nil - } - } - return false, nil -} - -func DeleteNodeRequestReportsBefore(db *gorm.DB, before time.Time) (int64, error) { - return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, func(tx *gorm.DB) *gorm.DB { - return tx.Where("window_ended_at < ?", before) - }) -} - -func DeleteAllNodeRequestReports(db *gorm.DB) (int64, error) { - return deleteAcrossShards(db, "node_request_reports", &NodeRequestReport{}, nil) -} diff --git a/openflare-server/internal/model/node_system_profile.go b/openflare-server/internal/model/node_system_profile.go deleted file mode 100644 index 5f2b345f..00000000 --- a/openflare-server/internal/model/node_system_profile.go +++ /dev/null @@ -1,54 +0,0 @@ -package model - -import ( - "time" - - "gorm.io/gorm/clause" -) - -type NodeSystemProfile struct { - ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"uniqueIndex;size:64;not null"` - Hostname string `json:"hostname" gorm:"size:255"` - OSName string `json:"os_name" gorm:"size:128"` - OSVersion string `json:"os_version" gorm:"size:128"` - KernelVersion string `json:"kernel_version" gorm:"size:128"` - Architecture string `json:"architecture" gorm:"size:64"` - CPUModel string `json:"cpu_model" gorm:"size:255"` - CPUCores int `json:"cpu_cores"` - TotalMemoryBytes int64 `json:"total_memory_bytes"` - TotalDiskBytes int64 `json:"total_disk_bytes"` - UptimeSeconds int64 `json:"uptime_seconds"` - ReportedAt time.Time `json:"reported_at" gorm:"index"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func GetNodeSystemProfile(nodeID string) (*NodeSystemProfile, error) { - profile := &NodeSystemProfile{} - err := DB.Where("node_id = ?", nodeID).First(profile).Error - return profile, err -} - -func UpsertNodeSystemProfile(profile *NodeSystemProfile) error { - if profile == nil { - return nil - } - return DB.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "node_id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "hostname", - "os_name", - "os_version", - "kernel_version", - "architecture", - "cpu_model", - "cpu_cores", - "total_memory_bytes", - "total_disk_bytes", - "uptime_seconds", - "reported_at", - "updated_at", - }), - }).Create(profile).Error -} diff --git a/Wavelet/internal/model/openflare_access_log.go b/openflare-server/internal/model/openflare_access_log.go similarity index 100% rename from Wavelet/internal/model/openflare_access_log.go rename to openflare-server/internal/model/openflare_access_log.go diff --git a/Wavelet/internal/model/openflare_access_log_test.go b/openflare-server/internal/model/openflare_access_log_test.go similarity index 100% rename from Wavelet/internal/model/openflare_access_log_test.go rename to openflare-server/internal/model/openflare_access_log_test.go diff --git a/Wavelet/internal/model/openflare_acme_account.go b/openflare-server/internal/model/openflare_acme_account.go similarity index 100% rename from Wavelet/internal/model/openflare_acme_account.go rename to openflare-server/internal/model/openflare_acme_account.go diff --git a/Wavelet/internal/model/openflare_apply_log.go b/openflare-server/internal/model/openflare_apply_log.go similarity index 100% rename from Wavelet/internal/model/openflare_apply_log.go rename to openflare-server/internal/model/openflare_apply_log.go diff --git a/Wavelet/internal/model/openflare_config_version.go b/openflare-server/internal/model/openflare_config_version.go similarity index 100% rename from Wavelet/internal/model/openflare_config_version.go rename to openflare-server/internal/model/openflare_config_version.go diff --git a/Wavelet/internal/model/openflare_dns_account.go b/openflare-server/internal/model/openflare_dns_account.go similarity index 100% rename from Wavelet/internal/model/openflare_dns_account.go rename to openflare-server/internal/model/openflare_dns_account.go diff --git a/Wavelet/internal/model/openflare_managed_domain.go b/openflare-server/internal/model/openflare_managed_domain.go similarity index 100% rename from Wavelet/internal/model/openflare_managed_domain.go rename to openflare-server/internal/model/openflare_managed_domain.go diff --git a/Wavelet/internal/model/openflare_node.go b/openflare-server/internal/model/openflare_node.go similarity index 100% rename from Wavelet/internal/model/openflare_node.go rename to openflare-server/internal/model/openflare_node.go diff --git a/Wavelet/internal/model/openflare_observability.go b/openflare-server/internal/model/openflare_observability.go similarity index 100% rename from Wavelet/internal/model/openflare_observability.go rename to openflare-server/internal/model/openflare_observability.go diff --git a/Wavelet/internal/model/openflare_option.go b/openflare-server/internal/model/openflare_option.go similarity index 100% rename from Wavelet/internal/model/openflare_option.go rename to openflare-server/internal/model/openflare_option.go diff --git a/Wavelet/internal/model/openflare_origin.go b/openflare-server/internal/model/openflare_origin.go similarity index 100% rename from Wavelet/internal/model/openflare_origin.go rename to openflare-server/internal/model/openflare_origin.go diff --git a/Wavelet/internal/model/openflare_pages.go b/openflare-server/internal/model/openflare_pages.go similarity index 100% rename from Wavelet/internal/model/openflare_pages.go rename to openflare-server/internal/model/openflare_pages.go diff --git a/Wavelet/internal/model/openflare_proxy_route.go b/openflare-server/internal/model/openflare_proxy_route.go similarity index 100% rename from Wavelet/internal/model/openflare_proxy_route.go rename to openflare-server/internal/model/openflare_proxy_route.go diff --git a/Wavelet/internal/model/openflare_tls.go b/openflare-server/internal/model/openflare_tls.go similarity index 100% rename from Wavelet/internal/model/openflare_tls.go rename to openflare-server/internal/model/openflare_tls.go diff --git a/Wavelet/internal/model/openflare_waf.go b/openflare-server/internal/model/openflare_waf.go similarity index 100% rename from Wavelet/internal/model/openflare_waf.go rename to openflare-server/internal/model/openflare_waf.go diff --git a/openflare-server/internal/model/option.go b/openflare-server/internal/model/option.go deleted file mode 100644 index 2ac8e006..00000000 --- a/openflare-server/internal/model/option.go +++ /dev/null @@ -1,426 +0,0 @@ -package model - -import ( - "strconv" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/pkg/geoip" - - "gorm.io/gorm" -) - -type Option struct { - Key string `json:"key" gorm:"primaryKey"` - Value string `json:"value"` -} - -func AllOption() ([]*Option, error) { - var options []*Option - var err error - err = DB.Find(&options).Error - return options, err -} - -func InitOptionMap() { - common.OptionMapRWMutex.Lock() - common.OptionMap = make(map[string]string) - common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled) - common.OptionMap["CapLoginEnabled"] = strconv.FormatBool(common.CapLoginEnabled) - common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled) - common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) - common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) - common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) - common.OptionMap["SMTPServer"] = "" - common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort) - common.OptionMap["SMTPAccount"] = "" - common.OptionMap["SMTPToken"] = "" - common.OptionMap["Notice"] = "" - common.OptionMap["About"] = "" - common.OptionMap["Footer"] = common.Footer - common.OptionMap["HomePageLink"] = common.HomePageLink - common.OptionMap["SystemName"] = common.SystemName - common.OptionMap["ServerAddress"] = "" - common.OptionMap["GitHubClientId"] = "" - common.OptionMap["GitHubClientSecret"] = "" - common.OptionMap["WeChatServerAddress"] = "" - common.OptionMap["WeChatServerToken"] = "" - common.OptionMap["WeChatAccountQRCodeImageURL"] = "" - common.OptionMap["AgentDiscoveryToken"] = "" - common.OptionMap["AgentHeartbeatInterval"] = strconv.Itoa(common.AgentHeartbeatInterval) - common.OptionMap["AgentWebsocketUpgradeEnabled"] = strconv.FormatBool(common.AgentWebsocketUpgradeEnabled) - common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds())) - common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo - common.OptionMap["GeoIPProvider"] = common.GeoIPProvider - common.OptionMap["DatabaseAutoCleanupEnabled"] = strconv.FormatBool(common.DatabaseAutoCleanupEnabled) - common.OptionMap["UptimeKumaEnabled"] = strconv.FormatBool(common.UptimeKumaEnabled) - common.OptionMap["UptimeKumaUrl"] = common.UptimeKumaUrl - common.OptionMap["UptimeKumaUsername"] = common.UptimeKumaUsername - common.OptionMap["UptimeKumaPassword"] = common.UptimeKumaPassword - common.OptionMap["UptimeKumaMonitorScope"] = common.UptimeKumaMonitorScope - common.OptionMap["UptimeKumaSelectedSites"] = common.UptimeKumaSelectedSites - common.OptionMap["UptimeKumaSyncInterval"] = strconv.Itoa(common.UptimeKumaSyncInterval) - common.OptionMap["UptimeKumaInterval"] = strconv.Itoa(common.UptimeKumaInterval) - common.OptionMap["UptimeKumaRetry"] = strconv.Itoa(common.UptimeKumaRetry) - common.OptionMap["UptimeKumaRetryInterval"] = strconv.Itoa(common.UptimeKumaRetryInterval) - common.OptionMap["UptimeKumaTimeout"] = strconv.Itoa(common.UptimeKumaTimeout) - common.OptionMap["DatabaseAutoCleanupRetentionDays"] = strconv.Itoa(common.DatabaseAutoCleanupRetentionDays) - common.OptionMap["OpenRestyDefaultServerReturnStatus"] = strconv.Itoa(common.OpenRestyDefaultServerReturnStatus) - common.OptionMap["OpenRestyWorkerProcesses"] = common.OpenRestyWorkerProcesses - common.OptionMap["OpenRestyWorkerConnections"] = strconv.Itoa(common.OpenRestyWorkerConnections) - common.OptionMap["OpenRestyWorkerRlimitNofile"] = strconv.Itoa(common.OpenRestyWorkerRlimitNofile) - common.OptionMap["OpenRestyEventsUse"] = common.OpenRestyEventsUse - common.OptionMap["OpenRestyEventsMultiAcceptEnabled"] = strconv.FormatBool(common.OpenRestyEventsMultiAcceptEnabled) - common.OptionMap["OpenRestyKeepaliveTimeout"] = strconv.Itoa(common.OpenRestyKeepaliveTimeout) - common.OptionMap["OpenRestyKeepaliveRequests"] = strconv.Itoa(common.OpenRestyKeepaliveRequests) - common.OptionMap["OpenRestyClientHeaderTimeout"] = strconv.Itoa(common.OpenRestyClientHeaderTimeout) - common.OptionMap["OpenRestyClientBodyTimeout"] = strconv.Itoa(common.OpenRestyClientBodyTimeout) - common.OptionMap["OpenRestyClientMaxBodySize"] = common.OpenRestyClientMaxBodySize - common.OptionMap["OpenRestyLargeClientHeaderBuffers"] = common.OpenRestyLargeClientHeaderBuffers - common.OptionMap["OpenRestySendTimeout"] = strconv.Itoa(common.OpenRestySendTimeout) - common.OptionMap["OpenRestyProxyConnectTimeout"] = strconv.Itoa(common.OpenRestyProxyConnectTimeout) - common.OptionMap["OpenRestyProxySendTimeout"] = strconv.Itoa(common.OpenRestyProxySendTimeout) - common.OptionMap["OpenRestyProxyReadTimeout"] = strconv.Itoa(common.OpenRestyProxyReadTimeout) - common.OptionMap["OpenRestyWebsocketEnabled"] = strconv.FormatBool(common.OpenRestyWebsocketEnabled) - common.OptionMap["OpenRestyHTTP3Enabled"] = strconv.FormatBool(common.OpenRestyHTTP3Enabled) - common.OptionMap["OpenRestyProxyRequestBufferingEnabled"] = strconv.FormatBool(common.OpenRestyProxyRequestBufferingEnabled) - common.OptionMap["OpenRestyProxyBufferingEnabled"] = strconv.FormatBool(common.OpenRestyProxyBufferingEnabled) - common.OptionMap["OpenRestyProxyBuffers"] = common.OpenRestyProxyBuffers - common.OptionMap["OpenRestyProxyBufferSize"] = common.OpenRestyProxyBufferSize - common.OptionMap["OpenRestyProxyBusyBuffersSize"] = common.OpenRestyProxyBusyBuffersSize - common.OptionMap["OpenRestyGzipEnabled"] = strconv.FormatBool(common.OpenRestyGzipEnabled) - common.OptionMap["OpenRestyGzipMinLength"] = strconv.Itoa(common.OpenRestyGzipMinLength) - common.OptionMap["OpenRestyGzipCompLevel"] = strconv.Itoa(common.OpenRestyGzipCompLevel) - common.OptionMap["OpenRestyCacheEnabled"] = strconv.FormatBool(common.OpenRestyCacheEnabled) - common.OptionMap["OpenRestyCachePath"] = common.OpenRestyCachePath - common.OptionMap["OpenRestyCacheLevels"] = common.OpenRestyCacheLevels - common.OptionMap["OpenRestyCacheInactive"] = common.OpenRestyCacheInactive - common.OptionMap["OpenRestyCacheMaxSize"] = common.OpenRestyCacheMaxSize - common.OptionMap["OpenRestyCacheKeyTemplate"] = common.OpenRestyCacheKeyTemplate - common.OptionMap["OpenRestyCacheLockEnabled"] = strconv.FormatBool(common.OpenRestyCacheLockEnabled) - common.OptionMap["OpenRestyCacheLockTimeout"] = common.OpenRestyCacheLockTimeout - common.OptionMap["OpenRestyCacheUseStale"] = common.OpenRestyCacheUseStale - common.OptionMap["OpenRestyMainConfigTemplate"] = common.OpenRestyMainConfigTemplate - common.OptionMap["GlobalApiRateLimitNum"] = strconv.Itoa(common.GlobalApiRateLimitNum) - common.OptionMap["GlobalApiRateLimitDuration"] = strconv.FormatInt(common.GlobalApiRateLimitDuration, 10) - common.OptionMap["GlobalWebRateLimitNum"] = strconv.Itoa(common.GlobalWebRateLimitNum) - common.OptionMap["GlobalWebRateLimitDuration"] = strconv.FormatInt(common.GlobalWebRateLimitDuration, 10) - common.OptionMap["CriticalRateLimitNum"] = strconv.Itoa(common.CriticalRateLimitNum) - common.OptionMap["CriticalRateLimitDuration"] = strconv.FormatInt(common.CriticalRateLimitDuration, 10) - common.OptionMapRWMutex.Unlock() - options, _ := AllOption() - for _, option := range options { - updateOptionMap(option.Key, option.Value) - } -} - -func UpdateOption(key string, value string) error { - 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 { - if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" { - continue - } - 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 { - if item.Key == "UptimeKumaPassword" && strings.TrimSpace(item.Value) == "" { - continue - } - updateOptionMap(item.Key, item.Value) - } - return nil -} - -func updateOptionMap(key string, value string) { - shouldRefreshGeoIP := false - common.OptionMapRWMutex.Lock() - if common.OptionMap == nil { - common.OptionMap = make(map[string]string) - } - common.OptionMap[key] = value - if strings.HasSuffix(key, "Enabled") { - boolValue := value == "true" - switch key { - case "PasswordRegisterEnabled": - common.PasswordRegisterEnabled = boolValue - case "PasswordLoginEnabled": - common.PasswordLoginEnabled = boolValue - case "CapLoginEnabled": - common.CapLoginEnabled = boolValue - case "EmailVerificationEnabled": - common.EmailVerificationEnabled = boolValue - case "GitHubOAuthEnabled": - common.GitHubOAuthEnabled = boolValue - case "WeChatAuthEnabled": - common.WeChatAuthEnabled = boolValue - } - } - switch key { - case "SMTPServer": - common.SMTPServer = value - case "SMTPPort": - intValue, _ := strconv.Atoi(value) - common.SMTPPort = intValue - case "SMTPAccount": - common.SMTPAccount = value - case "SMTPToken": - common.SMTPToken = value - case "ServerAddress": - common.ServerAddress = value - case "GitHubClientId": - common.GitHubClientId = value - case "GitHubClientSecret": - common.GitHubClientSecret = value - case "Footer": - common.Footer = value - case "HomePageLink": - common.HomePageLink = value - case "SystemName": - common.SystemName = value - case "WeChatServerAddress": - common.WeChatServerAddress = value - case "WeChatServerToken": - common.WeChatServerToken = value - case "WeChatAccountQRCodeImageURL": - common.WeChatAccountQRCodeImageURL = value - case "AgentDiscoveryToken": - common.AgentDiscoveryToken = value - case "AgentHeartbeatInterval": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.AgentHeartbeatInterval = v - } - case "AgentWebsocketUpgradeEnabled": - common.AgentWebsocketUpgradeEnabled = value == "true" - case "NodeOfflineThreshold": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.NodeOfflineThreshold = time.Duration(v) * time.Millisecond - } - case "AgentUpdateRepo": - if value != "" { - common.AgentUpdateRepo = value - } - case "GeoIPProvider": - if geoip.IsValidProvider(value) { - common.GeoIPProvider = value - shouldRefreshGeoIP = true - } - case "UptimeKumaEnabled": - common.UptimeKumaEnabled = value == "true" - case "UptimeKumaUrl": - common.UptimeKumaUrl = value - case "UptimeKumaUsername": - common.UptimeKumaUsername = value - case "UptimeKumaPassword": - common.UptimeKumaPassword = value - case "UptimeKumaMonitorScope": - common.UptimeKumaMonitorScope = value - case "UptimeKumaSelectedSites": - common.UptimeKumaSelectedSites = value - case "UptimeKumaSyncInterval": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.UptimeKumaSyncInterval = v - } - case "UptimeKumaInterval": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.UptimeKumaInterval = v - } - case "UptimeKumaRetry": - if v, err := strconv.Atoi(value); err == nil && v >= 0 { - common.UptimeKumaRetry = v - } - case "UptimeKumaRetryInterval": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.UptimeKumaRetryInterval = v - } - case "UptimeKumaTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.UptimeKumaTimeout = v - } - case "DatabaseAutoCleanupEnabled": - common.DatabaseAutoCleanupEnabled = value == "true" - case "DatabaseAutoCleanupRetentionDays": - if v, err := strconv.Atoi(value); err == nil && v >= 1 { - common.DatabaseAutoCleanupRetentionDays = v - } - case "OpenRestyDefaultServerReturnStatus": - if v, err := strconv.Atoi(value); err == nil && v >= 100 && v <= 999 { - common.OpenRestyDefaultServerReturnStatus = v - } - case "OpenRestyWorkerProcesses": - if strings.TrimSpace(value) != "" { - common.OpenRestyWorkerProcesses = value - } - case "OpenRestyWorkerConnections": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyWorkerConnections = v - } - case "OpenRestyWorkerRlimitNofile": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyWorkerRlimitNofile = v - } - case "OpenRestyEventsUse": - common.OpenRestyEventsUse = value - case "OpenRestyResolvers": - common.OpenRestyResolvers = value - case "OpenRestyEventsMultiAcceptEnabled": - common.OpenRestyEventsMultiAcceptEnabled = value == "true" - case "OpenRestyKeepaliveTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyKeepaliveTimeout = v - } - case "OpenRestyKeepaliveRequests": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyKeepaliveRequests = v - } - case "OpenRestyClientHeaderTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyClientHeaderTimeout = v - } - case "OpenRestyClientBodyTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyClientBodyTimeout = v - } - case "OpenRestyClientMaxBodySize": - if strings.TrimSpace(value) != "" { - common.OpenRestyClientMaxBodySize = value - } - case "OpenRestyLargeClientHeaderBuffers": - if strings.TrimSpace(value) != "" { - common.OpenRestyLargeClientHeaderBuffers = value - } - case "OpenRestySendTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestySendTimeout = v - } - case "OpenRestyProxyConnectTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyProxyConnectTimeout = v - } - case "OpenRestyProxySendTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyProxySendTimeout = v - } - case "OpenRestyProxyReadTimeout": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyProxyReadTimeout = v - } - case "OpenRestyWebsocketEnabled": - common.OpenRestyWebsocketEnabled = value == "true" - case "OpenRestyHTTP3Enabled": - common.OpenRestyHTTP3Enabled = value == "true" - case "OpenRestyProxyRequestBufferingEnabled": - common.OpenRestyProxyRequestBufferingEnabled = value == "true" - case "OpenRestyProxyBufferingEnabled": - common.OpenRestyProxyBufferingEnabled = value == "true" - case "OpenRestyProxyBuffers": - if strings.TrimSpace(value) != "" { - common.OpenRestyProxyBuffers = value - } - case "OpenRestyProxyBufferSize": - if strings.TrimSpace(value) != "" { - common.OpenRestyProxyBufferSize = value - } - case "OpenRestyProxyBusyBuffersSize": - if strings.TrimSpace(value) != "" { - common.OpenRestyProxyBusyBuffersSize = value - } - case "OpenRestyGzipEnabled": - common.OpenRestyGzipEnabled = value == "true" - case "OpenRestyGzipMinLength": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyGzipMinLength = v - } - case "OpenRestyGzipCompLevel": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.OpenRestyGzipCompLevel = v - } - case "OpenRestyCacheEnabled": - common.OpenRestyCacheEnabled = value == "true" - case "OpenRestyCachePath": - common.OpenRestyCachePath = value - case "OpenRestyCacheLevels": - if strings.TrimSpace(value) != "" { - common.OpenRestyCacheLevels = value - } - case "OpenRestyCacheInactive": - if strings.TrimSpace(value) != "" { - common.OpenRestyCacheInactive = value - } - case "OpenRestyCacheMaxSize": - if strings.TrimSpace(value) != "" { - common.OpenRestyCacheMaxSize = value - } - case "OpenRestyCacheKeyTemplate": - if strings.TrimSpace(value) != "" { - common.OpenRestyCacheKeyTemplate = value - } - case "OpenRestyCacheLockEnabled": - common.OpenRestyCacheLockEnabled = value == "true" - case "OpenRestyCacheLockTimeout": - if strings.TrimSpace(value) != "" { - common.OpenRestyCacheLockTimeout = value - } - case "OpenRestyCacheUseStale": - if strings.TrimSpace(value) != "" { - common.OpenRestyCacheUseStale = value - } - case "OpenRestyMainConfigTemplate": - if strings.TrimSpace(value) != "" { - common.OpenRestyMainConfigTemplate = value - } - case "GlobalApiRateLimitNum": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.GlobalApiRateLimitNum = v - } - case "GlobalApiRateLimitDuration": - if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { - common.GlobalApiRateLimitDuration = v - } - case "GlobalWebRateLimitNum": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.GlobalWebRateLimitNum = v - } - case "GlobalWebRateLimitDuration": - if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { - common.GlobalWebRateLimitDuration = v - } - case "CriticalRateLimitNum": - if v, err := strconv.Atoi(value); err == nil && v > 0 { - common.CriticalRateLimitNum = v - } - case "CriticalRateLimitDuration": - if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 { - common.CriticalRateLimitDuration = v - } - } - common.OptionMapRWMutex.Unlock() - if shouldRefreshGeoIP { - geoip.InitGeoIP(common.GeoIPProvider) - } -} diff --git a/openflare-server/internal/model/origin.go b/openflare-server/internal/model/origin.go deleted file mode 100644 index 43d488b8..00000000 --- a/openflare-server/internal/model/origin.go +++ /dev/null @@ -1,56 +0,0 @@ -package model - -import "time" - -type Origin struct { - ID uint `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:255;not null"` - Address string `json:"address" gorm:"uniqueIndex;size:255;not null"` - Remark string `json:"remark" gorm:"size:255"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type OriginRouteCount struct { - OriginID uint `json:"origin_id"` - RouteCount int64 `json:"route_count"` -} - -func ListOrigins() (origins []*Origin, err error) { - err = DB.Order("id desc").Find(&origins).Error - return origins, err -} - -func GetOriginByID(id uint) (*Origin, error) { - origin := &Origin{} - err := DB.First(origin, id).Error - return origin, err -} - -func GetOriginByAddress(address string) (*Origin, error) { - origin := &Origin{} - err := DB.Where("address = ?", address).First(origin).Error - return origin, err -} - -func ListOriginRouteCounts() ([]OriginRouteCount, error) { - result := make([]OriginRouteCount, 0) - err := DB.Model(&ProxyRoute{}). - Select("origin_id, COUNT(*) AS route_count"). - Where("origin_id IS NOT NULL"). - Group("origin_id"). - Scan(&result).Error - return result, err -} - -func (origin *Origin) Insert() error { - return DB.Create(origin).Error -} - -func (origin *Origin) Update() error { - return DB.Save(origin).Error -} - -func (origin *Origin) Delete() error { - return DB.Delete(origin).Error -} diff --git a/openflare-server/internal/model/pages.go b/openflare-server/internal/model/pages.go deleted file mode 100644 index 0fe6f854..00000000 --- a/openflare-server/internal/model/pages.go +++ /dev/null @@ -1,83 +0,0 @@ -package model - -import "time" - -const ( - PagesDeploymentStatusUploaded = "uploaded" - PagesDeploymentStatusActive = "active" -) - -type PagesProject struct { - ID uint `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:255;not null"` - Slug string `json:"slug" gorm:"uniqueIndex;size:128;not null"` - Description string `json:"description" gorm:"type:text;not null;default:''"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"` - SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"` - APIProxyEnabled bool `json:"api_proxy_enabled" gorm:"not null;default:false"` - APIProxyPath string `json:"api_proxy_path" gorm:"size:255;not null;default:''"` - APIProxyPass string `json:"api_proxy_pass" gorm:"size:2048;not null;default:''"` - APIProxyRewrite string `json:"api_proxy_rewrite" gorm:"size:255;not null;default:''"` - ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"` - RootDir string `json:"root_dir" gorm:"size:512;not null;default:''"` - EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type PagesDeployment struct { - ID uint `json:"id" gorm:"primaryKey"` - ProjectID uint `json:"project_id" gorm:"not null;index"` - DeploymentNumber int `json:"deployment_number" gorm:"not null"` - Checksum string `json:"checksum" gorm:"size:64;not null;index"` - Status string `json:"status" gorm:"size:32;not null;default:'uploaded';index"` - ArtifactPath string `json:"artifact_path" gorm:"size:2048;not null"` - FileCount int `json:"file_count" gorm:"not null;default:0"` - TotalSize int64 `json:"total_size" gorm:"not null;default:0"` - CreatedBy string `json:"created_by" gorm:"size:64;not null;default:''"` - CreatedAt time.Time `json:"created_at"` - ActivatedAt *time.Time `json:"activated_at"` -} - -type PagesDeploymentFile struct { - ID uint `json:"id" gorm:"primaryKey"` - DeploymentID uint `json:"deployment_id" gorm:"not null;index"` - Path string `json:"path" gorm:"size:2048;not null"` - Size int64 `json:"size" gorm:"not null;default:0"` - Checksum string `json:"checksum" gorm:"size:64;not null"` - CreatedAt time.Time `json:"created_at"` -} - -func ListPagesProjects() (projects []*PagesProject, err error) { - err = DB.Order("id desc").Find(&projects).Error - return projects, err -} - -func GetPagesProjectByID(id uint) (*PagesProject, error) { - project := &PagesProject{} - err := DB.First(project, id).Error - return project, err -} - -func GetPagesProjectBySlug(slug string) (*PagesProject, error) { - project := &PagesProject{} - err := DB.Where("slug = ?", slug).First(project).Error - return project, err -} - -func ListPagesDeployments(projectID uint) (deployments []*PagesDeployment, err error) { - err = DB.Where("project_id = ?", projectID).Order("id desc").Find(&deployments).Error - return deployments, err -} - -func GetPagesDeploymentByID(id uint) (*PagesDeployment, error) { - deployment := &PagesDeployment{} - err := DB.First(deployment, id).Error - return deployment, err -} - -func ListPagesDeploymentFiles(deploymentID uint) (files []*PagesDeploymentFile, err error) { - err = DB.Where("deployment_id = ?", deploymentID).Order("path asc").Find(&files).Error - return files, err -} diff --git a/openflare-server/internal/model/proxy_route.go b/openflare-server/internal/model/proxy_route.go deleted file mode 100644 index bf06dfc4..00000000 --- a/openflare-server/internal/model/proxy_route.go +++ /dev/null @@ -1,101 +0,0 @@ -package model - -import "time" - -type ProxyRoute struct { - 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:'[]'"` - BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"` - BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"` - BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"` - Remark string `json:"remark" gorm:"size:255"` - UpstreamType string `json:"upstream_type" gorm:"size:32;not null;default:'direct'"` - TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"` - TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"` - TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"` - PagesProjectID *uint `json:"pages_project_id" gorm:"index"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func ListProxyRoutes() (routes []*ProxyRoute, err error) { - err = DB.Order("id desc").Find(&routes).Error - return routes, err -} - -func GetEnabledProxyRoutes() (routes []*ProxyRoute, err error) { - err = DB.Where("enabled = ?", true).Order("site_name asc").Order("domain asc").Find(&routes).Error - return routes, err -} - -func GetProxyRouteByID(id uint) (*ProxyRoute, error) { - route := &ProxyRoute{} - err := DB.First(route, id).Error - return route, err -} - -func ListProxyRoutesByOriginID(originID uint) (routes []*ProxyRoute, err error) { - err = DB.Where("origin_id = ?", originID).Order("id desc").Find(&routes).Error - return routes, err -} - -func (route *ProxyRoute) Insert() error { - return DB.Create(route).Error -} - -func (route *ProxyRoute) Update() error { - return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{ - "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, - "basic_auth_enabled": route.BasicAuthEnabled, - "basic_auth_username": route.BasicAuthUsername, - "basic_auth_password": route.BasicAuthPassword, - "remark": route.Remark, - "upstream_type": route.UpstreamType, - "tunnel_node_id": route.TunnelNodeID, - "tunnel_target_addr": route.TunnelTargetAddr, - "tunnel_target_protocol": route.TunnelTargetProtocol, - "pages_project_id": route.PagesProjectID, - }).Error -} - -func (route *ProxyRoute) Delete() error { - return DB.Delete(route).Error -} diff --git a/Wavelet/internal/model/push_channel.go b/openflare-server/internal/model/push_channel.go similarity index 100% rename from Wavelet/internal/model/push_channel.go rename to openflare-server/internal/model/push_channel.go diff --git a/Wavelet/internal/model/push_event.go b/openflare-server/internal/model/push_event.go similarity index 100% rename from Wavelet/internal/model/push_event.go rename to openflare-server/internal/model/push_event.go diff --git a/Wavelet/internal/model/push_history.go b/openflare-server/internal/model/push_history.go similarity index 100% rename from Wavelet/internal/model/push_history.go rename to openflare-server/internal/model/push_history.go diff --git a/Wavelet/internal/model/schedule.go b/openflare-server/internal/model/schedule.go similarity index 100% rename from Wavelet/internal/model/schedule.go rename to openflare-server/internal/model/schedule.go diff --git a/openflare-server/internal/model/schema_migration_context.go b/openflare-server/internal/model/schema_migration_context.go deleted file mode 100644 index 2f7cb26b..00000000 --- a/openflare-server/internal/model/schema_migration_context.go +++ /dev/null @@ -1,60 +0,0 @@ -package model - -import ( - schemagoose "github.com/rain-kl/openflare/openflare-server/internal/model/goose" - - "gorm.io/gorm" -) - -func currentGooseTargetVersion() int64 { - return schemagoose.CurrentTargetVersion() -} - -func loadGooseDatabaseVersion(db *gorm.DB) (int, bool, error) { - return schemagoose.LoadDatabaseVersion(db) -} - -func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error { - return schemagoose.EnsureDatabaseSchemaUpToDate(db, backend, databaseSchemaMigrationContext{}) -} - -func (databaseSchemaMigrationContext) RegisterSharding(db *gorm.DB, backend string) error { - return registerSharding(db, backend) -} - -func (databaseSchemaMigrationContext) AutoMigrateLegacySchemaMetadata(db *gorm.DB) error { - return autoMigrateLegacySchemaMetadata(db) -} - -func (databaseSchemaMigrationContext) InitializeFreshDatabaseSchema(db *gorm.DB, backend string) error { - return initializeFreshDatabaseSchema(db, backend) -} - -func (databaseSchemaMigrationContext) IsDatabaseEmpty(db *gorm.DB) (bool, error) { - return isDatabaseEmpty(db) -} - -func (databaseSchemaMigrationContext) RepairCurrentSchemaState(db *gorm.DB, backend string) error { - if err := dropLegacyNodeColumns(db, backend); err != nil { - return err - } - if err := ensureDefaultGitHubAuthSource(db); err != nil { - return err - } - if err := ensureDefaultWAFRuleGroup(db); err != nil { - return err - } - return nil -} - -func (databaseSchemaMigrationContext) SaveLegacyDatabaseSchemaVersion(db *gorm.DB, version int) error { - return saveLegacyDatabaseSchemaVersion(db, version) -} - -func (databaseSchemaMigrationContext) UpgradeLegacyDatabaseSchema(db *gorm.DB, backend string, version int) error { - return upgradeLegacyDatabaseSchema(db, backend, version) -} - -func (databaseSchemaMigrationContext) ValidateCurrentDatabaseSchema(db *gorm.DB, backend string) error { - return validateCurrentDatabaseSchema(db, backend) -} diff --git a/openflare-server/internal/model/sharding.go b/openflare-server/internal/model/sharding.go deleted file mode 100644 index a615c7bc..00000000 --- a/openflare-server/internal/model/sharding.go +++ /dev/null @@ -1,208 +0,0 @@ -package model - -import ( - "fmt" - "strconv" - "strings" - "sync" - - "github.com/bwmarrin/snowflake" - "gorm.io/gorm" - "gorm.io/sharding" -) - -const observabilityShardCount = 10 - -var ( - observabilityIDNode *snowflake.Node - observabilityIDNodeErr error - observabilityIDNodeOnce sync.Once -) - -func registerSharding(db *gorm.DB, backend string) error { - if db == nil { - return nil - } - _ = backend - if err := db.Use(sharding.Register(sharding.Config{ - ShardingKey: "id", - NumberOfShards: observabilityShardCount, - ShardingAlgorithm: func(value any) (string, error) { - return observabilityShardSuffixForValue(value) - }, - ShardingAlgorithmByPrimaryKey: func(id int64) string { - return observabilityShardSuffixForInt64(id) - }, - PrimaryKeyGenerator: sharding.PKCustom, - PrimaryKeyGeneratorFn: func(tableIdx int64) int64 { - return 0 - }, - }, shardedObservabilityTables()...)); err != nil { - return fmt.Errorf("register observability sharding failed: %w", err) - } - return nil -} - -func shardedObservabilityTables() []any { - return []any{ - &NodeMetricSnapshot{}, - &NodeRequestReport{}, - &NodeAccessLog{}, - &NodeObservationOpenresty{}, - &NodeObservationFrps{}, - &NodeObservationFrpc{}, - } -} - -func shardedObservabilityBaseTables() []string { - return []string{ - "node_metric_snapshots", - "node_request_reports", - "node_access_logs", - "node_observation_openresties", - "node_observation_frps", - "node_observation_frpcs", - } -} - -func isShardedObservabilityTable(tableName string) bool { - switch strings.TrimSpace(tableName) { - case "node_metric_snapshots", "node_request_reports", "node_access_logs", "node_observation_openresties", "node_observation_frps", "node_observation_frpcs": - return true - default: - return false - } -} - -func observabilityShardTables(baseTable string) []string { - tables := make([]string, 0, observabilityShardCount) - for _, suffix := range observabilityShardSuffixes() { - tables = append(tables, baseTable+suffix) - } - return tables -} - -func observabilityShardSuffixes() []string { - suffixes := make([]string, 0, observabilityShardCount) - for index := 0; index < observabilityShardCount; index++ { - suffixes = append(suffixes, fmt.Sprintf("_%02d", index)) - } - return suffixes -} - -func observabilityShardSuffixForID(id uint) string { - return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount)) -} - -func observabilityShardSuffixForInt64(id int64) string { - if id < 0 { - id = -id - } - return fmt.Sprintf("_%02d", uint64(id)%uint64(observabilityShardCount)) -} - -func observabilityShardSuffixForValue(value any) (string, error) { - switch typed := value.(type) { - case int: - return observabilityShardSuffixForInt64(int64(typed)), nil - case int8: - return observabilityShardSuffixForInt64(int64(typed)), nil - case int16: - return observabilityShardSuffixForInt64(int64(typed)), nil - case int32: - return observabilityShardSuffixForInt64(int64(typed)), nil - case int64: - return observabilityShardSuffixForInt64(typed), nil - case uint: - return observabilityShardSuffixForID(typed), nil - case uint8: - return observabilityShardSuffixForID(uint(typed)), nil - case uint16: - return observabilityShardSuffixForID(uint(typed)), nil - case uint32: - return observabilityShardSuffixForID(uint(typed)), nil - case uint64: - return fmt.Sprintf("_%02d", typed%uint64(observabilityShardCount)), nil - case string: - id, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64) - if err != nil { - return "", fmt.Errorf("invalid sharding id %q", typed) - } - return fmt.Sprintf("_%02d", id%uint64(observabilityShardCount)), nil - default: - return "", fmt.Errorf("unsupported observability sharding value type %T", value) - } -} - -func legacyObservabilityShardTableName(tableName string) string { - return tableName + "_legacy_v2_to_v3" -} - -func normalizeShardedDB(db *gorm.DB) *gorm.DB { - if db != nil { - return db - } - return DB -} - -func nextObservabilityID() (uint, error) { - observabilityIDNodeOnce.Do(func() { - observabilityIDNode, observabilityIDNodeErr = snowflake.NewNode(0) - }) - if observabilityIDNodeErr != nil { - return 0, observabilityIDNodeErr - } - id := observabilityIDNode.Generate().Int64() - if id <= 0 { - return 0, fmt.Errorf("generated invalid observability id %d", id) - } - return uint(id), nil -} - -func assignObservabilityID(id *uint) error { - if id == nil || *id != 0 { - return nil - } - generated, err := nextObservabilityID() - if err != nil { - return err - } - *id = generated - return nil -} - -func queryAcrossShards[T any](baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) { - return queryAcrossShardsWithDB(DB, baseTable, query) -} - -func queryAcrossShardsWithDB[T any](db *gorm.DB, baseTable string, query func(tx *gorm.DB) ([]T, error)) ([]T, error) { - items := make([]T, 0) - db = normalizeShardedDB(db) - for _, table := range observabilityShardTables(baseTable) { - rows, err := query(db.Table(table)) - if err != nil { - return nil, err - } - items = append(items, rows...) - } - return items, nil -} - -func deleteAcrossShards(db *gorm.DB, baseTable string, model any, apply func(tx *gorm.DB) *gorm.DB) (int64, error) { - db = normalizeShardedDB(db) - var deleted int64 - for _, table := range observabilityShardTables(baseTable) { - tx := db.Table(table) - if apply != nil { - tx = apply(tx) - } else { - tx = tx.Session(&gorm.Session{AllowGlobalUpdate: true}) - } - result := tx.Delete(model) - if result.Error != nil { - return deleted, result.Error - } - deleted += result.RowsAffected - } - return deleted, nil -} diff --git a/Wavelet/internal/model/system_configs.go b/openflare-server/internal/model/system_configs.go similarity index 100% rename from Wavelet/internal/model/system_configs.go rename to openflare-server/internal/model/system_configs.go diff --git a/Wavelet/internal/model/task_execution.go b/openflare-server/internal/model/task_execution.go similarity index 100% rename from Wavelet/internal/model/task_execution.go rename to openflare-server/internal/model/task_execution.go diff --git a/Wavelet/internal/model/task_execution_test.go b/openflare-server/internal/model/task_execution_test.go similarity index 100% rename from Wavelet/internal/model/task_execution_test.go rename to openflare-server/internal/model/task_execution_test.go diff --git a/Wavelet/internal/model/templates.go b/openflare-server/internal/model/templates.go similarity index 100% rename from Wavelet/internal/model/templates.go rename to openflare-server/internal/model/templates.go diff --git a/openflare-server/internal/model/tls_certificate.go b/openflare-server/internal/model/tls_certificate.go deleted file mode 100644 index 9a7f029b..00000000 --- a/openflare-server/internal/model/tls_certificate.go +++ /dev/null @@ -1,51 +0,0 @@ -package model - -import "time" - -type TLSCertificate struct { - ID uint `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"uniqueIndex;size:255;not null"` - CertPEM string `json:"-" gorm:"type:text;not null"` - KeyPEM string `json:"-" gorm:"type:text;not null"` - NotBefore time.Time `json:"not_before"` - NotAfter time.Time `json:"not_after"` - Remark string `json:"remark" gorm:"size:255"` - Provider string `json:"provider" gorm:"size:64;default:'upload'"` // upload, acme - AcmeAccountID uint `json:"acme_account_id"` - DnsAccountID uint `json:"dns_account_id"` - KeyAlgorithm string `json:"key_algorithm" gorm:"size:32"` - AutoRenew bool `json:"auto_renew"` - PrimaryDomain string `json:"primary_domain" gorm:"size:255"` - OtherDomains string `json:"other_domains" gorm:"type:text"` - DisableCNAME bool `json:"disable_cname"` - SkipDNS bool `json:"skip_dns"` - DNS1 string `json:"dns1" gorm:"size:128"` - DNS2 string `json:"dns2" gorm:"size:128"` - ApplyStatus string `json:"apply_status" gorm:"size:64;default:'ready'"` - ApplyMessage string `json:"apply_message" gorm:"type:text"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func ListTLSCertificates() (certificates []*TLSCertificate, err error) { - err = DB.Order("id desc").Find(&certificates).Error - return certificates, err -} - -func GetTLSCertificateByID(id uint) (*TLSCertificate, error) { - certificate := &TLSCertificate{} - err := DB.First(certificate, id).Error - return certificate, err -} - -func (certificate *TLSCertificate) Insert() error { - return DB.Create(certificate).Error -} - -func (certificate *TLSCertificate) Update() error { - return DB.Save(certificate).Error -} - -func (certificate *TLSCertificate) Delete() error { - return DB.Delete(certificate).Error -} diff --git a/Wavelet/internal/model/upload_stats.go b/openflare-server/internal/model/upload_stats.go similarity index 100% rename from Wavelet/internal/model/upload_stats.go rename to openflare-server/internal/model/upload_stats.go diff --git a/Wavelet/internal/model/uploads.go b/openflare-server/internal/model/uploads.go similarity index 100% rename from Wavelet/internal/model/uploads.go rename to openflare-server/internal/model/uploads.go diff --git a/openflare-server/internal/model/user.go b/openflare-server/internal/model/user.go deleted file mode 100644 index 77fc1e22..00000000 --- a/openflare-server/internal/model/user.go +++ /dev/null @@ -1,193 +0,0 @@ -package model - -import ( - "errors" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/utils/security" -) - -// User if you add sensitive fields, don't forget to clean them in setupLogin function. -// Otherwise, the sensitive information will be saved on local storage in plain text! -type User struct { - Id int `json:"id"` - Username string `json:"username" gorm:"unique;index" validate:"max=12"` - Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` - DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` - Role int `json:"role" gorm:"type:int;default:1"` // admin, common - Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled - Token string `json:"token" gorm:"index"` - Email string `json:"email" gorm:"index" validate:"max=50"` - GitHubId string `json:"github_id" gorm:"column:github_id;index"` - WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` - VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! -} - -func GetMaxUserId() int { - var user User - DB.Last(&user) - return user.Id -} - -func GetAllUsers(startIdx int, num int) (users []*User, err error) { - err = DB.Order("id desc").Limit(num).Offset(startIdx).Select([]string{"id", "username", "display_name", "role", "status", "email"}).Find(&users).Error - return users, err -} - -func SearchUsers(keyword string) (users []*User, err error) { - err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email"}).Where("id = ? or username LIKE ? or email LIKE ? or display_name LIKE ?", keyword, keyword+"%", keyword+"%", keyword+"%").Find(&users).Error - return users, err -} - -func GetUserById(id int, selectAll bool) (*User, error) { - if id == 0 { - return nil, errors.New("id 为空!") - } - user := User{Id: id} - var err error = nil - if selectAll { - err = DB.First(&user, "id = ?", id).Error - } else { - err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email", "wechat_id", "github_id"}).First(&user, "id = ?", id).Error - } - return &user, err -} - -func DeleteUserById(id int) (err error) { - if id == 0 { - return errors.New("id 为空!") - } - user := User{Id: id} - return user.Delete() -} - -func (user *User) Insert() error { - var err error - if user.Password != "" { - user.Password, err = security.Password2Hash(user.Password) - if err != nil { - return err - } - } - err = DB.Create(user).Error - return err -} - -func (user *User) Update(updatePassword bool) error { - var err error - if updatePassword { - user.Password, err = security.Password2Hash(user.Password) - if err != nil { - return err - } - } - err = DB.Model(user).Updates(user).Error - return err -} - -func (user *User) Delete() error { - if user.Id == 0 { - return errors.New("id 为空!") - } - err := DB.Delete(user).Error - return err -} - -// ValidateAndFill check password & user status -func (user *User) ValidateAndFill() (err error) { - // When querying with struct, GORM will only query with non-zero fields, - // that means if your field’s value is 0, '', false or other zero values, - // it won’t be used to build query conditions - password := user.Password - if user.Username == "" || password == "" { - return errors.New("用户名或密码为空") - } - DB.Where(User{Username: user.Username}).First(user) - okay := security.ValidatePasswordAndHash(password, user.Password) - if !okay || user.Status != common.UserStatusEnabled { - return errors.New("用户名或密码错误,或用户已被封禁") - } - return nil -} - -func (user *User) FillUserById() error { - if user.Id == 0 { - return errors.New("id 为空!") - } - DB.Where(User{Id: user.Id}).First(user) - return nil -} - -func (user *User) FillUserByEmail() error { - if user.Email == "" { - return errors.New("email 为空!") - } - DB.Where(User{Email: user.Email}).First(user) - return nil -} - -func (user *User) FillUserByGitHubId() error { - if user.GitHubId == "" { - return errors.New("GitHub id 为空!") - } - DB.Where(User{GitHubId: user.GitHubId}).First(user) - return nil -} - -func (user *User) FillUserByWeChatId() error { - if user.WeChatId == "" { - return errors.New("WeChat id 为空!") - } - DB.Where(User{WeChatId: user.WeChatId}).First(user) - return nil -} - -func (user *User) FillUserByUsername() error { - if user.Username == "" { - return errors.New("username 为空!") - } - DB.Where(User{Username: user.Username}).First(user) - return nil -} - -// ValidateUserToken looks up a user by their stored JWT token string. -// JWT signature verification is handled by middleware/auth.go; this -// function is used by Logout to find and clear the token from DB. -func ValidateUserToken(token string) (user *User) { - if token == "" { - return nil - } - user = &User{} - if DB.Where("token = ?", token).First(user).RowsAffected == 1 { - return user - } - return nil -} - -func IsEmailAlreadyTaken(email string) bool { - return DB.Where("email = ?", email).Find(&User{}).RowsAffected == 1 -} - -func IsWeChatIdAlreadyTaken(wechatId string) bool { - return DB.Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1 -} - -func IsGitHubIdAlreadyTaken(githubId string) bool { - return DB.Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1 -} - -func IsUsernameAlreadyTaken(username string) bool { - return DB.Where("username = ?", username).Find(&User{}).RowsAffected == 1 -} - -func ResetUserPasswordByEmail(email string, password string) error { - if email == "" || password == "" { - return errors.New("邮箱地址或密码为空!") - } - hashedPassword, err := security.Password2Hash(password) - if err != nil { - return err - } - err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error - return err -} diff --git a/Wavelet/internal/model/users.go b/openflare-server/internal/model/users.go similarity index 100% rename from Wavelet/internal/model/users.go rename to openflare-server/internal/model/users.go diff --git a/openflare-server/internal/model/waf.go b/openflare-server/internal/model/waf.go deleted file mode 100644 index f72bd595..00000000 --- a/openflare-server/internal/model/waf.go +++ /dev/null @@ -1,168 +0,0 @@ -package model - -import "time" - -type WAFRuleGroup struct { - ID uint `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:255;not null"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - IsGlobal bool `json:"is_global" gorm:"not null;default:false;index"` - BlockStatusCode int `json:"block_status_code" gorm:"not null;default:418"` - BlockResponseBody string `json:"block_response_body" gorm:"type:text;not null;default:''"` - IPWhitelist string `json:"ip_whitelist" gorm:"type:text;not null;default:'[]'"` - IPBlacklist string `json:"ip_blacklist" gorm:"type:text;not null;default:'[]'"` - IPWhitelistGroups string `json:"ip_whitelist_group_ids" gorm:"type:text;not null;default:'[]'"` - IPBlacklistGroups string `json:"ip_blacklist_group_ids" gorm:"type:text;not null;default:'[]'"` - CountryWhitelist string `json:"country_whitelist" gorm:"type:text;not null;default:'[]'"` - CountryBlacklist string `json:"country_blacklist" gorm:"type:text;not null;default:'[]'"` - RegionWhitelist string `json:"region_whitelist" gorm:"type:text;not null;default:'[]'"` - RegionBlacklist string `json:"region_blacklist" 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"` -} - -type WAFIPGroup struct { - ID uint `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:255;not null"` - Type string `json:"type" gorm:"size:32;not null;index"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - IPList string `json:"ip_list" gorm:"type:text;not null;default:'[]'"` - AutoConfig string `json:"auto_config" gorm:"type:text;not null;default:'{}'"` - ExtIPs string `json:"ext_ips" gorm:"type:text;not null;default:'[]'"` - SubscriptionURL string `json:"subscription_url" gorm:"size:2048;not null;default:''"` - SubscriptionFormat string `json:"subscription_format" gorm:"size:32;not null;default:'text'"` - SubscriptionMappingRule string `json:"subscription_mapping_rule" gorm:"size:255;not null;default:''"` - SyncIntervalMinutes int `json:"sync_interval_minutes" gorm:"not null;default:1440"` - LastSyncedAt *time.Time `json:"last_synced_at"` - NextSyncAt *time.Time `json:"next_sync_at" gorm:"index"` - LastSyncStatus string `json:"last_sync_status" gorm:"size:32;not null;default:''"` - LastSyncMessage string `json:"last_sync_message" 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"` -} - -type WAFRuleGroupBinding struct { - ID uint `json:"id" gorm:"primaryKey"` - RuleGroupID uint `json:"rule_group_id" gorm:"not null;uniqueIndex:idx_waf_group_route"` - ProxyRouteID uint `json:"proxy_route_id" gorm:"not null;uniqueIndex:idx_waf_group_route;index"` - CreatedAt time.Time `json:"created_at"` -} - -func ListWAFRuleGroups() ([]*WAFRuleGroup, error) { - var groups []*WAFRuleGroup - err := DB.Order("is_global desc").Order("id asc").Find(&groups).Error - return groups, err -} - -func GetWAFRuleGroupByID(id uint) (*WAFRuleGroup, error) { - group := &WAFRuleGroup{} - err := DB.First(group, id).Error - return group, err -} - -func GetGlobalWAFRuleGroup() (*WAFRuleGroup, error) { - group := &WAFRuleGroup{} - err := DB.Where("is_global = ?", true).Order("id asc").First(group).Error - return group, err -} - -func (group *WAFRuleGroup) Insert() error { - return DB.Create(group).Error -} - -func (group *WAFRuleGroup) Update() error { - return DB.Model(&WAFRuleGroup{}).Where("id = ?", group.ID).Updates(map[string]any{ - "name": group.Name, - "enabled": group.Enabled, - "is_global": group.IsGlobal, - "block_status_code": group.BlockStatusCode, - "block_response_body": group.BlockResponseBody, - "ip_whitelist": group.IPWhitelist, - "ip_blacklist": group.IPBlacklist, - "ip_whitelist_groups": group.IPWhitelistGroups, - "ip_blacklist_groups": group.IPBlacklistGroups, - "country_whitelist": group.CountryWhitelist, - "country_blacklist": group.CountryBlacklist, - "region_whitelist": group.RegionWhitelist, - "region_blacklist": group.RegionBlacklist, - "pow_enabled": group.PoWEnabled, - "pow_config": group.PoWConfig, - "remark": group.Remark, - }).Error -} - -func (group *WAFRuleGroup) Delete() error { - return DB.Delete(group).Error -} - -func ListWAFIPGroups() ([]*WAFIPGroup, error) { - var groups []*WAFIPGroup - err := DB.Order("type asc").Order("id asc").Find(&groups).Error - return groups, err -} - -func GetWAFIPGroupByID(id uint) (*WAFIPGroup, error) { - group := &WAFIPGroup{} - err := DB.First(group, id).Error - return group, err -} - -func ListWAFIPGroupsByIDs(ids []uint) ([]*WAFIPGroup, error) { - if len(ids) == 0 { - return []*WAFIPGroup{}, nil - } - var groups []*WAFIPGroup - err := DB.Where("id IN ?", ids).Order("id asc").Find(&groups).Error - return groups, err -} - -func ListDueWAFIPGroups(now time.Time) ([]*WAFIPGroup, error) { - var groups []*WAFIPGroup - err := DB.Where("enabled = ? AND (type = ? OR (type = ? AND subscription_url <> '')) AND (next_sync_at IS NULL OR next_sync_at <= ?)", true, "automatic", "subscription", now). - Order("id asc"). - Find(&groups).Error - return groups, err -} - -func (group *WAFIPGroup) Insert() error { - return DB.Create(group).Error -} - -func (group *WAFIPGroup) Update() error { - return DB.Model(&WAFIPGroup{}).Where("id = ?", group.ID).Updates(map[string]any{ - "name": group.Name, - "type": group.Type, - "enabled": group.Enabled, - "ip_list": group.IPList, - "auto_config": group.AutoConfig, - "ext_ips": group.ExtIPs, - "subscription_url": group.SubscriptionURL, - "subscription_format": group.SubscriptionFormat, - "subscription_mapping_rule": group.SubscriptionMappingRule, - "sync_interval_minutes": group.SyncIntervalMinutes, - "next_sync_at": group.NextSyncAt, - "last_sync_status": group.LastSyncStatus, - "last_sync_message": group.LastSyncMessage, - "remark": group.Remark, - }).Error -} - -func (group *WAFIPGroup) UpdateSyncResult() error { - return DB.Model(&WAFIPGroup{}).Where("id = ?", group.ID).Updates(map[string]any{ - "ip_list": group.IPList, - "ext_ips": group.ExtIPs, - "last_synced_at": group.LastSyncedAt, - "next_sync_at": group.NextSyncAt, - "last_sync_status": group.LastSyncStatus, - "last_sync_message": group.LastSyncMessage, - "subscription_format": group.SubscriptionFormat, - }).Error -} - -func (group *WAFIPGroup) Delete() error { - return DB.Delete(group).Error -} diff --git a/Wavelet/internal/repository/push_channel.go b/openflare-server/internal/repository/push_channel.go similarity index 100% rename from Wavelet/internal/repository/push_channel.go rename to openflare-server/internal/repository/push_channel.go diff --git a/Wavelet/internal/repository/push_event.go b/openflare-server/internal/repository/push_event.go similarity index 100% rename from Wavelet/internal/repository/push_event.go rename to openflare-server/internal/repository/push_event.go diff --git a/Wavelet/internal/repository/push_history.go b/openflare-server/internal/repository/push_history.go similarity index 100% rename from Wavelet/internal/repository/push_history.go rename to openflare-server/internal/repository/push_history.go diff --git a/Wavelet/internal/repository/system_config.go b/openflare-server/internal/repository/system_config.go similarity index 100% rename from Wavelet/internal/repository/system_config.go rename to openflare-server/internal/repository/system_config.go diff --git a/Wavelet/internal/repository/system_config_admin.go b/openflare-server/internal/repository/system_config_admin.go similarity index 100% rename from Wavelet/internal/repository/system_config_admin.go rename to openflare-server/internal/repository/system_config_admin.go diff --git a/Wavelet/internal/repository/system_config_cache.go b/openflare-server/internal/repository/system_config_cache.go similarity index 100% rename from Wavelet/internal/repository/system_config_cache.go rename to openflare-server/internal/repository/system_config_cache.go diff --git a/Wavelet/internal/repository/template.go b/openflare-server/internal/repository/template.go similarity index 100% rename from Wavelet/internal/repository/template.go rename to openflare-server/internal/repository/template.go diff --git a/Wavelet/internal/repository/upload.go b/openflare-server/internal/repository/upload.go similarity index 100% rename from Wavelet/internal/repository/upload.go rename to openflare-server/internal/repository/upload.go diff --git a/Wavelet/internal/repository/upload_stat.go b/openflare-server/internal/repository/upload_stat.go similarity index 100% rename from Wavelet/internal/repository/upload_stat.go rename to openflare-server/internal/repository/upload_stat.go diff --git a/Wavelet/internal/repository/user.go b/openflare-server/internal/repository/user.go similarity index 100% rename from Wavelet/internal/repository/user.go rename to openflare-server/internal/repository/user.go diff --git a/openflare-server/internal/router/api-router.go b/openflare-server/internal/router/api-router.go deleted file mode 100644 index 74f32aef..00000000 --- a/openflare-server/internal/router/api-router.go +++ /dev/null @@ -1,271 +0,0 @@ -package router - -import ( - "github.com/rain-kl/openflare/openflare-server/internal/controller" - "github.com/rain-kl/openflare/openflare-server/internal/middleware" - - "github.com/gin-gonic/gin" -) - -func SetApiRouter(router *gin.Engine) { - apiRouter := router.Group("/api") - apiRouter.Use(middleware.GlobalAPIRateLimit()) - { - apiRouter.GET("/status", controller.GetStatus) - apiRouter.GET("/notice", controller.GetNotice) - apiRouter.GET("/about", controller.GetAbout) - apiRouter.GET("/verification", middleware.CriticalRateLimit(), controller.SendEmailVerification) - apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), controller.SendPasswordResetEmail) - apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) - apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth) - apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) - apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind) - apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind) - apiRouter.GET("/oauth/:source/authorize", middleware.CriticalRateLimit(), controller.OAuthAuthorize) - apiRouter.GET("/oauth/:source/callback", middleware.CriticalRateLimit(), controller.OAuthCallback) - apiRouter.POST("/oauth/link-existing", middleware.CriticalRateLimit(), controller.LinkExistingOAuthAccount) - externalAccountRoute := apiRouter.Group("/oauth/external-accounts") - externalAccountRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth()) - { - externalAccountRoute.GET("/", controller.ListExternalAccounts) - externalAccountRoute.POST("/:id/delete", controller.DeleteExternalAccount) - } - - capRoute := apiRouter.Group("/cap") - { - capRoute.POST("/:scope/challenge", middleware.CriticalRateLimit(), controller.GetCapChallenge) - capRoute.POST("/:scope/redeem", middleware.CriticalRateLimit(), controller.RedeemCapChallenge) - } - - userRoute := apiRouter.Group("/user") - { - userRoute.POST("/register", middleware.CriticalRateLimit(), controller.Register) - userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.CapAuth("login"), controller.Login) - userRoute.GET("/logout", controller.Logout) - - selfRoute := userRoute.Group("/") - selfRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth()) - { - selfRoute.GET("/self", controller.GetSelf) - selfRoute.POST("/self/update", controller.UpdateSelf) - selfRoute.POST("/self/delete", controller.DeleteSelf) - selfRoute.GET("/token", controller.GenerateToken) - } - - adminRoute := userRoute.Group("/") - adminRoute.Use(middleware.AdminAuth(), middleware.NoTokenAuth()) - { - adminRoute.GET("/", controller.GetAllUsers) - adminRoute.GET("/search", controller.SearchUsers) - adminRoute.GET("/:id", controller.GetUser) - adminRoute.POST("/", controller.CreateUser) - adminRoute.POST("/manage", controller.ManageUser) - adminRoute.POST("/update", controller.UpdateUser) - adminRoute.POST("/:id/delete", controller.DeleteUser) - } - } - optionRoute := apiRouter.Group("/option") - optionRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) - { - 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) - } - uptimekumaRoute := apiRouter.Group("/uptimekuma") - uptimekumaRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) - { - uptimekumaRoute.POST("/sync", controller.SyncUptimeKuma) - } - authSourceRoute := apiRouter.Group("/auth-sources") - authSourceRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) - { - authSourceRoute.GET("/", controller.ListAuthSources) - authSourceRoute.POST("/", controller.CreateAuthSource) - authSourceRoute.POST("/:id/update", controller.UpdateAuthSource) - authSourceRoute.POST("/:id/delete", controller.DeleteAuthSource) - authSourceRoute.POST("/:id/toggle", controller.ToggleAuthSource) - } - updateRoute := apiRouter.Group("/update") - updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) - { - updateRoute.GET("/latest-release", controller.GetLatestRelease) - updateRoute.GET("/logs/ws", controller.StreamServerUpgradeLogs) - updateRoute.POST("/manual-upload", controller.UploadManualServerBinary) - updateRoute.POST("/manual-upgrade", controller.ConfirmManualServerUpgrade) - updateRoute.POST("/upgrade", controller.UpgradeServer) - } - proxyRoute := apiRouter.Group("/proxy-routes") - 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) - } - wafRoute := apiRouter.Group("/waf") - wafRoute.Use(middleware.AdminAuth()) - { - wafRoute.GET("/ip-groups", controller.ListWAFIPGroups) - wafRoute.GET("/ip-groups/:id", controller.GetWAFIPGroup) - wafRoute.POST("/ip-groups", controller.CreateWAFIPGroup) - wafRoute.POST("/ip-groups/test", controller.TestWAFIPGroupAutoConfig) - wafRoute.POST("/ip-groups/:id/update", controller.UpdateWAFIPGroup) - wafRoute.POST("/ip-groups/:id/delete", controller.DeleteWAFIPGroup) - wafRoute.POST("/ip-groups/:id/sync", controller.SyncWAFIPGroup) - wafRoute.GET("/rule-groups", controller.ListWAFRuleGroups) - wafRoute.GET("/rule-groups/:id", controller.GetWAFRuleGroup) - wafRoute.POST("/rule-groups", controller.CreateWAFRuleGroup) - wafRoute.POST("/rule-groups/:id/update", controller.UpdateWAFRuleGroup) - wafRoute.POST("/rule-groups/:id/delete", controller.DeleteWAFRuleGroup) - wafRoute.POST("/rule-groups/:id/sites", controller.ReplaceWAFRuleGroupSites) - wafRoute.GET("/sites/:route_id/rule-groups", controller.GetWAFSiteRuleGroups) - wafRoute.POST("/sites/:route_id/rule-groups", controller.ReplaceWAFSiteRuleGroups) - } - originRoute := apiRouter.Group("/origins") - originRoute.Use(middleware.AdminAuth()) - { - originRoute.GET("/", controller.GetOrigins) - originRoute.GET("/:id", controller.GetOrigin) - originRoute.POST("/", controller.CreateOrigin) - originRoute.POST("/:id/update", controller.UpdateOrigin) - originRoute.POST("/:id/delete", controller.DeleteOrigin) - } - pagesRoute := apiRouter.Group("/pages") - pagesRoute.Use(middleware.AdminAuth()) - { - pagesRoute.GET("/", controller.ListPagesProjects) - pagesRoute.GET("/:id", controller.GetPagesProject) - pagesRoute.POST("/", controller.CreatePagesProject) - pagesRoute.POST("/:id/update", controller.UpdatePagesProject) - pagesRoute.POST("/:id/delete", controller.DeletePagesProject) - pagesRoute.GET("/:id/deployments", controller.ListPagesDeployments) - pagesRoute.POST("/:id/deployments/upload", controller.UploadPagesDeployment) - pagesRoute.POST("/:id/deployments/:deployment_id/activate", controller.ActivatePagesDeployment) - pagesRoute.POST("/:id/deployments/:deployment_id/delete", controller.DeletePagesDeployment) - pagesRoute.GET("/deployments/:deployment_id/files", controller.ListPagesDeploymentFiles) - } - managedDomainRoute := apiRouter.Group("/managed-domains") - managedDomainRoute.Use(middleware.AdminAuth()) - { - managedDomainRoute.GET("/", controller.GetManagedDomains) - managedDomainRoute.GET("/match", controller.MatchManagedDomainCertificate) - managedDomainRoute.POST("/", controller.CreateManagedDomain) - managedDomainRoute.POST("/:id/update", controller.UpdateManagedDomain) - managedDomainRoute.POST("/:id/delete", controller.DeleteManagedDomain) - } - tlsCertificateRoute := apiRouter.Group("/tls-certificates") - tlsCertificateRoute.Use(middleware.AdminAuth()) - { - tlsCertificateRoute.GET("/", controller.GetTLSCertificates) - tlsCertificateRoute.GET("/:id", controller.GetTLSCertificate) - tlsCertificateRoute.GET("/:id/content", controller.GetTLSCertificateContent) - tlsCertificateRoute.POST("/", controller.CreateTLSCertificate) - tlsCertificateRoute.POST("/:id/update", controller.UpdateTLSCertificate) - tlsCertificateRoute.POST("/:id/update-acme", controller.UpdateAcmeCertificate) - tlsCertificateRoute.POST("/:id/convert-acme", controller.ConvertTLSCertificateToAcme) - tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile) - tlsCertificateRoute.POST("/:id/delete", controller.DeleteTLSCertificate) - tlsCertificateRoute.POST("/apply", controller.ApplyTLSCertificate) - tlsCertificateRoute.POST("/:id/renew", controller.RenewTLSCertificate) - } - acmeAccountRoute := apiRouter.Group("/acme-accounts") - acmeAccountRoute.Use(middleware.AdminAuth()) - { - acmeAccountRoute.GET("/default", controller.GetDefaultAcmeAccount) - } - dnsAccountRoute := apiRouter.Group("/dns-accounts") - dnsAccountRoute.Use(middleware.AdminAuth()) - { - dnsAccountRoute.GET("/", controller.GetDnsAccounts) - dnsAccountRoute.POST("/", controller.CreateDnsAccount) - dnsAccountRoute.POST("/:id/update", controller.UpdateDnsAccount) - dnsAccountRoute.POST("/:id/delete", controller.DeleteDnsAccount) - } - configVersionRoute := apiRouter.Group("/config-versions") - configVersionRoute.Use(middleware.AdminAuth()) - { - configVersionRoute.GET("/", controller.GetConfigVersions) - configVersionRoute.GET("/active", controller.GetActiveConfigVersion) - configVersionRoute.GET("/preview", controller.PreviewConfigVersion) - configVersionRoute.GET("/diff", controller.DiffConfigVersion) - configVersionRoute.GET("/:id", controller.GetConfigVersion) - configVersionRoute.POST("/publish", controller.PublishConfigVersion) - configVersionRoute.POST("/:id/activate", controller.ActivateConfigVersion) - configVersionRoute.POST("/cleanup", controller.CleanupConfigVersions) - } - dashboardRoute := apiRouter.Group("/dashboard") - dashboardRoute.Use(middleware.AdminAuth()) - { - dashboardRoute.GET("/overview", controller.GetDashboardOverview) - } - nodeRoute := apiRouter.Group("/nodes") - nodeRoute.Use(middleware.AdminAuth()) - { - nodeRoute.GET("/bootstrap-token", controller.GetNodeBootstrapToken) - nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken) - nodeRoute.GET("/", controller.GetNodes) - nodeRoute.POST("/", controller.CreateNode) - nodeRoute.GET("/:id/agent-release", controller.GetNodeAgentRelease) - nodeRoute.POST("/:id/update", controller.UpdateNode) - nodeRoute.POST("/:id/delete", controller.DeleteNode) - nodeRoute.POST("/:id/agent-update", controller.RequestNodeAgentUpdate) - nodeRoute.POST("/:id/openresty-restart", controller.RequestNodeOpenrestyRestart) - nodeRoute.POST("/:id/force-sync", controller.RequestNodeForceSync) - nodeRoute.GET("/:id/observability", controller.GetNodeObservability) - nodeRoute.POST("/:id/observability/cleanup", controller.CleanupNodeHealthEvents) - } - applyLogRoute := apiRouter.Group("/apply-logs") - applyLogRoute.Use(middleware.AdminAuth()) - { - applyLogRoute.GET("/", controller.GetApplyLogs) - applyLogRoute.POST("/cleanup", controller.CleanupApplyLogs) - } - - accessLogRoute := apiRouter.Group("/access-logs") - accessLogRoute.Use(middleware.AdminAuth()) - { - accessLogRoute.GET("/", controller.GetAccessLogs) - accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs) - accessLogRoute.GET("/folds/ip-summary", controller.GetFoldedAccessLogIPs) - accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries) - accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend) - accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs) - } - agentRoute := apiRouter.Group("/agent") - { - discoveryRoute := agentRoute.Group("/") - discoveryRoute.Use(middleware.AgentRegisterAuth()) - { - discoveryRoute.POST("/nodes/register", controller.AgentRegister) - } - authorizedRoute := agentRoute.Group("/") - authorizedRoute.Use(middleware.AgentAuth()) - { - authorizedRoute.GET("/ws", controller.AgentWebSocket) - authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat) - authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig) - authorizedRoute.GET("/pages/deployments/:deployment_id/package", controller.AgentDownloadPagesDeploymentPackage) - authorizedRoute.POST("/waf/ip-groups/sync", controller.AgentSyncWAFIPGroups) - authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog) - } - } - relayRoute := apiRouter.Group("/relay") - relayRoute.Use(middleware.RelayAuth()) - { - relayRoute.POST("/heartbeat", controller.RelayHeartbeat) - relayRoute.GET("/ws", controller.RelayWebSocket) - } - flaredRoute := apiRouter.Group("/flared") - flaredRoute.Use(middleware.TunnelAuth()) - { - flaredRoute.POST("/heartbeat", controller.FlaredHeartbeat) - flaredRoute.GET("/config/active", controller.FlaredGetActiveConfig) - flaredRoute.POST("/apply-log", controller.FlaredReportApplyLog) - flaredRoute.GET("/ws", controller.FlaredWebSocket) - } - - } -} diff --git a/openflare-server/internal/router/api_flared_test.go b/openflare-server/internal/router/api_flared_test.go deleted file mode 100644 index 2a9633f5..00000000 --- a/openflare-server/internal/router/api_flared_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package router_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/router" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" -) - -func TestPhaseFlaredRoutesUnauthorized(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) - - heartbeatReq := httptest.NewRequest(http.MethodPost, "/api/flared/heartbeat", bytes.NewReader([]byte(`{}`))) - heartbeatReq.Header.Set("Content-Type", "application/json") - heartbeatRec := httptest.NewRecorder() - engine.ServeHTTP(heartbeatRec, heartbeatReq) - if heartbeatRec.Code != http.StatusUnauthorized { - t.Fatalf("expected unauthorized status for missing token, got %d body=%s", heartbeatRec.Code, heartbeatRec.Body.String()) - } - - activeReq := httptest.NewRequest(http.MethodGet, "/api/flared/config/active", nil) - activeRec := httptest.NewRecorder() - engine.ServeHTTP(activeRec, activeReq) - if activeRec.Code != http.StatusUnauthorized { - t.Fatalf("expected unauthorized status for missing token on active config, got %d", activeRec.Code) - } - - applyReq := httptest.NewRequest(http.MethodPost, "/api/flared/apply-log", bytes.NewReader([]byte(`{}`))) - applyReq.Header.Set("Content-Type", "application/json") - applyRec := httptest.NewRecorder() - engine.ServeHTTP(applyRec, applyReq) - if applyRec.Code != http.StatusUnauthorized { - t.Fatalf("expected unauthorized status for missing token on apply log, got %d", applyRec.Code) - } -} - -func TestPhaseFlaredRoutesRejectWrongNodeType(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) - - adminToken := prepareRootToken(t) - createNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{ - "name": "edge-for-flared-test", - "ip": "10.0.0.20", - }) - var createdNode service.NodeView - decodeResponseData(t, createNodeResp, &createdNode) - - heartbeatReq := httptest.NewRequest(http.MethodPost, "/api/flared/heartbeat", bytes.NewReader([]byte(`{}`))) - heartbeatReq.Header.Set("Content-Type", "application/json") - heartbeatReq.Header.Set("X-Tunnel-Token", createdNode.AccessToken) - heartbeatRec := httptest.NewRecorder() - engine.ServeHTTP(heartbeatRec, heartbeatReq) - if heartbeatRec.Code != http.StatusForbidden { - t.Fatalf("expected forbidden status for edge_node token, got %d body=%s", heartbeatRec.Code, heartbeatRec.Body.String()) - } -} - -func TestPhaseFlaredLifecycle(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) - - adminToken := prepareRootToken(t) - - // Create an enabled proxy route that will be served to the flared client - // through the tunnel upstream flow. - createRouteAndPublishVersion(t, engine, adminToken) - - // Seed a tunnel_client node directly so we can use its access token as the - // tunnel_token when calling the flared endpoints. - tunnelNode := &model.Node{ - NodeID: "tun-flared-1", - Name: "office-flared-1", - IP: "192.168.10.20", - AccessToken: "tunnel-token-phase", - Status: service.NodeStatusPending, - NodeType: "tunnel_client", - Version: "", - } - if err := tunnelNode.Insert(); err != nil { - t.Fatalf("failed to seed tunnel client node: %v", err) - } - - heartbeatResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodPost, "/api/flared/heartbeat", map[string]any{ - "client_version": "v0.2.0", - "frp_version": "0.61.0", - "tunnel_status": "running", - "current_version": "", - }) - if !heartbeatResp.Success { - t.Fatalf("flared heartbeat failed: %s", heartbeatResp.Message) - } - var heartbeatData service.FlaredHeartbeatResponse - if err := json.Unmarshal(heartbeatResp.Data, &heartbeatData); err != nil { - t.Fatalf("failed to decode flared heartbeat response: %v", err) - } - if heartbeatData.ActiveConfig == nil { - t.Fatal("expected heartbeat to return active config summary") - } - if heartbeatData.TunnelSettings == nil { - t.Fatal("expected heartbeat to return tunnel_settings") - } - - // Re-fetch node and assert status flipped to online. - updated, err := model.GetNodeByNodeID(tunnelNode.NodeID) - if err != nil { - t.Fatalf("failed to reload flared node: %v", err) - } - if updated.Status != service.NodeStatusOnline { - t.Fatalf("expected flared node status to be online, got %q", updated.Status) - } - if updated.Version != "v0.2.0" { - t.Fatalf("expected flared client_version to be stored, got %q", updated.Version) - } - - activeResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodGet, "/api/flared/config/active", nil) - if !activeResp.Success { - t.Fatalf("flared get active config failed: %s", activeResp.Message) - } - var activeConfig service.FlaredTunnelConfigResponse - if err := json.Unmarshal(activeResp.Data, &activeConfig); err != nil { - t.Fatalf("failed to decode flared active config: %v", err) - } - if activeConfig.Version == "" || activeConfig.Checksum == "" { - t.Fatalf("expected flared active config to return version summary, got %+v", activeConfig) - } - - applyResp := performFlaredJSONRequest(t, engine, tunnelNode.AccessToken, http.MethodPost, "/api/flared/apply-log", map[string]any{ - "version": activeConfig.Version, - "result": service.ApplyResultOK, - "message": "apply ok", - "checksum": activeConfig.Checksum, - }) - if !applyResp.Success { - t.Fatalf("flared apply log failed: %s", applyResp.Message) - } -} - -func performFlaredJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { - t.Helper() - var payload []byte - if body != nil { - var err error - payload, err = json.Marshal(body) - if err != nil { - t.Fatalf("failed to marshal request body: %v", err) - } - } - req := httptest.NewRequest(method, path, bytes.NewReader(payload)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("X-Tunnel-Token", token) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, 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.Fatalf("request %s %s failed: %s", method, path, resp.Message) - } - return resp -} diff --git a/openflare-server/internal/router/api_phase1_test.go b/openflare-server/internal/router/api_phase1_test.go deleted file mode 100644 index 137c2b9f..00000000 --- a/openflare-server/internal/router/api_phase1_test.go +++ /dev/null @@ -1,649 +0,0 @@ -package router_test - -import ( - "bytes" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" - "errors" - "math/big" - "mime/multipart" - "net/http" - "net/http/httptest" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/middleware" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/router" - "github.com/rain-kl/openflare/openflare-server/internal/service" -) - -type apiResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Data json.RawMessage `json:"data"` -} - -func TestPhase1PublishLifecycle(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) - - createBody := map[string]any{ - "domain": "app.example.com", - "origin_url": "https://10.0.0.11:8443", - "upstreams": []string{"https://10.0.0.12:8443"}, - "origin_host": "origin-a.internal", - "enabled": true, - "cache_enabled": true, - "cache_policy": "path_prefix", - "cache_rules": []string{"/assets", "/static"}, - "remark": "primary route", - } - resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody) - var createdRoute service.ProxyRouteView - decodeResponseData(t, resp, &createdRoute) - if createdRoute.Domain != "app.example.com" { - t.Fatalf("unexpected created route domain: %s", createdRoute.Domain) - } - if createdRoute.OriginHost != "origin-a.internal" { - t.Fatalf("unexpected created route origin host: %s", createdRoute.OriginHost) - } - if !createdRoute.CacheEnabled || createdRoute.CachePolicy != "path_prefix" { - t.Fatalf("expected route cache settings to persist, got %+v", createdRoute) - } - if !strings.Contains(createdRoute.Upstreams, "10.0.0.12:8443") { - t.Fatalf("expected route upstream list to persist, got %s", createdRoute.Upstreams) - } - if !strings.Contains(createdRoute.CacheRules, "/assets") { - t.Fatalf("expected route cache rules to persist, got %s", createdRoute.CacheRules) - } - - resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil) - var routes []service.ProxyRouteView - decodeResponseData(t, resp, &routes) - if len(routes) != 1 { - t.Fatalf("expected 1 route, got %d", len(routes)) - } - - resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) - var version1 model.ConfigVersion - decodeResponseData(t, resp, &version1) - if !version1.IsActive { - t.Fatal("expected published version to be active") - } - if version1.SnapshotJSON == "" || version1.RenderedConfig == "" || version1.Checksum == "" { - t.Fatal("expected published version to contain snapshot, rendered config and checksum") - } - if version1.MainConfig == "" { - t.Fatal("expected published version to contain main config") - } - - repeatPublishReq := httptest.NewRequest(http.MethodPost, "/api/config-versions/publish", nil) - repeatPublishReq.Header.Set("OpenFlare-Token", token) - repeatPublishRecorder := httptest.NewRecorder() - engine.ServeHTTP(repeatPublishRecorder, repeatPublishReq) - if repeatPublishRecorder.Code != http.StatusOK { - t.Fatalf("unexpected status %d for repeated publish: %s", repeatPublishRecorder.Code, repeatPublishRecorder.Body.String()) - } - var repeatPublishResp apiResponse - if err := json.Unmarshal(repeatPublishRecorder.Body.Bytes(), &repeatPublishResp); err != nil { - t.Fatalf("failed to unmarshal repeated publish response: %v", err) - } - if repeatPublishResp.Success { - t.Fatal("expected repeated publish without route changes to be rejected") - } - if !strings.Contains(repeatPublishResp.Message, "当前规则没有变更") { - t.Fatalf("unexpected repeated publish message: %s", repeatPublishResp.Message) - } - - initialSnapshot := version1.SnapshotJSON - initialMainConfig := version1.MainConfig - initialRendered := version1.RenderedConfig - - updateBody := map[string]any{ - "domain": "app.example.com", - "origin_url": "https://10.0.0.21:8443", - "upstreams": []string{"https://10.0.0.22:8443"}, - "origin_host": "origin-b.internal", - "enabled": true, - "cache_enabled": true, - "cache_policy": "path_exact", - "cache_rules": []string{"/robots.txt"}, - "remark": "updated route", - } - routePath := "/api/proxy-routes/" + toString(createdRoute.ID) - resp = performJSONRequest(t, engine, token, http.MethodPost, routePath+"/update", updateBody) - decodeResponseData(t, resp, &createdRoute) - if createdRoute.OriginURL != "https://10.0.0.21:8443" { - t.Fatalf("unexpected updated route origin: %s", createdRoute.OriginURL) - } - if createdRoute.OriginHost != "origin-b.internal" { - t.Fatalf("unexpected updated route origin host: %s", createdRoute.OriginHost) - } - if createdRoute.CachePolicy != "path_exact" || !strings.Contains(createdRoute.CacheRules, "/robots.txt") { - t.Fatalf("expected updated route cache rules to persist, got %+v", createdRoute) - } - if !strings.Contains(createdRoute.Upstreams, "10.0.0.22:8443") { - t.Fatalf("expected updated route upstream list to persist, got %s", createdRoute.Upstreams) - } - - resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) - var version2 model.ConfigVersion - decodeResponseData(t, resp, &version2) - if version2.ID == version1.ID { - t.Fatal("expected a new version record") - } - - resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/", nil) - var versions []map[string]any - decodeResponseData(t, resp, &versions) - if len(versions) != 2 { - t.Fatalf("expected 2 versions, got %d", len(versions)) - } - if _, ok := versions[0]["snapshot_json"]; ok { - t.Fatal("expected config version list to omit snapshot_json") - } - if _, ok := versions[0]["main_config"]; ok { - t.Fatal("expected config version list to omit main_config") - } - if _, ok := versions[0]["rendered_config"]; ok { - t.Fatal("expected config version list to omit rendered_config") - } - if _, ok := versions[0]["support_files_json"]; ok { - t.Fatal("expected config version list to omit support_files_json") - } - - detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/"+toString(version2.ID), nil) - var versionDetail model.ConfigVersion - decodeResponseData(t, detailResp, &versionDetail) - if versionDetail.ID != version2.ID { - t.Fatalf("expected config version detail %d, got %d", version2.ID, versionDetail.ID) - } - if versionDetail.SnapshotJSON == "" || versionDetail.MainConfig == "" || versionDetail.RenderedConfig == "" { - t.Fatal("expected config version detail endpoint to include full payload") - } - - activeResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/active", nil) - var activeVersion model.ConfigVersion - decodeResponseData(t, activeResp, &activeVersion) - if activeVersion.ID != version2.ID { - t.Fatalf("expected version %d active, got %d", version2.ID, activeVersion.ID) - } - - activatePath := "/api/config-versions/" + toString(version1.ID) + "/activate" - resp = performJSONRequest(t, engine, token, http.MethodPost, activatePath, nil) - decodeResponseData(t, resp, &activeVersion) - if activeVersion.ID != version1.ID || !activeVersion.IsActive { - t.Fatal("expected version1 to become active after rollback activation") - } - - var storedVersion1 model.ConfigVersion - if err := model.DB.First(&storedVersion1, version1.ID).Error; err != nil { - t.Fatalf("failed to query version1: %v", err) - } - if storedVersion1.SnapshotJSON != initialSnapshot { - t.Fatal("expected version1 snapshot to remain immutable") - } - if storedVersion1.MainConfig != initialMainConfig { - t.Fatal("expected version1 main config to remain immutable") - } - if storedVersion1.RenderedConfig != initialRendered { - t.Fatal("expected version1 rendered config to remain immutable") - } - - deletePath := "/api/proxy-routes/" + toString(createdRoute.ID) - resp = performJSONRequest(t, engine, token, http.MethodPost, deletePath+"/delete", nil) - if !resp.Success { - t.Fatalf("expected delete route success, got: %s", resp.Message) - } -} - -func TestPhase1HTTPSAndCertificateImportLifecycle(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) - certPEM, keyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com"}) - - manualResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{ - "name": "secure-example", - "cert_pem": certPEM, - "key_pem": keyPEM, - "remark": "manual import", - }) - var manualCertificate model.TLSCertificate - decodeResponseData(t, manualResp, &manualCertificate) - if manualCertificate.ID == 0 { - t.Fatal("expected manual certificate import to persist certificate") - } - - detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/"+toString(manualCertificate.ID), nil) - var certificateDetail map[string]any - decodeResponseData(t, detailResp, &certificateDetail) - if _, exists := certificateDetail["cert_pem"]; exists { - t.Fatal("expected certificate detail endpoint to omit cert_pem") - } - if _, exists := certificateDetail["key_pem"]; exists { - t.Fatal("expected certificate detail endpoint to omit key_pem") - } - - contentResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/"+toString(manualCertificate.ID)+"/content", nil) - var certificateContent map[string]any - decodeResponseData(t, contentResp, &certificateContent) - if certificateContent["cert_pem"] == "" || certificateContent["key_pem"] == "" { - t.Fatal("expected certificate content endpoint to return pem payloads") - } - - updatedCertPEM, updatedKeyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com", "www.secure.example.com"}) - updateCertificateResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(manualCertificate.ID)+"/update", map[string]any{ - "name": "secure-example-updated", - "cert_pem": updatedCertPEM, - "key_pem": updatedKeyPEM, - "remark": "updated manual import", - }) - decodeResponseData(t, updateCertificateResp, &manualCertificate) - if manualCertificate.Name != "secure-example-updated" || manualCertificate.Remark != "updated manual import" { - t.Fatalf("expected certificate update to persist metadata, got %+v", manualCertificate) - } - - fileCertPEM, fileKeyPEM := generateCertificatePairForRouterTest(t, []string{"upload.example.com"}) - multipartResp := performMultipartRequest(t, engine, token, "/api/tls-certificates/import-file", map[string]string{ - "name": "upload-example", - "remark": "upload import", - }, map[string]string{ - "cert_file": fileCertPEM, - "key_file": fileKeyPEM, - }) - var uploadedCertificate model.TLSCertificate - decodeResponseData(t, multipartResp, &uploadedCertificate) - if uploadedCertificate.ID == 0 { - t.Fatal("expected file certificate import to persist certificate") - } - - resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{ - "domain": "secure.example.com", - "origin_url": "https://origin-secure.internal", - "enabled": true, - "enable_https": true, - "cert_id": manualCertificate.ID, - "redirect_http": true, - "remark": "https route", - }) - 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") - } - - updateResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/"+toString(route.ID)+"/update", map[string]any{ - "domain": "secure.example.com", - "origin_url": "http://origin-secure.internal", - "enabled": true, - "enable_https": false, - "cert_id": nil, - "redirect_http": false, - "remark": "downgraded route", - }) - decodeResponseData(t, updateResp, &route) - if route.EnableHTTPS || route.CertID != nil || route.RedirectHTTP { - t.Fatalf("expected route to disable https flags, got %+v", route) - } - - updateResp = performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/"+toString(route.ID)+"/update", map[string]any{ - "domain": "secure.example.com", - "origin_url": "https://origin-secure.internal", - "enabled": true, - "enable_https": true, - "cert_id": manualCertificate.ID, - "redirect_http": true, - "remark": "re-enabled https route", - }) - decodeResponseData(t, updateResp, &route) - if !route.EnableHTTPS || route.CertID == nil || *route.CertID != manualCertificate.ID || !route.RedirectHTTP { - t.Fatalf("expected route update to persist https fields, got %+v", route) - } - - 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].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) - } - - certificateListResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/", nil) - var certificateList []map[string]any - decodeResponseData(t, certificateListResp, &certificateList) - if len(certificateList) == 0 { - t.Fatal("expected certificate list to return records") - } - if _, exists := certificateList[0]["cert_pem"]; exists { - t.Fatal("expected certificate list to omit cert_pem") - } - if _, exists := certificateList[0]["key_pem"]; exists { - t.Fatal("expected certificate list to omit key_pem") - } - - resp = performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil) - var version model.ConfigVersion - decodeResponseData(t, resp, &version) - if !strings.Contains(version.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") { - t.Fatal("expected active config to render managed main config") - } - if !strings.Contains(version.RenderedConfig, "listen 443 ssl;") { - t.Fatal("expected active config to render https ssl listener") - } - if !strings.Contains(version.RenderedConfig, "http2 on;") { - t.Fatal("expected active config to render dedicated http2 directive") - } - if !strings.Contains(version.RenderedConfig, "return 301 https://$host$request_uri;") { - t.Fatal("expected active config to render redirect server") - } - if !strings.Contains(version.SupportFilesJSON, ".crt") || !strings.Contains(version.SupportFilesJSON, ".key") { - t.Fatal("expected support files json to contain certificate artifacts") - } - if err := (&model.Node{ - NodeID: "phase1-node", - Name: "phase1-node", - IP: "10.0.0.8", - AccessToken: common.AccessToken, - Version: "0.1.0", - ExtVersion: "1.25.5", - Status: service.NodeStatusOnline, - LastSeenAt: time.Now(), - }).Insert(); err != nil { - t.Fatalf("failed to seed phase1 node: %v", err) - } - - agentResp := performAgentJSONRequestWithToken(t, engine, common.AccessToken, http.MethodGet, "/api/agent/config-versions/active", nil) - var activeConfig map[string]any - decodeResponseData(t, agentResp, &activeConfig) - sourceConfigJSON, ok := activeConfig["source_config_json"].(string) - if !ok || !strings.Contains(sourceConfigJSON, "secure.example.com") { - t.Fatalf("expected active config to expose source_config_json, got %#v", activeConfig["source_config_json"]) - } - supportFiles, ok := activeConfig["support_files"].([]any) - if !ok || len(supportFiles) != 2 { - t.Fatalf("expected active config to expose 2 certificate support files, got %#v", activeConfig["support_files"]) - } -} - -func TestTLSCertificateConvertAcmeAPI(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) - certPEM, keyPEM := generateCertificatePairForRouterTest(t, []string{"manual.example.com"}) - createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{ - "name": "manual-example", - "cert_pem": certPEM, - "key_pem": keyPEM, - }) - var certificate model.TLSCertificate - decodeResponseData(t, createResp, &certificate) - - started := make(chan struct{}, 1) - release := make(chan struct{}) - done := make(chan struct{}) - restore := service.SetTLSCertificateObtainFuncForTest(func(c *model.TLSCertificate) error { - defer close(done) - started <- struct{}{} - <-release - return errors.New("stop test conversion before external ACME call") - }) - t.Cleanup(func() { - close(release) - <-done - restore() - }) - - convertResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(certificate.ID)+"/convert-acme", map[string]any{ - "name": "managed-example", - "remark": "convert via api", - "acme_account_id": 1, - "dns_account_id": 2, - "key_algorithm": "EC256", - "auto_renew": true, - "primary_domain": "manual.example.com", - }) - var converted model.TLSCertificate - decodeResponseData(t, convertResp, &converted) - if converted.ID != certificate.ID || converted.Provider != "upload" || converted.ApplyStatus != "applying" { - t.Fatalf("expected conversion API to keep upload provider while applying, got %+v", converted) - } - - select { - case <-started: - case <-time.After(time.Second): - t.Fatal("expected conversion task to start") - } - - duplicateResp := performJSONRequestNoFatal(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(certificate.ID)+"/convert-acme", map[string]any{ - "name": "managed-example", - "primary_domain": "manual.example.com", - }) - if duplicateResp.Success || !strings.Contains(duplicateResp.Message, "already applying") { - t.Fatalf("expected duplicate conversion to fail, got %+v", duplicateResp) - } - - invalidResp := performJSONRequestNoFatal(t, engine, token, http.MethodPost, "/api/tls-certificates/not-a-number/convert-acme", map[string]any{}) - if invalidResp.Success || !strings.Contains(invalidResp.Message, "参数错误") { - t.Fatalf("expected invalid id to fail, got %+v", invalidResp) - } - - acmeCertPEM, acmeKeyPEM := generateCertificatePairForRouterTest(t, []string{"acme.example.com"}) - acmeResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{ - "name": "already-acme", - "cert_pem": acmeCertPEM, - "key_pem": acmeKeyPEM, - }) - var acmeCertificate model.TLSCertificate - decodeResponseData(t, acmeResp, &acmeCertificate) - acmeCertificate.Provider = "acme" - if err := acmeCertificate.Update(); err != nil { - t.Fatalf("failed to mark certificate acme: %v", err) - } - nonUploadResp := performJSONRequestNoFatal(t, engine, token, http.MethodPost, "/api/tls-certificates/"+toString(acmeCertificate.ID)+"/convert-acme", map[string]any{ - "name": "already-acme", - "primary_domain": "acme.example.com", - }) - if nonUploadResp.Success || !strings.Contains(nonUploadResp.Message, "only uploaded") { - t.Fatalf("expected non-upload conversion to fail, got %+v", nonUploadResp) - } -} - -func setupTestDB(t *testing.T) { - t.Helper() - dbPath := filepath.Join(t.TempDir(), "phase1.db") - common.SQLitePath = dbPath - common.AccessToken = "phase1-agent-token" - originalCapLoginEnabled := common.CapLoginEnabled - common.CapLoginEnabled = false - if err := model.InitDB(); err != nil { - t.Fatalf("failed to init db: %v", err) - } - middleware.InitJWTMiddleware() - t.Cleanup(func() { - common.CapLoginEnabled = originalCapLoginEnabled - if err := model.CloseDB(); err != nil { - t.Fatalf("failed to close db: %v", err) - } - }) -} - -func prepareRootToken(t *testing.T) string { - t.Helper() - user := &model.User{Username: "root"} - if err := user.FillUserByUsername(); err != nil { - t.Fatalf("failed to load root user: %v", err) - } - // Generate a proper JWT so auth middleware can validate it - tokenString, _, err := middleware.JWTMiddleware.TokenGenerator(user) - if err != nil { - t.Fatalf("failed to generate JWT for root user: %v", err) - } - if err := model.DB.Model(user).Update("token", tokenString).Error; err != nil { - t.Fatalf("failed to set root token: %v", err) - } - return tokenString -} - -func performJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { - t.Helper() - var payload []byte - var err error - if body != nil { - payload, err = json.Marshal(body) - if err != nil { - t.Fatalf("failed to marshal request body: %v", err) - } - } - req := httptest.NewRequest(method, path, bytes.NewReader(payload)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("OpenFlare-Token", token) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, 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.Fatalf("request %s %s failed: %s", method, path, resp.Message) - } - return resp -} - -func performJSONRequestNoFatal(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { - t.Helper() - var payload []byte - var err error - if body != nil { - payload, err = json.Marshal(body) - if err != nil { - t.Fatalf("failed to marshal request body: %v", err) - } - } - req := httptest.NewRequest(method, path, bytes.NewReader(payload)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("OpenFlare-Token", token) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK && recorder.Code != http.StatusBadRequest { - t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String()) - } - var resp apiResponse - if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - return resp -} - -func decodeResponseData(t *testing.T, resp apiResponse, target any) { - t.Helper() - if err := json.Unmarshal(resp.Data, target); err != nil { - t.Fatalf("failed to decode response data: %v", err) - } -} - -func toString(id uint) string { - return strconv.FormatUint(uint64(id), 10) -} - -func performMultipartRequest(t *testing.T, engine http.Handler, token string, path string, fields map[string]string, files map[string]string) apiResponse { - t.Helper() - var body bytes.Buffer - writer := multipart.NewWriter(&body) - for key, value := range fields { - if err := writer.WriteField(key, value); err != nil { - t.Fatalf("failed to write multipart field: %v", err) - } - } - for fieldName, content := range files { - part, err := writer.CreateFormFile(fieldName, fieldName+".pem") - if err != nil { - t.Fatalf("failed to create multipart file: %v", err) - } - if _, err = part.Write([]byte(content)); err != nil { - t.Fatalf("failed to write multipart file content: %v", err) - } - } - if err := writer.Close(); err != nil { - t.Fatalf("failed to close multipart writer: %v", err) - } - req := httptest.NewRequest(http.MethodPost, path, &body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("OpenFlare-Token", token) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected status %d for multipart %s: %s", recorder.Code, path, recorder.Body.String()) - } - var resp apiResponse - if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to unmarshal multipart response: %v", err) - } - if !resp.Success { - t.Fatalf("multipart request %s failed: %s", path, resp.Message) - } - return resp -} - -func generateCertificatePairForRouterTest(t *testing.T, dnsNames []string) (string, string) { - t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("GenerateKey failed: %v", err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(time.Now().UnixNano()), - Subject: pkix.Name{ - CommonName: dnsNames[0], - }, - DNSNames: dnsNames, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - } - certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) - if err != nil { - t.Fatalf("CreateCertificate failed: %v", err) - } - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) - return string(certPEM), string(keyPEM) -} diff --git a/openflare-server/internal/router/api_phase2_managed_domain_test.go b/openflare-server/internal/router/api_phase2_managed_domain_test.go deleted file mode 100644 index 9db25ffe..00000000 --- a/openflare-server/internal/router/api_phase2_managed_domain_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package router_test - -import ( - "net/http" - "testing" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/router" -) - -func TestPhase2ManagedDomainLifecycle(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) - wildcardCertPEM, wildcardKeyPEM := generateCertificatePairForRouterTest(t, []string{"*.example.com"}) - exactCertPEM, exactKeyPEM := generateCertificatePairForRouterTest(t, []string{"api.example.com"}) - - wildcardResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{ - "name": "wildcard-cert", - "cert_pem": wildcardCertPEM, - "key_pem": wildcardKeyPEM, - }) - var wildcardCertificate map[string]any - decodeResponseData(t, wildcardResp, &wildcardCertificate) - - exactResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/tls-certificates/", map[string]any{ - "name": "exact-cert", - "cert_pem": exactCertPEM, - "key_pem": exactKeyPEM, - }) - var exactCertificate map[string]any - decodeResponseData(t, exactResp, &exactCertificate) - - wildcardID := uint(wildcardCertificate["id"].(float64)) - exactID := uint(exactCertificate["id"].(float64)) - - createWildcard := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{ - "domain": "*.example.com", - "cert_id": wildcardID, - "enabled": true, - "remark": "wildcard binding", - }) - var wildcardDomain map[string]any - decodeResponseData(t, createWildcard, &wildcardDomain) - - createExact := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/", map[string]any{ - "domain": "api.example.com", - "cert_id": exactID, - "enabled": true, - "remark": "exact binding", - }) - var exactDomain map[string]any - decodeResponseData(t, createExact, &exactDomain) - - listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/", nil) - var domains []map[string]any - decodeResponseData(t, listResp, &domains) - if len(domains) != 2 { - t.Fatalf("expected 2 managed domains, got %d", len(domains)) - } - - matchResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil) - var matchResult map[string]any - decodeResponseData(t, matchResp, &matchResult) - if matched, ok := matchResult["matched"].(bool); !ok || !matched { - t.Fatalf("expected exact domain to be matched, got %#v", matchResult) - } - candidate, ok := matchResult["candidate"].(map[string]any) - if !ok { - t.Fatalf("expected candidate payload, got %#v", matchResult["candidate"]) - } - if candidate["match_type"] != "exact" { - t.Fatalf("expected exact match type, got %#v", candidate["match_type"]) - } - if uint(candidate["certificate_id"].(float64)) != exactID { - t.Fatalf("expected exact certificate id %d, got %#v", exactID, candidate["certificate_id"]) - } - - updateResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/"+toString(uint(exactDomain["id"].(float64)))+"/update", map[string]any{ - "domain": "api.example.com", - "cert_id": exactID, - "enabled": false, - "remark": "disabled exact binding", - }) - decodeResponseData(t, updateResp, &exactDomain) - - matchResp = performJSONRequest(t, engine, token, http.MethodGet, "/api/managed-domains/match?domain=api.example.com", nil) - decodeResponseData(t, matchResp, &matchResult) - candidate, ok = matchResult["candidate"].(map[string]any) - if !ok { - t.Fatalf("expected wildcard fallback candidate, got %#v", matchResult["candidate"]) - } - if candidate["match_type"] != "wildcard" { - t.Fatalf("expected wildcard fallback, got %#v", candidate["match_type"]) - } - if uint(candidate["certificate_id"].(float64)) != wildcardID { - t.Fatalf("expected wildcard certificate id %d, got %#v", wildcardID, candidate["certificate_id"]) - } - - deleteResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/managed-domains/"+toString(uint(wildcardDomain["id"].(float64)))+"/delete", nil) - if !deleteResp.Success { - t.Fatalf("expected delete success, got %s", deleteResp.Message) - } -} diff --git a/openflare-server/internal/router/api_phase2_test.go b/openflare-server/internal/router/api_phase2_test.go deleted file mode 100644 index 35d349ad..00000000 --- a/openflare-server/internal/router/api_phase2_test.go +++ /dev/null @@ -1,930 +0,0 @@ -package router_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/router" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" -) - -func TestPhase2RateLimitOptionsHotReload(t *testing.T) { - gin.SetMode(gin.TestMode) - common.RedisEnabled = false - setupTestDB(t) - model.InitOptionMap() - - oldGlobalApiRateLimitNum := common.GlobalApiRateLimitNum - oldGlobalApiRateLimitDuration := common.GlobalApiRateLimitDuration - oldCriticalRateLimitNum := common.CriticalRateLimitNum - oldCriticalRateLimitDuration := common.CriticalRateLimitDuration - t.Cleanup(func() { - common.GlobalApiRateLimitNum = oldGlobalApiRateLimitNum - common.GlobalApiRateLimitDuration = oldGlobalApiRateLimitDuration - common.CriticalRateLimitNum = oldCriticalRateLimitNum - common.CriticalRateLimitDuration = oldCriticalRateLimitDuration - }) - - 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": "GlobalApiRateLimitNum", - "value": "450", - }, - { - "key": "GlobalApiRateLimitDuration", - "value": "240", - }, - { - "key": "CriticalRateLimitNum", - "value": "150", - }, - { - "key": "CriticalRateLimitDuration", - "value": "900", - }, - }, - }) - - if common.GlobalApiRateLimitNum != 450 { - t.Fatalf("expected GlobalApiRateLimitNum to be hot reloaded, got %d", common.GlobalApiRateLimitNum) - } - if common.GlobalApiRateLimitDuration != 240 { - t.Fatalf("expected GlobalApiRateLimitDuration to be hot reloaded, got %d", common.GlobalApiRateLimitDuration) - } - if common.CriticalRateLimitNum != 150 { - t.Fatalf("expected CriticalRateLimitNum to be hot reloaded, got %d", common.CriticalRateLimitNum) - } - if common.CriticalRateLimitDuration != 900 { - t.Fatalf("expected CriticalRateLimitDuration to be hot reloaded, got %d", common.CriticalRateLimitDuration) - } - - 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"] != "450" { - t.Fatalf("expected option payload to include GlobalApiRateLimitNum=450, got %q", optionMap["GlobalApiRateLimitNum"]) - } - if optionMap["CriticalRateLimitDuration"] != "900" { - t.Fatalf("expected option payload to include CriticalRateLimitDuration=900, got %q", optionMap["CriticalRateLimitDuration"]) - } -} - -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.Header.Set("OpenFlare-Token", 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 TestAuthSourceUpdateAcceptsClientSecret(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) - - loginCookie := loginAsRoot(t, engine) - - createResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/", map[string]any{ - "name": "GitHub", - "type": "github", - "display_name": "GitHub", - "is_active": false, - "client_id": "github-client-id", - "client_secret": "initial-secret", - "scopes": "user:email", - }) - - var created model.AuthSource - decodeResponseData(t, createResp, &created) - if created.ClientSecret != "" { - t.Fatal("expected create response to avoid exposing client_secret") - } - if !created.ClientSecretConfigured { - t.Fatal("expected create response to mark client_secret as configured") - } - - updateResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/1/update", map[string]any{ - "name": "GitHub", - "type": "github", - "display_name": "GitHub", - "is_active": true, - "client_id": "github-client-id", - "client_secret": "updated-secret", - "scopes": "user:email", - }) - - var updated model.AuthSource - decodeResponseData(t, updateResp, &updated) - if updated.ClientSecret != "" { - t.Fatal("expected update response to avoid exposing client_secret") - } - if !updated.ClientSecretConfigured { - t.Fatal("expected update response to mark client_secret as configured") - } - if !updated.IsActive { - t.Fatal("expected auth source to be active after update") - } - - stored, err := model.GetAuthSourceByID(1) - if err != nil { - t.Fatalf("expected auth source to exist: %v", err) - } - if stored.ClientSecret != "updated-secret" { - t.Fatalf("expected stored client secret to be updated, got %q", stored.ClientSecret) - } - - performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/1/toggle", map[string]any{ - "is_active": false, - }) - performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/1/toggle", map[string]any{ - "is_active": true, - }) -} - -func TestExternalAccountBindingsCanBeListedAndDeleted(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) - - loginCookie := loginAsRoot(t, engine) - - source := &model.AuthSource{ - Name: "logto", - Type: model.AuthSourceTypeOIDC, - DisplayName: "Logto", - ClientID: "logto-client-id", - ClientSecret: "logto-client-secret", - OpenIDDiscoveryURL: "https://auth.example.com/.well-known/openid-configuration", - } - if err := model.CreateAuthSource(source); err != nil { - t.Fatalf("create auth source: %v", err) - } - if err := model.LinkExternalAccount(&model.ExternalAccount{ - AuthSourceID: source.ID, - UserID: 1, - ExternalID: "logto-user-1", - ExternalUsername: "ryan", - Email: "ryan@example.com", - }); err != nil { - t.Fatalf("link external account: %v", err) - } - - listResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil) - var bindings []model.ExternalAccountView - decodeResponseData(t, listResp, &bindings) - if len(bindings) != 1 { - t.Fatalf("expected 1 binding, got %d", len(bindings)) - } - if bindings[0].AuthSourceName != "logto" || bindings[0].ExternalUsername != "ryan" { - t.Fatalf("unexpected binding view: %+v", bindings[0]) - } - - performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/oauth/external-accounts/1/delete", nil) - - listResp = performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil) - decodeResponseData(t, listResp, &bindings) - if len(bindings) != 0 { - t.Fatalf("expected binding to be deleted, got %+v", bindings) - } -} - -func loginAsRoot(t *testing.T, engine http.Handler) string { - t.Helper() - payload, err := json.Marshal(map[string]any{ - "username": "root", - "password": "123456", - }) - if err != nil { - t.Fatalf("failed to marshal login payload: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected login 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 decode login response: %v", err) - } - if !resp.Success { - t.Fatalf("root login failed: %s", resp.Message) - } - - var user model.User - if err = json.Unmarshal(resp.Data, &user); err != nil { - t.Fatalf("failed to decode login user: %v", err) - } - if user.Token == "" { - t.Fatal("expected OpenFlare-Token after root login") - } - return user.Token -} - -func performSessionJSONRequest(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { - t.Helper() - var payload []byte - var err error - if body != nil { - payload, err = json.Marshal(body) - if err != nil { - t.Fatalf("failed to marshal request body: %v", err) - } - } - - req := httptest.NewRequest(method, path, bytes.NewReader(payload)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("OpenFlare-Token", token) - - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, 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.Fatalf("request %s %s failed: %s", method, path, resp.Message) - } - return resp -} - -func TestPhase2AgentLifecycle(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) - - adminToken := prepareRootToken(t) - - createRouteAndPublishVersion(t, engine, adminToken) - - dashboardResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/dashboard/overview", nil) - var dashboard struct { - Summary service.DashboardSummary `json:"summary"` - } - decodeResponseData(t, dashboardResp, &dashboard) - if dashboard.Summary.TotalNodes != 0 { - t.Fatalf("expected empty dashboard node summary before node registration, got %+v", dashboard.Summary) - } - - unauthorizedRequest := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/register", bytes.NewReader([]byte(`{}`))) - unauthorizedRecorder := httptest.NewRecorder() - engine.ServeHTTP(unauthorizedRecorder, unauthorizedRequest) - if unauthorizedRecorder.Code != http.StatusUnauthorized { - t.Fatalf("expected unauthorized status for missing discovery token, got %d", unauthorizedRecorder.Code) - } - - createdNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/", map[string]any{ - "name": "shanghai-edge-1", - "geo_manual_override": true, - "geo_name": "Shanghai", - "geo_latitude": 31.2304, - "geo_longitude": 121.4737, - }) - var createdNode service.NodeView - decodeResponseData(t, createdNodeResp, &createdNode) - if createdNode.AccessToken == "" || createdNode.Status != service.NodeStatusPending { - t.Fatal("expected created node to expose agent token with pending status") - } - if createdNode.GeoName != "Shanghai" || createdNode.GeoLatitude == nil || createdNode.GeoLongitude == nil { - t.Fatalf("expected created node to expose geo metadata, got %+v", createdNode) - } - - heartbeatPayload := map[string]any{ - "node_id": "spoofed-node-id", - "name": "shanghai-edge-1", - "ip": "10.0.0.9", - "version": "0.1.1", - "ext_version": "1.27.1.2", - "openresty_status": service.OpenrestyStatusUnhealthy, - "openresty_message": "docker run openresty failed: bind 80 already allocated", - "current_version": "", - "last_error": "", - } - resp := performAgentJSONRequestWithTokenAndRemote(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload, "198.51.100.10:1234") - var registeredNode model.Node - decodeResponseData(t, resp, ®isteredNode) - if registeredNode.IP != "198.51.100.10" || registeredNode.Version != "0.1.1" || registeredNode.NodeID != createdNode.NodeID { - t.Fatal("expected heartbeat to update node metadata") - } - if registeredNode.OpenrestyStatus != service.OpenrestyStatusUnhealthy { - t.Fatal("expected heartbeat to update openresty status") - } - - activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodGet, "/api/agent/config-versions/active", nil) - var activeConfig service.AgentConfigResponse - decodeResponseData(t, activeConfigResp, &activeConfig) - if activeConfig.Version == "" || activeConfig.SourceConfigJSON == "" || activeConfig.Checksum == "" { - t.Fatal("expected active config response to contain version payload") - } - - successApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{ - "node_id": "spoofed-node-id", - "version": activeConfig.Version, - "result": service.ApplyResultOK, - "message": "apply ok", - }) - var successApplyLog model.ApplyLog - decodeResponseData(t, successApplyResp, &successApplyLog) - if successApplyLog.Result != service.ApplyResultOK { - t.Fatal("expected apply log success to be recorded") - } - - failedApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{ - "node_id": "spoofed-node-id", - "version": activeConfig.Version, - "result": service.ApplyResultFailed, - "message": "openresty reload failed", - }) - var failedApplyLog model.ApplyLog - decodeResponseData(t, failedApplyResp, &failedApplyLog) - if failedApplyLog.Result != service.ApplyResultFailed { - t.Fatal("expected failed apply log to be recorded") - } - - nodesResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil) - var nodes []service.NodeView - decodeResponseData(t, nodesResp, &nodes) - if len(nodes) != 1 { - t.Fatalf("expected 1 node, got %d", len(nodes)) - } - if nodes[0].Status != service.NodeStatusOnline { - t.Fatal("expected registered node to become online") - } - if nodes[0].AccessToken != createdNode.AccessToken { - t.Fatal("expected node auth token to remain stable after occupancy") - } - if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "openresty reload failed" { - t.Fatal("expected node list to expose latest apply status") - } - if nodes[0].CurrentVersion != activeConfig.Version { - t.Fatal("expected node current_version to remain at last successful version") - } - if nodes[0].LastError != "openresty reload failed" { - t.Fatal("expected node last_error to reflect failed apply") - } - if nodes[0].OpenrestyStatus != service.OpenrestyStatusUnhealthy { - t.Fatal("expected node list to expose openresty status") - } - if nodes[0].OpenrestyMessage != "docker run openresty failed: bind 80 already allocated" { - t.Fatal("expected node list to expose openresty message") - } - - if err := model.DB.Create(&model.NodeHealthEvent{ - NodeID: createdNode.NodeID, - EventType: "openresty_down", - Severity: service.NodeHealthSeverityCritical, - Status: service.NodeHealthEventStatusActive, - Message: "docker run openresty failed: bind 80 already allocated", - FirstTriggeredAt: time.Now().Add(-2 * time.Minute), - LastTriggeredAt: time.Now().Add(-time.Minute), - ReportedAt: time.Now().Add(-time.Minute), - }).Error; err != nil { - t.Fatalf("failed to insert node health event: %v", err) - } - - observabilityResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/"+toString(createdNode.ID)+"/observability?hours=24&limit=20", nil) - var observability service.NodeObservabilityView - decodeResponseData(t, observabilityResp, &observability) - if observability.NodeID != createdNode.NodeID { - t.Fatalf("expected observability response for node %s, got %s", createdNode.NodeID, observability.NodeID) - } - if len(observability.HealthEvents) != 1 { - t.Fatalf("expected observability response to include health events, got %+v", observability.HealthEvents) - } - - cleanupHealthResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/observability/cleanup", nil) - var cleanupHealthResult service.NodeHealthEventCleanupResult - decodeResponseData(t, cleanupHealthResp, &cleanupHealthResult) - if cleanupHealthResult.NodeID != createdNode.NodeID || cleanupHealthResult.DeletedCount != 1 { - t.Fatalf("unexpected node health cleanup result: %+v", cleanupHealthResult) - } - - observabilityAfterCleanupResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/"+toString(createdNode.ID)+"/observability?hours=24&limit=20", nil) - decodeResponseData(t, observabilityAfterCleanupResp, &observability) - if len(observability.HealthEvents) != 0 { - t.Fatalf("expected health events to be cleaned up, got %+v", observability.HealthEvents) - } - - restartResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/openresty-restart", nil) - decodeResponseData(t, restartResp, &createdNode) - if !createdNode.RestartOpenrestyRequested { - t.Fatal("expected openresty restart request flag to be set") - } - - rawHeartbeatPayload, err := json.Marshal(heartbeatPayload) - if err != nil { - t.Fatalf("failed to marshal heartbeat payload: %v", err) - } - restartHeartbeatReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader(rawHeartbeatPayload)) - restartHeartbeatReq.Header.Set("Content-Type", "application/json") - restartHeartbeatReq.Header.Set("X-Agent-Token", createdNode.AccessToken) - restartHeartbeatReq.RemoteAddr = "198.51.100.10:1234" - restartHeartbeatRecorder := httptest.NewRecorder() - engine.ServeHTTP(restartHeartbeatRecorder, restartHeartbeatReq) - if restartHeartbeatRecorder.Code != http.StatusOK { - t.Fatalf("unexpected heartbeat status %d: %s", restartHeartbeatRecorder.Code, restartHeartbeatRecorder.Body.String()) - } - var restartHeartbeatBody struct { - Success bool `json:"success"` - Message string `json:"message"` - AgentSettings service.AgentSettings `json:"agent_settings"` - ActiveConfig *service.ActiveConfigMeta `json:"active_config"` - } - if err = json.Unmarshal(restartHeartbeatRecorder.Body.Bytes(), &restartHeartbeatBody); err != nil { - t.Fatalf("failed to decode heartbeat response: %v", err) - } - if !restartHeartbeatBody.Success { - t.Fatalf("expected heartbeat request success, got %s", restartHeartbeatBody.Message) - } - if !restartHeartbeatBody.AgentSettings.RestartOpenrestyNow { - t.Fatal("expected heartbeat response to instruct openresty restart") - } - if restartHeartbeatBody.ActiveConfig == nil || restartHeartbeatBody.ActiveConfig.Version == "" || restartHeartbeatBody.ActiveConfig.Checksum == "" { - t.Fatal("expected heartbeat response to include active config summary") - } - - logsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID+"&pageNo=1&pageSize=1", nil) - var logs service.ApplyLogListResult - decodeResponseData(t, logsResp, &logs) - if logs.Current != 1 || logs.Total != 2 || logs.TotalPage != 2 { - t.Fatalf("unexpected paged apply logs result: %+v", logs) - } - if len(logs.Rows) != 1 { - t.Fatalf("expected 1 apply log row on page 1, got %d", len(logs.Rows)) - } - if logs.Rows[0].Result != service.ApplyResultFailed { - t.Fatalf("expected newest apply log first, got %s", logs.Rows[0].Result) - } - oldApplyLogTime := time.Now().Add(-48 * time.Hour) - if err := model.DB.Model(&model.ApplyLog{}).Where("id = ?", successApplyLog.ID).Update("created_at", oldApplyLogTime).Error; err != nil { - t.Fatalf("failed to backdate apply log: %v", err) - } - cleanupResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/apply-logs/cleanup", map[string]any{ - "retention_days": 1, - }) - var cleanupResult service.ApplyLogCleanupResult - decodeResponseData(t, cleanupResp, &cleanupResult) - if cleanupResult.DeleteAll { - t.Fatal("expected retention cleanup instead of delete-all cleanup") - } - if cleanupResult.RetentionDays != 1 || cleanupResult.DeletedCount != 1 { - t.Fatalf("unexpected cleanup result: %+v", cleanupResult) - } - postCleanupResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil) - decodeResponseData(t, postCleanupResp, &logs) - if logs.Total != 1 || len(logs.Rows) != 1 { - t.Fatalf("expected one apply log after retention cleanup, got %+v", logs) - } - deleteAllResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/apply-logs/cleanup", map[string]any{ - "delete_all": true, - }) - decodeResponseData(t, deleteAllResp, &cleanupResult) - if !cleanupResult.DeleteAll || cleanupResult.DeletedCount != 1 { - t.Fatalf("unexpected delete-all cleanup result: %+v", cleanupResult) - } - emptyLogsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil) - decodeResponseData(t, emptyLogsResp, &logs) - if logs.Total != 0 || len(logs.Rows) != 0 || logs.Current != 1 || logs.TotalPage != 0 { - t.Fatalf("expected empty apply log page after delete-all cleanup, got %+v", logs) - } - postDeleteApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AccessToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{ - "version": activeConfig.Version, - "result": service.ApplyResultOK, - "message": "local config already matches active version; apply skipped", - "checksum": activeConfig.Checksum, - }) - var postDeleteApplyLog model.ApplyLog - decodeResponseData(t, postDeleteApplyResp, &postDeleteApplyLog) - if postDeleteApplyLog.ID == 0 || postDeleteApplyLog.NodeID != createdNode.NodeID { - t.Fatalf("expected apply log to be recreated after delete-all cleanup, got %+v", postDeleteApplyLog) - } - postDeleteLogsResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/apply-logs/?node_id="+createdNode.NodeID, nil) - decodeResponseData(t, postDeleteLogsResp, &logs) - if logs.Total != 1 || len(logs.Rows) != 1 || logs.Rows[0].ID != postDeleteApplyLog.ID { - t.Fatalf("expected new apply log after delete-all cleanup, got %+v", logs) - } - - updatedNodeResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/update", map[string]any{ - "name": "shanghai-edge-1-renamed", - "geo_manual_override": true, - "geo_name": "Tokyo", - "geo_latitude": 35.6762, - "geo_longitude": 139.6503, - }) - decodeResponseData(t, updatedNodeResp, &createdNode) - if createdNode.Name != "shanghai-edge-1-renamed" { - t.Fatal("expected node name to be editable") - } - if createdNode.GeoName != "Tokyo" || createdNode.GeoLatitude == nil || createdNode.GeoLongitude == nil { - t.Fatalf("expected node geo metadata to be editable, got %+v", createdNode) - } - - oldTime := time.Now().Add(-common.NodeOfflineThreshold - time.Minute) - if err := model.DB.Model(&model.Node{}).Where("node_id = ?", createdNode.NodeID).Update("last_seen_at", oldTime).Error; err != nil { - t.Fatalf("failed to update node last_seen_at: %v", err) - } - nodesResp = performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil) - decodeResponseData(t, nodesResp, &nodes) - if nodes[0].Status != service.NodeStatusOffline { - t.Fatal("expected node to be shown as offline after timeout") - } - - deleteResp := performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/nodes/"+toString(createdNode.ID)+"/delete", nil) - if !deleteResp.Success { - t.Fatalf("expected delete node success, got %s", deleteResp.Message) - } - - deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","version":"0.1.1"}`))) - deniedReq.Header.Set("Content-Type", "application/json") - deniedReq.Header.Set("X-Agent-Token", createdNode.AccessToken) - deniedRecorder := httptest.NewRecorder() - engine.ServeHTTP(deniedRecorder, deniedReq) - if deniedRecorder.Code != http.StatusUnauthorized { - t.Fatalf("expected deleted node token to be rejected, got %d", deniedRecorder.Code) - } -} - -func TestPhase2CustomHeadersPreviewAndDiffLifecycle(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{ - "domain": "preview.example.com", - "origin_url": "https://origin-a.internal", - "origin_host": "preview-origin.internal", - "enabled": true, - "custom_headers": []map[string]any{ - {"key": "X-Trace-Id", "value": "$request_id"}, - }, - }) - 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) - } - 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) - - performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/"+toString(createdRoute.ID)+"/update", map[string]any{ - "domain": "preview.example.com", - "origin_url": "https://origin-b.internal", - "origin_host": "preview-upstream.internal", - "enabled": true, - "custom_headers": []map[string]any{ - {"key": "X-Trace-Id", "value": "$request_id"}, - {"key": "X-Release", "value": "candidate"}, - }, - }) - performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{ - "domain": "new-preview.example.com", - "origin_url": "https://origin-new.internal", - "enabled": true, - }) - - previewResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/preview", nil) - 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) - } - if !strings.Contains(renderedConfig, `proxy_set_header Host "preview-upstream.internal";`) { - t.Fatalf("expected preview endpoint to return overridden host header, got %s", renderedConfig) - } - if !strings.Contains(renderedConfig, "proxy_ssl_server_name on;") { - t.Fatalf("expected preview endpoint to enable proxy ssl server name, got %s", renderedConfig) - } - if !strings.Contains(renderedConfig, `proxy_ssl_name "preview-upstream.internal";`) { - t.Fatalf("expected preview endpoint to return proxy ssl name, got %s", renderedConfig) - } - - diffResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/diff", nil) - var diff map[string]any - decodeResponseData(t, diffResp, &diff) - modifiedDomains, ok := diff["modified_domains"].([]any) - if !ok || len(modifiedDomains) != 1 || modifiedDomains[0].(string) != "preview.example.com" { - t.Fatalf("unexpected modified domains: %#v", diff["modified_domains"]) - } - addedDomains, ok := diff["added_domains"].([]any) - 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) { - 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) - - adminToken := prepareRootToken(t) - bootstrapResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/bootstrap-token", nil) - var bootstrap service.NodeBootstrapView - decodeResponseData(t, bootstrapResp, &bootstrap) - if bootstrap.DiscoveryToken == "" { - t.Fatal("expected global discovery token to be available") - } - - resp := performAgentJSONRequestWithTokenAndRemote(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{ - "node_id": "local-node-id", - "name": "bulk-edge-1", - "ip": "10.0.0.18", - "version": "0.2.0", - "ext_version": "1.25.5", - "current_version": "", - "last_error": "", - }, "203.0.113.18:4321") - var registration service.AgentRegistrationResponse - decodeResponseData(t, resp, ®istration) - if registration.AccessToken == "" || registration.NodeID == "" { - t.Fatal("expected discovery registration to issue node-specific agent token") - } - - nodesResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil) - var nodes []service.NodeView - decodeResponseData(t, nodesResp, &nodes) - if len(nodes) != 1 { - t.Fatalf("expected 1 discovered node, got %d", len(nodes)) - } - if nodes[0].Name != "bulk-edge-1" || nodes[0].AccessToken != registration.AccessToken || nodes[0].Status != service.NodeStatusOnline { - t.Fatal("expected discovered node to be created online with issued agent token") - } - if nodes[0].IP != "203.0.113.18" { - t.Fatalf("expected discovered node to keep public source ip, got %s", nodes[0].IP) - } -} - -func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse { - return performAgentJSONRequestWithTokenAndRemote(t, engine, token, method, path, body, "") -} - -func performAgentJSONRequestWithTokenAndRemote(t *testing.T, engine http.Handler, token string, method string, path string, body any, remoteAddr string) apiResponse { - t.Helper() - var payload []byte - var err error - if body != nil { - payload, err = json.Marshal(body) - if err != nil { - t.Fatalf("failed to marshal request body: %v", err) - } - } - req := httptest.NewRequest(method, path, bytes.NewReader(payload)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - if remoteAddr != "" { - req.RemoteAddr = remoteAddr - } - req.Header.Set("X-Agent-Token", token) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, 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.Fatalf("request %s %s failed: %s", method, path, resp.Message) - } - return resp -} - -func createRouteAndPublishVersion(t *testing.T, engine http.Handler, adminToken string) { - t.Helper() - createBody := map[string]any{ - "domain": "agent.example.com", - "origin_url": "https://agent-origin.internal", - "enabled": true, - "remark": "agent route", - } - performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/proxy-routes/", createBody) - performJSONRequest(t, engine, adminToken, http.MethodPost, "/api/config-versions/publish", nil) -} diff --git a/openflare-server/internal/router/api_uptimekuma_test.go b/openflare-server/internal/router/api_uptimekuma_test.go deleted file mode 100644 index 6fece53c..00000000 --- a/openflare-server/internal/router/api_uptimekuma_test.go +++ /dev/null @@ -1,455 +0,0 @@ -package router_test - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/router" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" -) - -// mockKumaServer simulates Uptime Kuma's Engine.IO/Socket.IO polling endpoints -type mockKumaServer struct { - mu sync.Mutex - postsReceived []string - pendingPackets chan string - monitorList string // JSON representing map[string]UptimeKumaMonitor -} - -func newMockKumaServer(monitorList string) *mockKumaServer { - return &mockKumaServer{ - pendingPackets: make(chan string, 100), - monitorList: monitorList, - } -} - -func (s *mockKumaServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.mu.Lock() - defer s.mu.Unlock() - - transport := r.URL.Query().Get("transport") - sid := r.URL.Query().Get("sid") - - if r.Method == "GET" { - if transport == "polling" && sid == "" { - // Handshake response - w.Header().Set("Content-Type", "text/plain;charset=UTF-8") - _, _ = w.Write([]byte(`0{"sid":"mock-sid"}`)) - return - } - - if transport == "polling" && sid == "mock-sid" { - // Long-polling GET request - w.Header().Set("Content-Type", "text/plain;charset=UTF-8") - select { - case pkt := <-s.pendingPackets: - _, _ = w.Write([]byte(pkt)) - case <-time.After(100 * time.Millisecond): - _, _ = w.Write([]byte("")) - } - return - } - } else if r.Method == "POST" { - bodyBytes, _ := io.ReadAll(r.Body) - bodyStr := string(bodyBytes) - s.postsReceived = append(s.postsReceived, bodyStr) - - w.Header().Set("Content-Type", "text/plain;charset=UTF-8") - w.WriteHeader(http.StatusOK) - - if bodyStr == "40" { - // Namespace Connect event - // Immediately queue the monitorList payload to be fetched by the next GET poll - s.pendingPackets <- fmt.Sprintf(`42["monitorList",%s]`, s.monitorList) - return - } - - if strings.HasPrefix(bodyStr, "42") { - // Socket.IO message: 42[...] - payload := bodyStr[2:] - // Find ack ID (digits at the start of payload) - digitsEnd := 0 - for digitsEnd < len(payload) && payload[digitsEnd] >= '0' && payload[digitsEnd] <= '9' { - digitsEnd++ - } - if digitsEnd == 0 { - return - } - ackIDStr := payload[:digitsEnd] - jsonArrayStr := payload[digitsEnd:] - - var arr []json.RawMessage - if err := json.Unmarshal([]byte(jsonArrayStr), &arr); err != nil || len(arr) == 0 { - return - } - - var eventName string - _ = json.Unmarshal(arr[0], &eventName) - - switch eventName { - case "login", "loginByToken": - s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr) - case "getTags": - s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tags\":[{\"id\":10,\"name\":\"OpenFlare\",\"color\":\"#4f46e5\"}]}]", ackIDStr) - case "addTag": - s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"tag\":{\"id\":10}}]", ackIDStr) - case "add": - s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true,\"monitorID\":100}]", ackIDStr) - case "addMonitorTag": - s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr) - case "editMonitor": - s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr) - case "deleteMonitor": - s.pendingPackets <- fmt.Sprintf("43%s[{\"ok\":true}]", ackIDStr) - } - } - } -} - -func TestUptimeKumaSyncDisabled(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) - - loginCookie := loginAsRoot(t, engine) - - // Keep integration disabled - common.UptimeKumaEnabled = false - - // Request sync, should fail - req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil) - req.Header.Set("OpenFlare-Token", loginCookie) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", recorder.Code) - } - - var resp apiResponse - if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if resp.Success { - t.Fatal("expected sync request to fail when integration is disabled") - } - if !strings.Contains(resp.Message, "disabled") { - t.Fatalf("expected error message to mention integration is disabled, got: %s", resp.Message) - } -} - -func TestUptimeKumaSyncSuccess(t *testing.T) { - gin.SetMode(gin.TestMode) - common.RedisEnabled = false - setupTestDB(t) - - // Clean up route table just in case - _ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error - - // Seed proxy routes - // Route 1: site-a (exists in Uptime Kuma but has different check parameters - should trigger editMonitor) - routeA := &model.ProxyRoute{ - SiteName: "site-a", - Domain: "site-a.com", - Domains: `["site-a.com"]`, - OriginURL: "http://10.0.0.1", - Enabled: true, - EnableHTTPS: false, - } - // Route 2: site-b (does not exist in Uptime Kuma - should trigger add & addMonitorTag) - routeB := &model.ProxyRoute{ - SiteName: "site-b", - Domain: "site-b.com", - Domains: `["site-b.com"]`, - OriginURL: "https://10.0.0.2", - Enabled: true, - EnableHTTPS: true, - } - // Route 3: site-c (disabled locally - should NOT be processed/created) - routeC := &model.ProxyRoute{ - SiteName: "site-c", - Domain: "site-c.com", - Domains: `["site-c.com"]`, - OriginURL: "http://10.0.0.3", - Enabled: false, - EnableHTTPS: false, - } - - if err := model.DB.Create(routeA).Error; err != nil { - t.Fatalf("failed to seed routeA: %v", err) - } - if err := model.DB.Create(routeB).Error; err != nil { - t.Fatalf("failed to seed routeB: %v", err) - } - if err := model.DB.Create(routeC).Error; err != nil { - t.Fatalf("failed to seed routeC: %v", err) - } - - // Prepare mock monitorList - // 1. "site-old": tagged with OpenFlare but doesn't exist locally anymore -> should trigger deleteMonitor - // 2. "site-a": matches routeA but has interval = 30 (default UptimeKumaInterval is 60) -> should trigger editMonitor - monitorListJSON := `{ - "99": { - "id": 99, - "name": "site-old", - "url": "http://site-old.com", - "interval": 60, - "tags": [{"tag_id": 10, "name": "OpenFlare"}] - }, - "98": { - "id": 98, - "name": "site-a", - "url": "http://site-a.com", - "interval": 30, - "tags": [{"tag_id": 10, "name": "OpenFlare"}] - } - }` - - mockSrv := newMockKumaServer(monitorListJSON) - server := httptest.NewServer(mockSrv) - defer server.Close() - - // Backup and set configs - oldEnabled := common.UptimeKumaEnabled - oldUrl := common.UptimeKumaUrl - oldUsername := common.UptimeKumaUsername - oldPassword := common.UptimeKumaPassword - oldScope := common.UptimeKumaMonitorScope - oldInterval := common.UptimeKumaInterval - oldRetry := common.UptimeKumaRetry - oldRetryInterval := common.UptimeKumaRetryInterval - oldTimeout := common.UptimeKumaTimeout - - common.UptimeKumaEnabled = true - common.UptimeKumaUrl = server.URL - common.UptimeKumaUsername = "admin" - common.UptimeKumaPassword = "password" - common.UptimeKumaMonitorScope = "all" - common.UptimeKumaInterval = 60 - common.UptimeKumaRetry = 0 - common.UptimeKumaRetryInterval = 60 - common.UptimeKumaTimeout = 48 - - defer func() { - common.UptimeKumaEnabled = oldEnabled - common.UptimeKumaUrl = oldUrl - common.UptimeKumaUsername = oldUsername - common.UptimeKumaPassword = oldPassword - common.UptimeKumaMonitorScope = oldScope - common.UptimeKumaInterval = oldInterval - common.UptimeKumaRetry = oldRetry - common.UptimeKumaRetryInterval = oldRetryInterval - common.UptimeKumaTimeout = oldTimeout - }() - - engine := gin.New() - engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) - router.SetApiRouter(engine) - - loginCookie := loginAsRoot(t, engine) - - req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil) - req.Header.Set("OpenFlare-Token", loginCookie) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d. Body: %s", recorder.Code, recorder.Body.String()) - } - - var resp apiResponse - if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Fatalf("sync request failed: %s", resp.Message) - } - - mockSrv.mu.Lock() - posts := mockSrv.postsReceived - mockSrv.mu.Unlock() - - // Verify events received - hasLogin := false - hasGetTags := false - hasAddSiteB := false - hasTagSiteB := false - hasEditSiteA := false - hasDeleteOld := false - - for _, body := range posts { - if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) { - hasLogin = true - } - if strings.Contains(body, `"getTags"`) { - hasGetTags = true - } - if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) && strings.Contains(body, `"https://site-b.com"`) { - hasAddSiteB = true - } - if strings.Contains(body, `"addMonitorTag"`) && strings.Contains(body, `10`) && strings.Contains(body, `100`) { - hasTagSiteB = true - } - if strings.Contains(body, `"editMonitor"`) && strings.Contains(body, `98`) && strings.Contains(body, `"site-a"`) && strings.Contains(body, `"interval":60`) { - hasEditSiteA = true - } - if strings.Contains(body, `"deleteMonitor"`) && strings.Contains(body, `99`) { - hasDeleteOld = true - } - } - - if !hasLogin { - t.Error("expected login event to be called") - } - if !hasGetTags { - t.Error("expected getTags event to be called") - } - if !hasAddSiteB { - t.Error("expected site-b to be added") - } - if !hasTagSiteB { - t.Error("expected site-b to be tagged") - } - if !hasEditSiteA { - t.Error("expected site-a to be edited/updated") - } - if !hasDeleteOld { - t.Error("expected site-old to be deleted") - } -} - -func TestUptimeKumaSyncSelectedScope(t *testing.T) { - gin.SetMode(gin.TestMode) - common.RedisEnabled = false - setupTestDB(t) - - // Clean up route table - _ = model.DB.Where("1 = 1").Delete(&model.ProxyRoute{}).Error - - // Seed proxy routes - // Route 1: site-a (enabled, in selected list) - routeA := &model.ProxyRoute{ - SiteName: "site-a", - Domain: "site-a.com", - Domains: `["site-a.com"]`, - OriginURL: "http://10.0.0.1", - Enabled: true, - EnableHTTPS: false, - } - // Route 2: site-b (enabled, NOT in selected list) - routeB := &model.ProxyRoute{ - SiteName: "site-b", - Domain: "site-b.com", - Domains: `["site-b.com"]`, - OriginURL: "http://10.0.0.2", - Enabled: true, - EnableHTTPS: false, - } - - if err := model.DB.Create(routeA).Error; err != nil { - t.Fatalf("failed to seed routeA: %v", err) - } - if err := model.DB.Create(routeB).Error; err != nil { - t.Fatalf("failed to seed routeB: %v", err) - } - - mockSrv := newMockKumaServer(`{}`) - server := httptest.NewServer(mockSrv) - defer server.Close() - - // Backup and set configs - oldEnabled := common.UptimeKumaEnabled - oldUrl := common.UptimeKumaUrl - oldUsername := common.UptimeKumaUsername - oldPassword := common.UptimeKumaPassword - oldScope := common.UptimeKumaMonitorScope - oldSelected := common.UptimeKumaSelectedSites - - common.UptimeKumaEnabled = true - common.UptimeKumaUrl = server.URL - common.UptimeKumaUsername = "admin" - common.UptimeKumaPassword = "password" - common.UptimeKumaMonitorScope = "selected" - common.UptimeKumaSelectedSites = "site-a" // site-b is excluded - - defer func() { - common.UptimeKumaEnabled = oldEnabled - common.UptimeKumaUrl = oldUrl - common.UptimeKumaUsername = oldUsername - common.UptimeKumaPassword = oldPassword - common.UptimeKumaMonitorScope = oldScope - common.UptimeKumaSelectedSites = oldSelected - }() - - engine := gin.New() - engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) - router.SetApiRouter(engine) - - loginCookie := loginAsRoot(t, engine) - - req := httptest.NewRequest(http.MethodPost, "/api/uptimekuma/sync", nil) - req.Header.Set("OpenFlare-Token", loginCookie) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", recorder.Code) - } - - var resp apiResponse - if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - - if !resp.Success { - t.Fatalf("sync request failed: %s", resp.Message) - } - - mockSrv.mu.Lock() - posts := mockSrv.postsReceived - mockSrv.mu.Unlock() - - hasLogin := false - hasAddSiteA := false - hasAddSiteB := false - - for _, body := range posts { - if strings.Contains(body, `"login"`) && strings.Contains(body, `"admin"`) && strings.Contains(body, `"password"`) { - hasLogin = true - } - if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-a"`) { - hasAddSiteA = true - } - if strings.Contains(body, `"add"`) && strings.Contains(body, `"site-b"`) { - hasAddSiteB = true - } - } - - if !hasLogin { - t.Error("expected login event to be called") - } - if !hasAddSiteA { - t.Error("expected site-a to be added") - } - if hasAddSiteB { - t.Error("expected site-b NOT to be added (not in selected scope)") - } -} diff --git a/openflare-server/internal/router/main.go b/openflare-server/internal/router/main.go deleted file mode 100644 index a4ae4c62..00000000 --- a/openflare-server/internal/router/main.go +++ /dev/null @@ -1,25 +0,0 @@ -package router - -import ( - "embed" - - "github.com/rain-kl/openflare/openflare-server/internal/middleware" - - "github.com/gin-gonic/gin" - swaggerFiles "github.com/swaggo/files" - ginSwagger "github.com/swaggo/gin-swagger" -) - -func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { - SetApiRouter(router) - swaggerRoute := router.Group("/swagger") - swaggerRoute.Use(middleware.AdminAuth()) - swaggerRoute.GET("/*any", ginSwagger.WrapHandler( - swaggerFiles.Handler, - ginSwagger.URL("/swagger/doc.json"), - ginSwagger.DocExpansion("list"), - ginSwagger.PersistAuthorization(true), - ginSwagger.DefaultModelsExpandDepth(1), - )) - setWebRouter(router, buildFS, indexPage) -} diff --git a/Wavelet/internal/router/middlewares.go b/openflare-server/internal/router/middlewares.go similarity index 100% rename from Wavelet/internal/router/middlewares.go rename to openflare-server/internal/router/middlewares.go diff --git a/Wavelet/internal/router/middlewares_test.go b/openflare-server/internal/router/middlewares_test.go similarity index 100% rename from Wavelet/internal/router/middlewares_test.go rename to openflare-server/internal/router/middlewares_test.go diff --git a/Wavelet/internal/router/root/custom.go b/openflare-server/internal/router/root/custom.go similarity index 100% rename from Wavelet/internal/router/root/custom.go rename to openflare-server/internal/router/root/custom.go diff --git a/Wavelet/internal/router/root/default.go b/openflare-server/internal/router/root/default.go similarity index 100% rename from Wavelet/internal/router/root/default.go rename to openflare-server/internal/router/root/default.go diff --git a/Wavelet/internal/router/root/frontend.go b/openflare-server/internal/router/root/frontend.go similarity index 100% rename from Wavelet/internal/router/root/frontend.go rename to openflare-server/internal/router/root/frontend.go diff --git a/Wavelet/internal/router/root/root.go b/openflare-server/internal/router/root/root.go similarity index 100% rename from Wavelet/internal/router/root/root.go rename to openflare-server/internal/router/root/root.go diff --git a/Wavelet/internal/router/router.go b/openflare-server/internal/router/router.go similarity index 100% rename from Wavelet/internal/router/router.go rename to openflare-server/internal/router/router.go diff --git a/openflare-server/internal/router/swagger_test.go b/openflare-server/internal/router/swagger_test.go deleted file mode 100644 index 13276ee4..00000000 --- a/openflare-server/internal/router/swagger_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package router_test - -import ( - "os" - "strings" - "testing" -) - -func TestGeneratedSwaggerSpecExists(t *testing.T) { - data, err := os.ReadFile("../../docs/swagger.json") - if err != nil { - t.Fatalf("failed to read generated swagger spec: %v", err) - } - content := string(data) - if !strings.Contains(content, "\"title\": \"OpenFlare Server API\"") { - t.Fatal("expected swagger spec title to exist") - } - if !strings.Contains(content, "\"/api/proxy-routes/\"") { - t.Fatal("expected swagger spec to contain proxy route endpoint") - } -} diff --git a/openflare-server/internal/router/update_test.go b/openflare-server/internal/router/update_test.go deleted file mode 100644 index 5cfe58aa..00000000 --- a/openflare-server/internal/router/update_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package router_test - -import ( - "bytes" - "encoding/json" - "io" - "mime/multipart" - "net/http" - "net/http/httptest" - "runtime" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/router" - "github.com/rain-kl/openflare/openflare-server/internal/service" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" -) - -type roundTripFunc func(req *http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestLatestReleaseProxy(t *testing.T) { - gin.SetMode(gin.TestMode) - common.RedisEnabled = false - setupTestDB(t) - - originalClient := service.UpdateHTTPClientForTest() - service.SetUpdateHTTPClientForTest(&http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "https://api.github.com/repos/Rain-kl/OpenFlare/releases/latest" { - t.Fatalf("unexpected request url: %s", req.URL.String()) - } - if req.Header.Get("Accept") != "application/vnd.github+json" { - t.Fatalf("unexpected accept header: %s", req.Header.Get("Accept")) - } - if req.Header.Get("User-Agent") != "OpenFlare-Server" { - t.Fatalf("unexpected user-agent header: %s", req.Header.Get("User-Agent")) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{ - "tag_name":"v1.2.3", - "body":"release notes", - "html_url":"https://github.com/Rain-kl/OpenFlare/releases/tag/v1.2.3", - "published_at":"2026-03-11T00:00:00Z" - }`)), - }, nil - }), - }) - t.Cleanup(func() { - service.SetUpdateHTTPClientForTest(originalClient) - }) - - engine := gin.New() - engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) - router.SetApiRouter(engine) - - loginBody, err := json.Marshal(map[string]string{ - "username": "root", - "password": "123456", - }) - if err != nil { - t.Fatalf("failed to marshal login body: %v", err) - } - loginReq := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(loginBody)) - loginReq.Header.Set("Content-Type", "application/json") - loginRecorder := httptest.NewRecorder() - engine.ServeHTTP(loginRecorder, loginReq) - if loginRecorder.Code != http.StatusOK { - t.Fatalf("unexpected login status code: %d", loginRecorder.Code) - } - var loginResp apiResponse - if err = json.Unmarshal(loginRecorder.Body.Bytes(), &loginResp); err != nil { - t.Fatalf("failed to decode login response: %v", err) - } - var loginUser struct { - Token string `json:"token"` - } - if err = json.Unmarshal(loginResp.Data, &loginUser); err != nil { - t.Fatalf("failed to decode login user: %v", err) - } - if loginUser.Token == "" { - t.Fatal("expected OpenFlare-Token after login") - } - - req := httptest.NewRequest(http.MethodGet, "/api/update/latest-release", nil) - req.Header.Set("OpenFlare-Token", loginUser.Token) - - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected status code: %d", recorder.Code) - } - - var resp apiResponse - if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if !resp.Success { - t.Fatalf("expected success response, got message: %s", resp.Message) - } - - var data map[string]any - if err := json.Unmarshal(resp.Data, &data); err != nil { - t.Fatalf("failed to decode response data: %v", err) - } - if data["tag_name"] != "v1.2.3" { - t.Fatalf("unexpected tag_name: %#v", data["tag_name"]) - } - if data["current_version"] != common.Version { - t.Fatalf("unexpected current_version: %#v", data["current_version"]) - } -} - -func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, string) { - t.Helper() - gin.SetMode(gin.TestMode) - common.RedisEnabled = false - originalCapLoginEnabled := common.CapLoginEnabled - common.CapLoginEnabled = false - t.Cleanup(func() { - common.CapLoginEnabled = originalCapLoginEnabled - }) - setupTestDB(t) - - engine := gin.New() - engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) - router.SetApiRouter(engine) - - loginBody, err := json.Marshal(map[string]string{ - "username": "root", - "password": "123456", - }) - if err != nil { - t.Fatalf("failed to marshal login body: %v", err) - } - loginReq := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(loginBody)) - loginReq.Header.Set("Content-Type", "application/json") - loginRecorder := httptest.NewRecorder() - engine.ServeHTTP(loginRecorder, loginReq) - if loginRecorder.Code != http.StatusOK { - t.Fatalf("unexpected login status code: %d", loginRecorder.Code) - } - var loginResp apiResponse - if err = json.Unmarshal(loginRecorder.Body.Bytes(), &loginResp); err != nil { - t.Fatalf("failed to decode login response: %v", err) - } - var loginUser struct { - Token string `json:"token"` - } - if err = json.Unmarshal(loginResp.Data, &loginUser); err != nil { - t.Fatalf("failed to decode login user: %v", err) - } - if loginUser.Token == "" { - t.Fatal("expected OpenFlare-Token after login") - } - - return engine, loginUser.Token -} - -func fakeManualServerBinary(version string) (string, []byte) { - if runtime.GOOS == "windows" { - return "openflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n") - } - return "openflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n") -} - -func TestManualUploadRoute(t *testing.T) { - originalVersion := common.Version - common.Version = "v0.4.0" - t.Cleanup(func() { - common.Version = originalVersion - service.SetServerBinaryUpgradeExecutorForTest(nil) - service.SetServerUpgradeDispatchDelayForTest(500 * time.Millisecond) - }) - - engine, token := loginRootAndBuildEngine(t) - fileName, content := fakeManualServerBinary("v0.5.0") - - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("binary", fileName) - if err != nil { - t.Fatalf("failed to create form file: %v", err) - } - if _, err = part.Write(content); err != nil { - t.Fatalf("failed to write upload content: %v", err) - } - if err = writer.Close(); err != nil { - t.Fatalf("failed to close multipart writer: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("OpenFlare-Token", token) - - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - if recorder.Code != http.StatusOK { - t.Fatalf("unexpected status code: %d", recorder.Code) - } - - var resp apiResponse - if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to decode response: %v", err) - } - if resp.Success { - t.Fatal("expected failure response for disabled manual upload feature") - } - if resp.Message != "手动升级功能已禁用" { - t.Fatalf("unexpected failure message: %s", resp.Message) - } -} - -func TestManualUpgradeConfirmRoute(t *testing.T) { - gin.SetMode(gin.TestMode) - common.RedisEnabled = false - setupTestDB(t) - - engine, token := loginRootAndBuildEngine(t) - - confirmBody, err := json.Marshal(map[string]string{"upload_token": "fake-token"}) - if err != nil { - t.Fatalf("failed to marshal confirm body: %v", err) - } - confirmReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upgrade", bytes.NewReader(confirmBody)) - confirmReq.Header.Set("Content-Type", "application/json") - confirmReq.Header.Set("OpenFlare-Token", token) - - confirmRecorder := httptest.NewRecorder() - engine.ServeHTTP(confirmRecorder, confirmReq) - if confirmRecorder.Code != http.StatusOK { - t.Fatalf("unexpected confirm status code: %d", confirmRecorder.Code) - } - - var confirmResp apiResponse - if err = json.Unmarshal(confirmRecorder.Body.Bytes(), &confirmResp); err != nil { - t.Fatalf("failed to decode confirm response: %v", err) - } - if confirmResp.Success { - t.Fatal("expected failure response for disabled manual upgrade feature") - } - if confirmResp.Message != "手动升级功能已禁用" { - t.Fatalf("unexpected failure message: %s", confirmResp.Message) - } -} diff --git a/Wavelet/internal/router/v1/admin.go b/openflare-server/internal/router/v1/admin.go similarity index 100% rename from Wavelet/internal/router/v1/admin.go rename to openflare-server/internal/router/v1/admin.go diff --git a/Wavelet/internal/router/v1/custom.go b/openflare-server/internal/router/v1/custom.go similarity index 100% rename from Wavelet/internal/router/v1/custom.go rename to openflare-server/internal/router/v1/custom.go diff --git a/Wavelet/internal/router/v1/openflare/openflare.go b/openflare-server/internal/router/v1/openflare/openflare.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/openflare.go rename to openflare-server/internal/router/v1/openflare/openflare.go diff --git a/Wavelet/internal/router/v1/openflare/register_agent.go b/openflare-server/internal/router/v1/openflare/register_agent.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_agent.go rename to openflare-server/internal/router/v1/openflare/register_agent.go diff --git a/Wavelet/internal/router/v1/openflare/register_apply_log.go b/openflare-server/internal/router/v1/openflare/register_apply_log.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_apply_log.go rename to openflare-server/internal/router/v1/openflare/register_apply_log.go diff --git a/Wavelet/internal/router/v1/openflare/register_config_version.go b/openflare-server/internal/router/v1/openflare/register_config_version.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_config_version.go rename to openflare-server/internal/router/v1/openflare/register_config_version.go diff --git a/Wavelet/internal/router/v1/openflare/register_dashboard.go b/openflare-server/internal/router/v1/openflare/register_dashboard.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_dashboard.go rename to openflare-server/internal/router/v1/openflare/register_dashboard.go diff --git a/Wavelet/internal/router/v1/openflare/register_node.go b/openflare-server/internal/router/v1/openflare/register_node.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_node.go rename to openflare-server/internal/router/v1/openflare/register_node.go diff --git a/Wavelet/internal/router/v1/openflare/register_observability.go b/openflare-server/internal/router/v1/openflare/register_observability.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_observability.go rename to openflare-server/internal/router/v1/openflare/register_observability.go diff --git a/Wavelet/internal/router/v1/openflare/register_option.go b/openflare-server/internal/router/v1/openflare/register_option.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_option.go rename to openflare-server/internal/router/v1/openflare/register_option.go diff --git a/Wavelet/internal/router/v1/openflare/register_origin.go b/openflare-server/internal/router/v1/openflare/register_origin.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_origin.go rename to openflare-server/internal/router/v1/openflare/register_origin.go diff --git a/Wavelet/internal/router/v1/openflare/register_pages.go b/openflare-server/internal/router/v1/openflare/register_pages.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_pages.go rename to openflare-server/internal/router/v1/openflare/register_pages.go diff --git a/Wavelet/internal/router/v1/openflare/register_proxy_route.go b/openflare-server/internal/router/v1/openflare/register_proxy_route.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_proxy_route.go rename to openflare-server/internal/router/v1/openflare/register_proxy_route.go diff --git a/Wavelet/internal/router/v1/openflare/register_relay_flared.go b/openflare-server/internal/router/v1/openflare/register_relay_flared.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_relay_flared.go rename to openflare-server/internal/router/v1/openflare/register_relay_flared.go diff --git a/Wavelet/internal/router/v1/openflare/register_tls.go b/openflare-server/internal/router/v1/openflare/register_tls.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_tls.go rename to openflare-server/internal/router/v1/openflare/register_tls.go diff --git a/Wavelet/internal/router/v1/openflare/register_waf.go b/openflare-server/internal/router/v1/openflare/register_waf.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/register_waf.go rename to openflare-server/internal/router/v1/openflare/register_waf.go diff --git a/Wavelet/internal/router/v1/openflare/v1.go b/openflare-server/internal/router/v1/openflare/v1.go similarity index 100% rename from Wavelet/internal/router/v1/openflare/v1.go rename to openflare-server/internal/router/v1/openflare/v1.go diff --git a/Wavelet/internal/router/v1/user.go b/openflare-server/internal/router/v1/user.go similarity index 100% rename from Wavelet/internal/router/v1/user.go rename to openflare-server/internal/router/v1/user.go diff --git a/Wavelet/internal/router/v1/v1.go b/openflare-server/internal/router/v1/v1.go similarity index 100% rename from Wavelet/internal/router/v1/v1.go rename to openflare-server/internal/router/v1/v1.go diff --git a/openflare-server/internal/router/web-router.go b/openflare-server/internal/router/web-router.go deleted file mode 100644 index bd63b207..00000000 --- a/openflare-server/internal/router/web-router.go +++ /dev/null @@ -1,104 +0,0 @@ -package router - -import ( - "embed" - "io/fs" - "net/http" - pathpkg "path" - "strings" - - "github.com/rain-kl/openflare/openflare-server/internal/middleware" - "github.com/rain-kl/openflare/openflare-server/internal/utils/embedfs" - - "github.com/gin-contrib/static" - "github.com/gin-gonic/gin" -) - -func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { - exportedBuildFS, err := fs.Sub(buildFS, "web/build") - if err != nil { - panic(err) - } - - router.Use(middleware.GlobalWebRateLimit()) - router.Use(normalizeStaticExportDataNavigation()) - router.Use(middleware.Cache()) - router.Use(static.Serve("/", embedfs.EmbedFolder(buildFS, "web/build"))) - router.NoRoute(func(c *gin.Context) { - if serveExportedPage(c, exportedBuildFS) { - return - } - - if isStaticAssetRequest(c.Request.URL.Path) { - c.Status(http.StatusNotFound) - return - } - - c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage) - }) -} - -func serveExportedPage(c *gin.Context, buildFS fs.FS) bool { - requestPath := strings.Trim(c.Request.URL.Path, "/") - if isOAuthCallbackPath(requestPath) { - requestPath = "oauth/callback" - } - - candidates := []string{"index.html"} - if requestPath != "" { - candidates = []string{ - requestPath + ".html", - pathpkg.Join(requestPath, "index.html"), - } - } - - for _, candidate := range candidates { - content, err := fs.ReadFile(buildFS, candidate) - if err == nil { - c.Data(http.StatusOK, "text/html; charset=utf-8", content) - return true - } - } - - return false -} - -func isOAuthCallbackPath(requestPath string) bool { - if !strings.HasPrefix(requestPath, "oauth/") || strings.Count(requestPath, "/") != 1 { - return false - } - source := strings.TrimPrefix(requestPath, "oauth/") - switch source { - case "", "callback", "link": - return false - default: - return true - } -} - -func normalizeStaticExportDataNavigation() gin.HandlerFunc { - return func(c *gin.Context) { - requestPath := c.Request.URL.Path - if strings.HasSuffix(requestPath, ".txt") && isDocumentNavigationRequest(c.Request) { - normalizedPath := strings.TrimSuffix(requestPath, ".txt") - if normalizedPath == "" { - normalizedPath = "/" - } - c.Request.URL.Path = normalizedPath - } - - c.Next() - } -} - -func isDocumentNavigationRequest(request *http.Request) bool { - if request.Header.Get("Sec-Fetch-Mode") == "navigate" || request.Header.Get("Sec-Fetch-Dest") == "document" { - return true - } - - return strings.Contains(request.Header.Get("Accept"), "text/html") -} - -func isStaticAssetRequest(requestPath string) bool { - return strings.HasPrefix(requestPath, "/_next/") || pathpkg.Ext(requestPath) != "" -} diff --git a/openflare-server/internal/router/web-router_test.go b/openflare-server/internal/router/web-router_test.go deleted file mode 100644 index 12b1b76e..00000000 --- a/openflare-server/internal/router/web-router_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package router - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/rain-kl/openflare/openflare-server/internal/middleware" - - "github.com/gin-gonic/gin" -) - -func TestNormalizeStaticExportDataNavigationRewritesDocumentRequests(t *testing.T) { - gin.SetMode(gin.TestMode) - engine := gin.New() - engine.Use(normalizeStaticExportDataNavigation()) - engine.GET("/*any", func(c *gin.Context) { - c.String(http.StatusOK, c.Request.URL.Path) - }) - - req := httptest.NewRequest(http.MethodGet, "/website.txt", nil) - req.Header.Set("Accept", "text/html,application/xhtml+xml") - req.Header.Set("Sec-Fetch-Mode", "navigate") - req.Header.Set("Sec-Fetch-Dest", "document") - - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", recorder.Code) - } - - if body := recorder.Body.String(); body != "/website" { - t.Fatalf("expected document request to be rewritten to /website, got %q", body) - } -} - -func TestNormalizeStaticExportDataNavigationKeepsDataRequests(t *testing.T) { - gin.SetMode(gin.TestMode) - engine := gin.New() - engine.Use(normalizeStaticExportDataNavigation()) - engine.GET("/*any", func(c *gin.Context) { - c.String(http.StatusOK, c.Request.URL.Path) - }) - - req := httptest.NewRequest(http.MethodGet, "/website.txt", nil) - req.Header.Set("Accept", "*/*") - req.Header.Set("Sec-Fetch-Mode", "cors") - req.Header.Set("Sec-Fetch-Dest", "empty") - - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", recorder.Code) - } - - if body := recorder.Body.String(); body != "/website.txt" { - t.Fatalf("expected data request to keep txt path, got %q", body) - } -} - -func TestCacheHeadersDisableExportedPageCaching(t *testing.T) { - gin.SetMode(gin.TestMode) - engine := gin.New() - engine.Use(middleware.Cache()) - engine.GET("/website", func(c *gin.Context) { - c.String(http.StatusOK, "ok") - }) - - req := httptest.NewRequest(http.MethodGet, "/website", nil) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - - if got := recorder.Header().Get("Cache-Control"); got != "no-store, no-cache, must-revalidate" { - t.Fatalf("unexpected cache-control for page: %q", got) - } -} - -func TestCacheHeadersKeepImmutableStaticAssets(t *testing.T) { - gin.SetMode(gin.TestMode) - engine := gin.New() - engine.Use(middleware.Cache()) - engine.GET("/_next/static/app.js", func(c *gin.Context) { - c.String(http.StatusOK, "ok") - }) - - req := httptest.NewRequest(http.MethodGet, "/_next/static/app.js", nil) - recorder := httptest.NewRecorder() - engine.ServeHTTP(recorder, req) - - if got := recorder.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" { - t.Fatalf("unexpected cache-control for static asset: %q", got) - } -} - -func TestOAuthCallbackPathMatchesSourceNames(t *testing.T) { - cases := map[string]bool{ - "oauth/github": true, - "oauth/oidc-main": true, - "oauth/1": true, - "oauth/callback": false, - "oauth/link": false, - "oauth": false, - } - - for requestPath, expected := range cases { - if got := isOAuthCallbackPath(requestPath); got != expected { - t.Fatalf("expected %s match=%v, got %v", requestPath, expected, got) - } - } -} diff --git a/openflare-server/internal/service/access_log.go b/openflare-server/internal/service/access_log.go deleted file mode 100644 index e230364e..00000000 --- a/openflare-server/internal/service/access_log.go +++ /dev/null @@ -1,608 +0,0 @@ -package service - -import ( - "errors" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -const ( - defaultAccessLogPageSize = 20 - maxAccessLogPageSize = 200 - defaultAccessLogSortBy = "logged_at" - defaultAccessLogSortOrder = "desc" - defaultAccessLogFoldMinute = 3 - defaultIPTrendHours = 24 - defaultIPTrendBucketMinute = 30 - maxIPTrendHours = 168 - nodeAccessLogRetentionDays = 90 -) - -type AccessLogQuery struct { - NodeID string `json:"node_id"` - RemoteAddr string `json:"remote_addr"` - Host string `json:"host"` - Path string `json:"path"` - Page int `json:"page"` - PageSize int `json:"page_size"` - SortBy string `json:"sort_by"` - SortOrder string `json:"sort_order"` - FoldMinutes int `json:"fold_minutes"` -} - -type AccessLogView struct { - ID uint `json:"id"` - NodeID string `json:"node_id"` - NodeName string `json:"node_name"` - LoggedAt time.Time `json:"logged_at"` - RemoteAddr string `json:"remote_addr"` - Region string `json:"region"` - Host string `json:"host"` - Path string `json:"path"` - StatusCode int `json:"status_code"` -} - -type AccessLogList struct { - Items []AccessLogView `json:"items"` - Page int `json:"page"` - PageSize int `json:"page_size"` - HasMore bool `json:"has_more"` - TotalRecord int64 `json:"total_record"` - TotalIP int64 `json:"total_ip"` -} - -type FoldedAccessLogView struct { - BucketStartedAt time.Time `json:"bucket_started_at"` - RequestCount int64 `json:"request_count"` - UniqueIPCount int64 `json:"unique_ip_count"` - UniqueHostCount int64 `json:"unique_host_count"` - SuccessCount int64 `json:"success_count"` - ClientErrorCount int64 `json:"client_error_count"` - ServerErrorCount int64 `json:"server_error_count"` -} - -type FoldedAccessLogList struct { - Items []FoldedAccessLogView `json:"items"` - Page int `json:"page"` - PageSize int `json:"page_size"` - HasMore bool `json:"has_more"` - TotalBucket int64 `json:"total_bucket"` - TotalRecord int64 `json:"total_record"` - TotalIP int64 `json:"total_ip"` - FoldMinutes int `json:"fold_minutes"` -} - -type FoldedAccessLogIPQuery struct { - NodeID string `json:"node_id"` - RemoteAddr string `json:"remote_addr"` - Host string `json:"host"` - Path string `json:"path"` - BucketStartedAt string `json:"bucket_started_at"` - FoldMinutes int `json:"fold_minutes"` - Page int `json:"page"` - PageSize int `json:"page_size"` - SortBy string `json:"sort_by"` - SortOrder string `json:"sort_order"` -} - -type FoldedAccessLogIPView struct { - RemoteAddr string `json:"remote_addr"` - RequestCount int64 `json:"request_count"` - SuccessCount int64 `json:"success_count"` - ClientErrorCount int64 `json:"client_error_count"` - ServerErrorCount int64 `json:"server_error_count"` - LastSeenAt time.Time `json:"last_seen_at"` -} - -type FoldedAccessLogIPList struct { - Items []FoldedAccessLogIPView `json:"items"` - Page int `json:"page"` - PageSize int `json:"page_size"` - HasMore bool `json:"has_more"` - TotalIP int64 `json:"total_ip"` - BucketStartedAt time.Time `json:"bucket_started_at"` - FoldMinutes int `json:"fold_minutes"` - SortBy string `json:"sort_by"` - SortOrder string `json:"sort_order"` -} - -type AccessLogIPSummaryQuery struct { - NodeID string `json:"node_id"` - RemoteAddr string `json:"remote_addr"` - Host string `json:"host"` - Page int `json:"page"` - PageSize int `json:"page_size"` - SortBy string `json:"sort_by"` - SortOrder string `json:"sort_order"` -} - -type AccessLogIPSummaryView struct { - RemoteAddr string `json:"remote_addr"` - TotalRequests int64 `json:"total_requests"` - RecentRequests int64 `json:"recent_requests"` - LastSeenAt time.Time `json:"last_seen_at"` -} - -type AccessLogIPSummaryList struct { - Items []AccessLogIPSummaryView `json:"items"` - Page int `json:"page"` - PageSize int `json:"page_size"` - HasMore bool `json:"has_more"` - TotalIP int64 `json:"total_ip"` - SortBy string `json:"sort_by"` - SortOrder string `json:"sort_order"` -} - -type AccessLogIPTrendQuery struct { - NodeID string `json:"node_id"` - RemoteAddr string `json:"remote_addr"` - Host string `json:"host"` - Hours int `json:"hours"` - BucketMinutes int `json:"bucket_minutes"` -} - -type AccessLogIPTrendPoint struct { - BucketStartedAt time.Time `json:"bucket_started_at"` - RequestCount int64 `json:"request_count"` -} - -type AccessLogIPTrendView struct { - RemoteAddr string `json:"remote_addr"` - Hours int `json:"hours"` - BucketMinutes int `json:"bucket_minutes"` - Points []AccessLogIPTrendPoint `json:"points"` -} - -type AccessLogCleanupInput struct { - RetentionDays int `json:"retention_days"` -} - -type AccessLogCleanupResult struct { - RetentionDays int `json:"retention_days"` - DeletedCount int64 `json:"deleted_count"` - Cutoff time.Time `json:"cutoff"` -} - -func ListAccessLogs(input AccessLogQuery) (*AccessLogList, error) { - normalized := normalizeAccessLogQuery(input) - modelQuery := buildModelAccessLogQuery(normalized) - logs, err := model.ListNodeAccessLogs(modelQuery) - if err != nil { - return nil, err - } - totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery) - if err != nil { - return nil, err - } - nodeNames, err := listNodeNameMap(logs) - if err != nil { - return nil, err - } - views := make([]AccessLogView, 0, len(logs)) - for _, item := range logs { - if item == nil { - continue - } - views = append(views, AccessLogView{ - ID: item.ID, - NodeID: item.NodeID, - NodeName: nodeNames[item.NodeID], - LoggedAt: item.LoggedAt, - RemoteAddr: item.RemoteAddr, - Region: item.Region, - Host: item.Host, - Path: item.Path, - StatusCode: item.StatusCode, - }) - } - return &AccessLogList{ - Items: views, - Page: normalized.Page, - PageSize: normalized.PageSize, - HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalRecords, - TotalRecord: totalRecords, - TotalIP: totalIPs, - }, nil -} - -func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) { - normalized := normalizeAccessLogQuery(input) - foldMinutes, err := normalizeFoldMinutes(normalized.FoldMinutes) - if err != nil { - return nil, err - } - modelQuery := buildModelAccessLogQuery(normalized) - bucketQuery := model.NodeAccessLogBucketQuery{ - NodeID: modelQuery.NodeID, - RemoteAddr: modelQuery.RemoteAddr, - Host: modelQuery.Host, - Path: modelQuery.Path, - Since: modelQuery.Since, - Page: normalized.Page, - PageSize: normalized.PageSize, - SortBy: normalizeFoldSortBy(input.SortBy), - SortOrder: normalized.SortOrder, - FoldMinutes: foldMinutes, - } - items, err := model.ListNodeAccessLogBuckets(bucketQuery) - if err != nil { - return nil, err - } - totalBuckets, err := model.CountNodeAccessLogBuckets(bucketQuery) - if err != nil { - return nil, err - } - totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery) - if err != nil { - return nil, err - } - views := make([]FoldedAccessLogView, 0, len(items)) - for _, item := range items { - if item == nil { - continue - } - views = append(views, FoldedAccessLogView{ - BucketStartedAt: time.Unix(item.BucketEpoch, 0).UTC(), - RequestCount: item.RequestCount, - UniqueIPCount: item.UniqueIPCount, - UniqueHostCount: item.UniqueHostCount, - SuccessCount: item.SuccessCount, - ClientErrorCount: item.ClientErrorCount, - ServerErrorCount: item.ServerErrorCount, - }) - } - return &FoldedAccessLogList{ - Items: views, - Page: normalized.Page, - PageSize: normalized.PageSize, - HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalBuckets, - TotalBucket: totalBuckets, - TotalRecord: totalRecords, - TotalIP: totalIPs, - FoldMinutes: foldMinutes, - }, nil -} - -func ListFoldedAccessLogIPs(input FoldedAccessLogIPQuery) (*FoldedAccessLogIPList, error) { - normalized, bucketStartedAt, err := normalizeFoldedAccessLogIPQuery(input) - if err != nil { - return nil, err - } - modelQuery := model.NodeAccessLogBucketIPQuery{ - NodeID: normalized.NodeID, - RemoteAddr: normalized.RemoteAddr, - Host: normalized.Host, - Path: normalized.Path, - BucketStartedAt: bucketStartedAt, - FoldMinutes: normalized.FoldMinutes, - Page: normalized.Page, - PageSize: normalized.PageSize, - SortBy: normalized.SortBy, - SortOrder: normalized.SortOrder, - } - items, err := model.ListNodeAccessLogBucketIPs(modelQuery) - if err != nil { - return nil, err - } - totalIP, err := model.CountNodeAccessLogBucketIPs(modelQuery) - if err != nil { - return nil, err - } - views := make([]FoldedAccessLogIPView, 0, len(items)) - for _, item := range items { - if item == nil { - continue - } - views = append(views, FoldedAccessLogIPView{ - RemoteAddr: item.RemoteAddr, - RequestCount: item.RequestCount, - SuccessCount: item.SuccessCount, - ClientErrorCount: item.ClientErrorCount, - ServerErrorCount: item.ServerErrorCount, - LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(), - }) - } - return &FoldedAccessLogIPList{ - Items: views, - Page: normalized.Page, - PageSize: normalized.PageSize, - HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP, - TotalIP: totalIP, - BucketStartedAt: bucketStartedAt, - FoldMinutes: normalized.FoldMinutes, - SortBy: normalized.SortBy, - SortOrder: normalized.SortOrder, - }, nil -} - -func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) { - normalized := normalizeAccessLogIPSummaryQuery(input) - since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow) - recentSince := time.Now().UTC().Add(-3 * time.Hour) - query := model.NodeAccessLogIPSummaryQuery{ - NodeID: strings.TrimSpace(normalized.NodeID), - RemoteAddr: strings.TrimSpace(normalized.RemoteAddr), - Host: strings.TrimSpace(normalized.Host), - Since: since, - Page: normalized.Page, - PageSize: normalized.PageSize, - SortBy: normalized.SortBy, - SortOrder: normalized.SortOrder, - } - items, err := model.ListNodeAccessLogIPSummaries(query, recentSince) - if err != nil { - return nil, err - } - totalIP, err := model.CountNodeAccessLogIPSummaries(query) - if err != nil { - return nil, err - } - views := make([]AccessLogIPSummaryView, 0, len(items)) - for _, item := range items { - if item == nil { - continue - } - views = append(views, AccessLogIPSummaryView{ - RemoteAddr: item.RemoteAddr, - TotalRequests: item.TotalRequests, - RecentRequests: item.RecentRequests, - LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(), - }) - } - return &AccessLogIPSummaryList{ - Items: views, - Page: normalized.Page, - PageSize: normalized.PageSize, - HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP, - TotalIP: totalIP, - SortBy: normalized.SortBy, - SortOrder: normalized.SortOrder, - }, nil -} - -func GetAccessLogIPTrend(input AccessLogIPTrendQuery) (*AccessLogIPTrendView, error) { - normalized, err := normalizeAccessLogIPTrendQuery(input) - if err != nil { - return nil, err - } - points, err := model.ListNodeAccessLogIPTrend(model.NodeAccessLogIPTrendQuery{ - NodeID: strings.TrimSpace(normalized.NodeID), - RemoteAddr: strings.TrimSpace(normalized.RemoteAddr), - Host: strings.TrimSpace(normalized.Host), - Since: time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour), - BucketMinutes: normalized.BucketMinutes, - }) - if err != nil { - return nil, err - } - pointMap := make(map[int64]int64, len(points)) - for _, item := range points { - if item == nil { - continue - } - pointMap[item.BucketEpoch] = item.RequestCount - } - bucketDuration := time.Duration(normalized.BucketMinutes) * time.Minute - start := time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour).Truncate(bucketDuration) - end := time.Now().UTC().Truncate(bucketDuration) - views := make([]AccessLogIPTrendPoint, 0, int(end.Sub(start)/bucketDuration)+1) - for cursor := start; !cursor.After(end); cursor = cursor.Add(bucketDuration) { - views = append(views, AccessLogIPTrendPoint{ - BucketStartedAt: cursor, - RequestCount: pointMap[cursor.Unix()], - }) - } - return &AccessLogIPTrendView{ - RemoteAddr: normalized.RemoteAddr, - Hours: normalized.Hours, - BucketMinutes: normalized.BucketMinutes, - Points: views, - }, nil -} - -func CleanupAccessLogs(input AccessLogCleanupInput) (*AccessLogCleanupResult, error) { - if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays { - return nil, errors.New("retention_days 必须在 1 到 90 之间") - } - cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour) - deleted, err := model.DeleteNodeAccessLogsBefore(cutoff) - if err != nil { - return nil, err - } - return &AccessLogCleanupResult{ - RetentionDays: input.RetentionDays, - DeletedCount: deleted, - Cutoff: cutoff, - }, nil -} - -func buildModelAccessLogQuery(input AccessLogQuery) model.NodeAccessLogQuery { - return model.NodeAccessLogQuery{ - NodeID: strings.TrimSpace(input.NodeID), - RemoteAddr: strings.TrimSpace(input.RemoteAddr), - Host: strings.TrimSpace(input.Host), - Path: strings.TrimSpace(input.Path), - Since: time.Now().UTC().Add(-nodeAccessLogRetentionWindow), - Page: input.Page, - PageSize: input.PageSize, - SortBy: input.SortBy, - SortOrder: input.SortOrder, - } -} - -func listNodeNameMap(logs []*model.NodeAccessLog) (map[string]string, error) { - nodeIDs := make([]string, 0, len(logs)) - seen := make(map[string]struct{}, len(logs)) - for _, item := range logs { - if item == nil || item.NodeID == "" { - continue - } - if _, exists := seen[item.NodeID]; exists { - continue - } - seen[item.NodeID] = struct{}{} - nodeIDs = append(nodeIDs, item.NodeID) - } - nodes, err := model.ListNodesByNodeIDs(nodeIDs) - if err != nil { - return nil, err - } - result := make(map[string]string, len(nodes)) - for _, node := range nodes { - if node == nil { - continue - } - result[node.NodeID] = node.Name - } - return result, nil -} - -func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery { - return AccessLogQuery{ - NodeID: strings.TrimSpace(input.NodeID), - RemoteAddr: strings.TrimSpace(input.RemoteAddr), - Host: strings.TrimSpace(input.Host), - Path: strings.TrimSpace(input.Path), - Page: normalizeAccessLogPage(input.Page), - PageSize: normalizeAccessLogPageSize(input.PageSize), - SortBy: normalizeAccessLogSortBy(input.SortBy), - SortOrder: normalizeAccessLogSortOrder(input.SortOrder), - FoldMinutes: input.FoldMinutes, - } -} - -func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIPSummaryQuery { - return AccessLogIPSummaryQuery{ - NodeID: strings.TrimSpace(input.NodeID), - RemoteAddr: strings.TrimSpace(input.RemoteAddr), - Host: strings.TrimSpace(input.Host), - Page: normalizeAccessLogPage(input.Page), - PageSize: normalizeAccessLogPageSize(input.PageSize), - SortBy: normalizeIPSummarySortBy(input.SortBy), - SortOrder: normalizeAccessLogSortOrder(input.SortOrder), - } -} - -func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccessLogIPQuery, time.Time, error) { - foldMinutes, err := normalizeFoldMinutes(input.FoldMinutes) - if err != nil { - return FoldedAccessLogIPQuery{}, time.Time{}, err - } - bucketStartedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(input.BucketStartedAt)) - if err != nil { - return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at 必须为 RFC3339 时间") - } - normalizedSortBy := strings.TrimSpace(input.SortBy) - switch normalizedSortBy { - case "last_seen_at", "remote_addr": - default: - normalizedSortBy = "request_count" - } - return FoldedAccessLogIPQuery{ - NodeID: strings.TrimSpace(input.NodeID), - RemoteAddr: strings.TrimSpace(input.RemoteAddr), - Host: strings.TrimSpace(input.Host), - Path: strings.TrimSpace(input.Path), - BucketStartedAt: strings.TrimSpace(input.BucketStartedAt), - FoldMinutes: foldMinutes, - Page: normalizeAccessLogPage(input.Page), - PageSize: normalizeAccessLogPageSize(input.PageSize), - SortBy: normalizedSortBy, - SortOrder: normalizeAccessLogSortOrder(input.SortOrder), - }, bucketStartedAt.UTC(), nil -} - -func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) { - remoteAddr := strings.TrimSpace(input.RemoteAddr) - if remoteAddr == "" { - return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空") - } - hours := input.Hours - if hours <= 0 { - hours = defaultIPTrendHours - } - if hours > maxIPTrendHours { - hours = maxIPTrendHours - } - bucketMinutes := input.BucketMinutes - if bucketMinutes <= 0 { - bucketMinutes = defaultIPTrendBucketMinute - } - switch bucketMinutes { - case 5, 10, 15, 30, 60: - default: - return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60") - } - return AccessLogIPTrendQuery{ - NodeID: strings.TrimSpace(input.NodeID), - RemoteAddr: remoteAddr, - Host: strings.TrimSpace(input.Host), - Hours: hours, - BucketMinutes: bucketMinutes, - }, nil -} - -func normalizeAccessLogPage(page int) int { - if page < 0 { - return 0 - } - return page -} - -func normalizeAccessLogPageSize(pageSize int) int { - if pageSize <= 0 { - return defaultAccessLogPageSize - } - if pageSize > maxAccessLogPageSize { - return maxAccessLogPageSize - } - return pageSize -} - -func normalizeAccessLogSortBy(sortBy string) string { - switch strings.TrimSpace(sortBy) { - case "status_code", "remote_addr", "host", "path": - return strings.TrimSpace(sortBy) - default: - return defaultAccessLogSortBy - } -} - -func normalizeAccessLogSortOrder(sortOrder string) string { - if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") { - return "asc" - } - return defaultAccessLogSortOrder -} - -func normalizeFoldSortBy(sortBy string) string { - switch strings.TrimSpace(sortBy) { - case "request_count": - return "request_count" - default: - return "bucket_started_at" - } -} - -func normalizeIPSummarySortBy(sortBy string) string { - switch strings.TrimSpace(sortBy) { - case "recent_requests", "last_seen_at", "remote_addr": - return strings.TrimSpace(sortBy) - default: - return "total_requests" - } -} - -func normalizeFoldMinutes(value int) (int, error) { - if value <= 0 { - return defaultAccessLogFoldMinute, nil - } - switch value { - case 3, 5: - return value, nil - default: - return 0, errors.New("fold_minutes 仅支持 3 或 5") - } -} diff --git a/openflare-server/internal/service/access_log_region.go b/openflare-server/internal/service/access_log_region.go deleted file mode 100644 index 60cf26db..00000000 --- a/openflare-server/internal/service/access_log_region.go +++ /dev/null @@ -1,92 +0,0 @@ -package service - -import ( - "log/slog" - "net" - "strings" - - "github.com/rain-kl/openflare/pkg/geoip" -) - -var accessLogGeoProviderFactory = func() (geoip.GeoIPService, error) { - return geoip.NewMaxMindGeoIPService() -} - -type accessLogRegionResolver struct { - provider geoip.GeoIPService - cache map[string]string -} - -func newAccessLogRegionResolver() (*accessLogRegionResolver, error) { - provider, err := accessLogGeoProviderFactory() - if err != nil { - return nil, err - } - return &accessLogRegionResolver{ - provider: provider, - cache: make(map[string]string), - }, nil -} - -func (r *accessLogRegionResolver) Close() { - if r == nil || r.provider == nil { - return - } - if err := r.provider.Close(); err != nil { - slog.Warn("close access log geo provider failed", "error", err) - } -} - -func (r *accessLogRegionResolver) Resolve(rawIP string) string { - if r == nil || r.provider == nil { - return "" - } - normalizedIP := normalizeAccessLogIP(rawIP) - if normalizedIP == "" { - return "" - } - if cached, ok := r.cache[normalizedIP]; ok { - return cached - } - - info, err := r.provider.GetGeoInfo(net.ParseIP(normalizedIP)) - if err != nil || info == nil { - r.cache[normalizedIP] = "" - return "" - } - - region := strings.TrimSpace(info.Name) - if region == "" { - region = strings.TrimSpace(info.ISOCode) - } - r.cache[normalizedIP] = region - return region -} - -func normalizeAccessLogIP(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "" - } - - if ip := net.ParseIP(trimmed); ip != nil { - return ip.String() - } - - trimmed = strings.TrimPrefix(trimmed, "[") - trimmed = strings.TrimSuffix(trimmed, "]") - if ip := net.ParseIP(trimmed); ip != nil { - return ip.String() - } - - host, _, err := net.SplitHostPort(strings.TrimSpace(raw)) - if err != nil { - return "" - } - host = strings.TrimPrefix(host, "[") - host = strings.TrimSuffix(host, "]") - if ip := net.ParseIP(host); ip != nil { - return ip.String() - } - return "" -} diff --git a/openflare-server/internal/service/access_log_test.go b/openflare-server/internal/service/access_log_test.go deleted file mode 100644 index 38dbdb28..00000000 --- a/openflare-server/internal/service/access_log_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package service - -import ( - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestListAccessLogsIncludesSummaryTotals(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now() - if err := model.DB.Create(&model.Node{ - NodeID: "node-a", - Name: "edge-a", - }).Error; err != nil { - t.Fatalf("failed to seed node-a: %v", err) - } - if err := model.DB.Create(&model.Node{ - NodeID: "node-b", - Name: "edge-b", - }).Error; err != nil { - t.Fatalf("failed to seed node-b: %v", err) - } - - logs := []*model.NodeAccessLog{ - { - NodeID: "node-a", - LoggedAt: now.Add(-5 * time.Minute), - RemoteAddr: "1.1.1.1", - Region: "United States", - Host: "a.example.com", - Path: "/alpha", - StatusCode: 200, - }, - { - NodeID: "node-a", - LoggedAt: now.Add(-4 * time.Minute), - RemoteAddr: "2.2.2.2", - Region: "China", - Host: "a.example.com", - Path: "/beta", - StatusCode: 404, - }, - { - NodeID: "node-b", - LoggedAt: now.Add(-3 * time.Minute), - RemoteAddr: "1.1.1.1", - Region: "United States", - Host: "b.example.com", - Path: "/gamma", - StatusCode: 502, - }, - { - NodeID: "node-b", - LoggedAt: now.Add(-2 * time.Minute), - RemoteAddr: "", - Host: "b.example.com", - Path: "/delta", - StatusCode: 200, - }, - } - seedNodeAccessLogs(t, logs) - - result, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 2}) - if err != nil { - t.Fatalf("ListAccessLogs failed: %v", err) - } - if result.TotalRecord != 4 { - t.Fatalf("expected total_record=4, got %d", result.TotalRecord) - } - if result.TotalIP != 2 { - t.Fatalf("expected total_ip=2, got %d", result.TotalIP) - } - if len(result.Items) != 2 { - t.Fatalf("expected current page items=2, got %d", len(result.Items)) - } - if result.Items[1].Region == "" { - t.Fatalf("expected region to be returned, got %+v", result.Items[1]) - } - if !result.HasMore { - t.Fatal("expected has_more to be true") - } - - filtered, err := ListAccessLogs(AccessLogQuery{NodeID: "node-a", Page: 0, PageSize: 50}) - if err != nil { - t.Fatalf("ListAccessLogs filtered failed: %v", err) - } - if filtered.TotalRecord != 2 { - t.Fatalf("expected filtered total_record=2, got %d", filtered.TotalRecord) - } - if filtered.TotalIP != 2 { - t.Fatalf("expected filtered total_ip=2, got %d", filtered.TotalIP) - } - if len(filtered.Items) != 2 { - t.Fatalf("expected filtered items=2, got %d", len(filtered.Items)) - } -} - -func TestListAccessLogsUsesDefaultPageSize(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now() - if err := model.DB.Create(&model.Node{ - NodeID: "node-default-page-size", - Name: "edge-default-page-size", - }).Error; err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - logs := make([]*model.NodeAccessLog, 0, 25) - for index := range 25 { - logs = append(logs, &model.NodeAccessLog{ - NodeID: "node-default-page-size", - LoggedAt: now.Add(-time.Duration(index) * time.Minute), - RemoteAddr: "1.1.1.1", - Host: "example.com", - Path: "/default-page-size", - StatusCode: 200, - }) - } - seedNodeAccessLogs(t, logs) - - result, err := ListAccessLogs(AccessLogQuery{}) - if err != nil { - t.Fatalf("ListAccessLogs failed: %v", err) - } - if result.PageSize != 20 { - t.Fatalf("expected default page_size=20, got %d", result.PageSize) - } - if len(result.Items) != 20 { - t.Fatalf("expected current page items=20, got %d", len(result.Items)) - } - if !result.HasMore { - t.Fatal("expected has_more to be true") - } -} - -func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) { - setupServiceTestDB(t) - - now := time.Date(2026, 3, 19, 8, 12, 30, 0, time.UTC) - if err := model.DB.Create(&model.Node{ - NodeID: "node-folded", - Name: "edge-folded", - }).Error; err != nil { - t.Fatalf("failed to seed node: %v", err) - } - logs := []*model.NodeAccessLog{ - { - NodeID: "node-folded", - LoggedAt: now.Add(-4 * time.Minute), - RemoteAddr: "203.0.113.1", - Host: "alpha.example.com", - Path: "/first", - StatusCode: 200, - }, - { - NodeID: "node-folded", - LoggedAt: now.Add(-3 * time.Minute), - RemoteAddr: "203.0.113.1", - Host: "alpha.example.com", - Path: "/second", - StatusCode: 502, - }, - { - NodeID: "node-folded", - LoggedAt: now.Add(-2 * time.Minute), - RemoteAddr: "203.0.113.2", - Host: "beta.example.com", - Path: "/third", - StatusCode: 404, - }, - } - seedNodeAccessLogs(t, logs) - - folded, err := ListFoldedAccessLogs(AccessLogQuery{ - NodeID: "node-folded", - Page: 0, - PageSize: 10, - SortBy: "request_count", - SortOrder: "desc", - FoldMinutes: 5, - }) - if err != nil { - t.Fatalf("ListFoldedAccessLogs failed: %v", err) - } - if len(folded.Items) != 2 { - t.Fatalf("expected two folded buckets, got %+v", folded.Items) - } - if folded.TotalRecord != 3 || folded.TotalBucket != 2 { - t.Fatalf("unexpected folded totals: %+v", folded) - } - if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 { - t.Fatalf("unexpected folded request count sum: %+v", folded.Items) - } - if folded.Items[0].RequestCount != 2 { - t.Fatalf("expected folded buckets to sort by request_count desc, got %+v", folded.Items) - } - - bucketIPs, err := ListFoldedAccessLogIPs(FoldedAccessLogIPQuery{ - NodeID: "node-folded", - BucketStartedAt: folded.Items[0].BucketStartedAt.Format(time.RFC3339), - FoldMinutes: 5, - Page: 0, - PageSize: 10, - SortBy: "request_count", - SortOrder: "desc", - }) - if err != nil { - t.Fatalf("ListFoldedAccessLogIPs failed: %v", err) - } - if bucketIPs.TotalIP != 1 || len(bucketIPs.Items) != 1 { - t.Fatalf("expected one folded bucket IP row, got %+v", bucketIPs) - } - if bucketIPs.Items[0].RemoteAddr != "203.0.113.1" || bucketIPs.Items[0].RequestCount != 2 { - t.Fatalf("unexpected top folded bucket IP row: %+v", bucketIPs.Items[0]) - } - - ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{ - NodeID: "node-folded", - Page: 0, - PageSize: 10, - SortBy: "total_requests", - SortOrder: "desc", - }) - if err != nil { - t.Fatalf("ListAccessLogIPSummaries failed: %v", err) - } - if len(ipSummaries.Items) != 2 { - t.Fatalf("expected two ip summary rows, got %+v", ipSummaries.Items) - } - if ipSummaries.Items[0].RemoteAddr != "203.0.113.1" || ipSummaries.Items[0].TotalRequests != 2 { - t.Fatalf("unexpected top ip summary row: %+v", ipSummaries.Items[0]) - } -} - -func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - seedNodeAccessLogs(t, []*model.NodeAccessLog{ - { - NodeID: "node-cleanup", - LoggedAt: now.Add(-10 * 24 * time.Hour), - RemoteAddr: "203.0.113.9", - Host: "cleanup.example.com", - Path: "/old", - StatusCode: 200, - }, - { - NodeID: "node-cleanup", - LoggedAt: now.Add(-2 * 24 * time.Hour), - RemoteAddr: "203.0.113.10", - Host: "cleanup.example.com", - Path: "/recent", - StatusCode: 200, - }, - }) - - result, err := CleanupAccessLogs(AccessLogCleanupInput{RetentionDays: 7}) - if err != nil { - t.Fatalf("CleanupAccessLogs failed: %v", err) - } - if result.DeletedCount != 1 { - t.Fatalf("expected 1 deleted record, got %+v", result) - } - - remaining, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 10, NodeID: "node-cleanup"}) - if err != nil { - t.Fatalf("ListAccessLogs failed after cleanup: %v", err) - } - if len(remaining.Items) != 1 || remaining.Items[0].Path != "/recent" { - t.Fatalf("unexpected remaining logs after cleanup: %+v", remaining.Items) - } -} - -func TestPersistNodeAccessLogsTruncatesLongPath(t *testing.T) { - setupServiceTestDB(t) - - longPath := "/" + strings.Repeat("a", 140) - reportedAt := time.Now().UTC() - if err := persistNodeAccessLogs(model.DB, "node-truncate", []AgentNodeAccessLog{ - { - LoggedAtUnix: reportedAt.Unix(), - RemoteAddr: "203.0.113.10", - Host: "truncate.example.com", - Path: longPath, - StatusCode: 200, - }, - }, reportedAt); err != nil { - t.Fatalf("persistNodeAccessLogs failed: %v", err) - } - - logs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{ - NodeID: "node-truncate", - Page: 0, - PageSize: 10, - }) - if err != nil { - t.Fatalf("ListNodeAccessLogs failed: %v", err) - } - if len(logs) != 1 { - t.Fatalf("expected one stored log, got %+v", logs) - } - if got := len([]rune(logs[0].Path)); got != nodeAccessLogPathMaxLength { - t.Fatalf("expected truncated path length %d, got %d (%q)", nodeAccessLogPathMaxLength, got, logs[0].Path) - } -} - -func seedNodeAccessLogs(t *testing.T, logs []*model.NodeAccessLog) { - t.Helper() - for _, item := range logs { - if err := model.DB.Create(item).Error; err != nil { - t.Fatalf("failed to seed access log: %v", err) - } - } -} diff --git a/openflare-server/internal/service/agent.go b/openflare-server/internal/service/agent.go deleted file mode 100644 index 6528cae1..00000000 --- a/openflare-server/internal/service/agent.go +++ /dev/null @@ -1,529 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "log/slog" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -const ( - NodeStatusOnline = "online" - NodeStatusOffline = "offline" - NodeStatusPending = "pending" - ApplyResultOK = "success" - ApplyResultWarning = "warning" - ApplyResultFailed = "failed" - OpenrestyStatusHealthy = "healthy" - OpenrestyStatusUnhealthy = "unhealthy" - OpenrestyStatusUnknown = "unknown" -) - -type AgentNodePayload struct { - NodeID string `json:"node_id"` - Name string `json:"name"` - IP string `json:"ip"` - Version string `json:"version"` - ExtVersion string `json:"ext_version"` - CurrentVersion string `json:"current_version"` - LastError string `json:"last_error"` - OpenrestyStatus string `json:"openresty_status"` - OpenrestyMessage string `json:"openresty_message"` - Profile *AgentNodeSystemProfile `json:"profile,omitempty"` - Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"` - OpenrestyObservation *AgentNodeOpenrestyObservation `json:"openresty_observation,omitempty"` - TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"` - AccessLogs []AgentNodeAccessLog `json:"access_logs,omitempty"` - BufferedObservability []AgentBufferedObservabilityRecord `json:"buffered_observability,omitempty"` - HealthEvents []AgentNodeHealthEvent `json:"health_events"` - WAFIPGroupChecksums map[string]string `json:"waf_ip_group_checksums,omitempty"` -} - -type ApplyLogPayload struct { - NodeID string `json:"node_id"` - Version string `json:"version"` - Result string `json:"result"` - Message string `json:"message"` - Checksum string `json:"checksum"` - MainConfigChecksum string `json:"main_config_checksum"` - RouteConfigChecksum string `json:"route_config_checksum"` - SupportFileCount int `json:"support_file_count"` -} - -type ApplyLogListQuery struct { - NodeID string `json:"node_id"` - PageNo int `json:"pageNo"` - PageSize int `json:"pageSize"` -} - -type ApplyLogListResult struct { - Rows []*model.ApplyLog `json:"rows"` - Current int `json:"current"` - Total int `json:"total"` - TotalPage int `json:"totalPage"` -} - -type ApplyLogCleanupInput struct { - DeleteAll bool `json:"delete_all"` - RetentionDays int `json:"retention_days"` -} - -type ApplyLogCleanupResult struct { - DeleteAll bool `json:"delete_all"` - RetentionDays int `json:"retention_days"` - DeletedCount int64 `json:"deleted_count"` - Cutoff *time.Time `json:"cutoff,omitempty"` -} - -type AgentConfigResponse struct { - Version string `json:"version"` - Checksum string `json:"checksum"` - SourceConfigJSON string `json:"source_config_json"` - SupportFiles []SupportFile `json:"support_files"` - CreatedAt time.Time `json:"created_at"` -} - -type AgentSettings struct { - HeartbeatInterval int `json:"heartbeat_interval"` - WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"` - AutoUpdate bool `json:"auto_update"` - UpdateRepo string `json:"update_repo"` - UpdateNow bool `json:"update_now"` - UpdateChannel string `json:"update_channel"` - UpdateTag string `json:"update_tag"` - RestartOpenrestyNow bool `json:"restart_openresty_now"` -} - -type ActiveConfigMeta struct { - Version string `json:"version"` - Checksum string `json:"checksum"` -} - -type HeartbeatResponse struct { - Node *model.Node `json:"node"` - AgentSettings *AgentSettings `json:"agent_settings"` - ActiveConfig *ActiveConfigMeta `json:"active_config"` - WAFIPGroups []AgentWAFIPGroup `json:"waf_ip_groups,omitempty"` -} - -type AgentWAFIPGroup struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Enabled bool `json:"enabled"` - IPList []string `json:"ip_list"` - Checksum string `json:"checksum"` -} - -type AgentWAFIPGroupSyncInput struct { - IDs []uint `json:"ids"` - Checksums map[string]string `json:"checksums"` -} - -type AgentWAFIPGroupSyncResult struct { - Groups []AgentWAFIPGroup `json:"groups"` -} - -type NodeView struct { - ID uint `json:"id"` - NodeID string `json:"node_id"` - Name string `json:"name"` - IP string `json:"ip"` - IPManualOverride bool `json:"ip_manual_override"` - GeoName string `json:"geo_name"` - GeoLatitude *float64 `json:"geo_latitude"` - GeoLongitude *float64 `json:"geo_longitude"` - GeoManualOverride bool `json:"geo_manual_override"` - AccessToken string `json:"access_token"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - UpdateRequested bool `json:"update_requested"` - UpdateChannel string `json:"update_channel"` - UpdateTag string `json:"update_tag"` - RestartOpenrestyRequested bool `json:"restart_openresty_requested"` - Version string `json:"version"` - ExtVersion string `json:"ext_version"` - OpenrestyStatus string `json:"openresty_status"` - OpenrestyMessage string `json:"openresty_message"` - Status string `json:"status"` - CurrentVersion string `json:"current_version"` - LastSeenAt any `json:"last_seen_at"` - LastError string `json:"last_error"` - LatestApplyResult string `json:"latest_apply_result"` - LatestApplyMessage string `json:"latest_apply_message"` - LatestApplyChecksum string `json:"latest_apply_checksum"` - LatestMainConfigChecksum string `json:"latest_main_config_checksum"` - LatestRouteConfigChecksum string `json:"latest_route_config_checksum"` - LatestSupportFileCount int `json:"latest_support_file_count"` - LatestApplyAt *time.Time `json:"latest_apply_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - // TunnelRelay fields - NodeType string `json:"node_type"` - RelayBindPort int `json:"relay_bind_port"` - RelayVhostHTTPPort int `json:"relay_vhost_http_port"` - RelayAgentAccessAddr string `json:"relay_agent_access_addr"` - RelayClientAccessAddr string `json:"relay_client_access_addr"` - RelayClientProxyURL string `json:"relay_client_proxy_url"` - RelayStatus string `json:"relay_status"` - RelayWebServerEnabled bool `json:"relay_web_server_enabled"` -} - -func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) { - slog.Debug("agent heartbeat received", "node_id", node.NodeID, "current_version", strings.TrimSpace(payload.CurrentVersion)) - payload.NodeID = node.NodeID - payload = normalizeAgentNodePayload(payload) - if err := validateAgentNodePayload(payload); err != nil { - return nil, err - } - previous := *node - updateNow := node.UpdateRequested - restartOpenrestyNow := node.RestartOpenrestyRequested - updateChannel := normalizeReleaseChannel(node.UpdateChannel) - updateTag := strings.TrimSpace(node.UpdateTag) - applyNodeRuntime(node, payload, true) - node.UpdateRequested = false - node.UpdateChannel = ReleaseChannelStable.String() - node.UpdateTag = "" - node.RestartOpenrestyRequested = false - changes := collectNodeHeartbeatChanges(&previous, node) - if len(changes) > 0 { - if err := model.DB.Model(node).Updates(changes).Error; err != nil { - return nil, err - } - } - refreshAccessTokenCache(node) - persistHeartbeatObservability(node.NodeID, payload, node.LastSeenAt) - activeConfig, err := GetActiveConfigMetaForAgent() - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - wafIPGroups, err := ChangedWAFIPGroupsForAgent(nil, payload.WAFIPGroupChecksums) - if err != nil { - return nil, err - } - return &HeartbeatResponse{ - Node: node, - AgentSettings: buildAgentSettings(node, updateNow, updateChannel.String(), updateTag, restartOpenrestyNow), - ActiveConfig: activeConfig, - WAFIPGroups: wafIPGroups, - }, nil -} - -func buildAgentSettings(node *model.Node, updateNow bool, updateChannel string, updateTag string, restartOpenrestyNow bool) *AgentSettings { - autoUpdate := false - if node != nil { - autoUpdate = node.AutoUpdateEnabled - } - if strings.TrimSpace(updateChannel) == "" { - updateChannel = ReleaseChannelStable.String() - } - return &AgentSettings{ - HeartbeatInterval: common.AgentHeartbeatInterval, - WebsocketUpgradeEnabled: common.AgentWebsocketUpgradeEnabled, - AutoUpdate: autoUpdate, - UpdateRepo: common.AgentUpdateRepo, - UpdateNow: updateNow, - UpdateChannel: updateChannel, - UpdateTag: strings.TrimSpace(updateTag), - RestartOpenrestyNow: restartOpenrestyNow, - } -} - -func GetActiveConfigMetaForAgent() (*ActiveConfigMeta, error) { - version, err := model.GetActiveConfigVersion() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - return nil, err - } - return &ActiveConfigMeta{ - Version: version.Version, - Checksum: version.Checksum, - }, nil -} - -func GetActiveConfigForAgent() (*AgentConfigResponse, error) { - version, err := model.GetActiveConfigVersion() - if err != nil { - slog.Error("agent requested active config but no active version is available") - return nil, err - } - var supportFiles []SupportFile - if version.SupportFilesJSON != "" { - if err = json.Unmarshal([]byte(version.SupportFilesJSON), &supportFiles); err != nil { - return nil, err - } - } - slog.Debug("agent fetched active config", "version", version.Version, "checksum", version.Checksum) - return &AgentConfigResponse{ - Version: version.Version, - Checksum: version.Checksum, - SourceConfigJSON: version.SnapshotJSON, - SupportFiles: sourceSupportFiles(supportFiles), - CreatedAt: version.CreatedAt, - }, nil -} - -func normalizeApplyLogPayload(payload ApplyLogPayload) ApplyLogPayload { - payload.Result = strings.ToLower(payload.Result) - utils.TrimStringFields( - &payload.NodeID, - &payload.Version, - &payload.Result, - &payload.Message, - &payload.Checksum, - &payload.MainConfigChecksum, - &payload.RouteConfigChecksum, - ) - payload.Message = truncateForDatabase(payload.Message, 16000) - return payload -} - -func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) { - now := time.Now() - payload = normalizeApplyLogPayload(payload) - if payload.NodeID == "" { - return nil, errors.New("node_id 不能为空") - } - if payload.Version == "" { - return nil, errors.New("version 不能为空") - } - if payload.Result != ApplyResultOK && payload.Result != ApplyResultWarning && payload.Result != ApplyResultFailed { - return nil, errors.New("result 仅支持 success、warning 或 failed") - } - slog.Debug("agent apply log received", "node_id", payload.NodeID, "version", payload.Version, "result", payload.Result) - - log := &model.ApplyLog{ - NodeID: payload.NodeID, - Version: payload.Version, - Result: payload.Result, - Message: payload.Message, - Checksum: payload.Checksum, - MainConfigChecksum: payload.MainConfigChecksum, - RouteConfigChecksum: payload.RouteConfigChecksum, - SupportFileCount: payload.SupportFileCount, - CreatedAt: now, - } - err := model.DB.Transaction(func(tx *gorm.DB) error { - node := &model.Node{} - if err := tx.Where("node_id = ?", payload.NodeID).First(node).Error; err != nil { - return err - } - node.Status = NodeStatusOnline - node.LastSeenAt = now - if payload.Result == ApplyResultOK { - node.CurrentVersion = payload.Version - node.LastError = "" - } else { - node.LastError = payload.Message - } - if err := tx.Create(log).Error; err != nil { - return err - } - return tx.Model(node).Select("status", "last_seen_at", "current_version", "last_error").Updates(node).Error - }) - if err != nil { - return nil, err - } - if payload.Result == ApplyResultOK { - slog.Debug("agent apply reported success", "node_id", payload.NodeID, "version", payload.Version) - } else if payload.Result == ApplyResultWarning { - slog.Warn("agent apply reported warning", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message) - } else { - slog.Error("agent apply reported failure", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message) - } - return log, nil -} - -func ListNodeViews() ([]*NodeView, error) { - nodes, err := model.ListNodes() - if err != nil { - return nil, err - } - nodeIDs := make([]string, 0, len(nodes)) - for _, node := range nodes { - nodeIDs = append(nodeIDs, node.NodeID) - } - latestLogs, err := model.GetLatestApplyLogsByNodeIDs(nodeIDs) - if err != nil { - return nil, err - } - views := make([]*NodeView, 0, len(nodes)) - for _, node := range nodes { - computedStatus := computeNodeStatus(node) - view := buildNodeView(node) - view.Status = computedStatus - if log, ok := latestLogs[node.NodeID]; ok { - view.LatestApplyResult = log.Result - view.LatestApplyMessage = log.Message - view.LatestApplyChecksum = log.Checksum - view.LatestMainConfigChecksum = log.MainConfigChecksum - view.LatestRouteConfigChecksum = log.RouteConfigChecksum - view.LatestSupportFileCount = log.SupportFileCount - view.LatestApplyAt = &log.CreatedAt - } - views = append(views, view) - } - return views, nil -} - -func truncateForDatabase(value string, max int) string { - if max <= 0 { - return "" - } - runes := []rune(strings.TrimSpace(value)) - if len(runes) <= max { - return string(runes) - } - return string(runes[:max]) -} - -const ( - defaultApplyLogPageSize = 20 - maxApplyLogPageSize = 200 - maxApplyLogRetentionDays = 3650 -) - -func ListApplyLogsPage(input ApplyLogListQuery) (*ApplyLogListResult, error) { - pageNo := normalizeApplyLogPageNo(input.PageNo) - pageSize := normalizeApplyLogPageSize(input.PageSize) - nodeID := strings.TrimSpace(input.NodeID) - rows, err := model.ListApplyLogs(model.ApplyLogQuery{ - NodeID: nodeID, - PageNo: pageNo, - PageSize: pageSize, - }) - if err != nil { - return nil, err - } - total, err := model.CountApplyLogs(nodeID) - if err != nil { - return nil, err - } - totalPage := 0 - if total > 0 { - totalPage = int((total + int64(pageSize) - 1) / int64(pageSize)) - } - return &ApplyLogListResult{ - Rows: rows, - Current: pageNo, - Total: int(total), - TotalPage: totalPage, - }, nil -} - -func CleanupApplyLogs(input ApplyLogCleanupInput) (*ApplyLogCleanupResult, error) { - if input.DeleteAll { - deleted, err := model.DeleteAllApplyLogs() - if err != nil { - return nil, err - } - return &ApplyLogCleanupResult{ - DeleteAll: true, - DeletedCount: deleted, - }, nil - } - if input.RetentionDays <= 0 || input.RetentionDays > maxApplyLogRetentionDays { - return nil, errors.New("retention_days 必须在 1 到 3650 之间") - } - cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour) - deleted, err := model.DeleteApplyLogsBefore(cutoff) - if err != nil { - return nil, err - } - return &ApplyLogCleanupResult{ - RetentionDays: input.RetentionDays, - DeletedCount: deleted, - Cutoff: &cutoff, - }, nil -} - -func normalizeApplyLogPageNo(pageNo int) int { - if pageNo <= 0 { - return 1 - } - return pageNo -} - -func normalizeApplyLogPageSize(pageSize int) int { - if pageSize <= 0 { - return defaultApplyLogPageSize - } - if pageSize > maxApplyLogPageSize { - return maxApplyLogPageSize - } - return pageSize -} - -func computeNodeStatus(node *model.Node) string { - if node == nil { - return NodeStatusOffline - } - if node.NodeType == "tunnel_relay" && IsRelayWSConnected(node.NodeID) { - return NodeStatusOnline - } - if node.NodeType == "tunnel_client" && IsFlaredWSConnected(node.NodeID) { - return NodeStatusOnline - } - if IsAgentWSConnected(node.NodeID) { - return NodeStatusOnline - } - if node.LastSeenAt.IsZero() { - return NodeStatusPending - } - if time.Since(node.LastSeenAt) > common.NodeOfflineThreshold { - return NodeStatusOffline - } - return NodeStatusOnline -} - -func collectNodeHeartbeatChanges(previous *model.Node, current *model.Node) map[string]any { - if previous == nil || current == nil { - return map[string]any{} - } - changes := make(map[string]any) - appendIfChanged := func(key string, before any, after any) { - if before != after { - changes[key] = after - } - } - appendIfChanged("name", previous.Name, current.Name) - appendIfChanged("ip", previous.IP, current.IP) - appendIfChanged("geo_name", previous.GeoName, current.GeoName) - appendIfChanged("version", previous.Version, current.Version) - appendIfChanged("ext_version", previous.ExtVersion, current.ExtVersion) - appendIfChanged("openresty_status", previous.OpenrestyStatus, current.OpenrestyStatus) - appendIfChanged("openresty_message", previous.OpenrestyMessage, current.OpenrestyMessage) - appendIfChanged("status", previous.Status, current.Status) - appendIfChanged("current_version", previous.CurrentVersion, current.CurrentVersion) - appendIfChanged("last_error", previous.LastError, current.LastError) - appendIfChanged("update_requested", previous.UpdateRequested, current.UpdateRequested) - appendIfChanged("update_channel", previous.UpdateChannel, current.UpdateChannel) - appendIfChanged("update_tag", previous.UpdateTag, current.UpdateTag) - appendIfChanged("restart_openresty_requested", previous.RestartOpenrestyRequested, current.RestartOpenrestyRequested) - if !coordinatesEqual(previous.GeoLatitude, current.GeoLatitude) { - changes["geo_latitude"] = current.GeoLatitude - } - if !coordinatesEqual(previous.GeoLongitude, current.GeoLongitude) { - changes["geo_longitude"] = current.GeoLongitude - } - if !previous.LastSeenAt.Equal(current.LastSeenAt) { - changes["last_seen_at"] = current.LastSeenAt - } - return changes -} - -func coordinatesEqual(before *float64, after *float64) bool { - if before == nil || after == nil { - return before == after - } - return *before == *after -} diff --git a/openflare-server/internal/service/agent_test.go b/openflare-server/internal/service/agent_test.go deleted file mode 100644 index e9bf0be3..00000000 --- a/openflare-server/internal/service/agent_test.go +++ /dev/null @@ -1,522 +0,0 @@ -package service - -import ( - "errors" - "strconv" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -func TestGetActiveConfigForAgentIncludesWAFConfig(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "waf-agent.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - if _, err := PublishConfigVersion("root", false); 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 == "waf_config.json" { - t.Fatal("agent config should not receive rendered waf_config.json") - } - } - if !strings.Contains(activeConfig.SourceConfigJSON, `"waf"`) { - t.Fatal("expected agent config source json to include WAF source configuration") - } -} - -func TestChangedWAFIPGroupsForAgentReturnsChecksumDelta(t *testing.T) { - setupServiceTestDB(t) - - route, err := CreateProxyRoute(ProxyRouteInput{ - SiteName: "agent-waf-ip-group", - Domains: []string{"agent-waf-ip-group.example.com"}, - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - ipGroup, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "agent runtime group", - Type: WAFIPGroupTypeManual, - Enabled: true, - IPList: []string{"203.0.113.44"}, - }) - if err != nil { - t.Fatalf("CreateWAFIPGroup failed: %v", err) - } - ruleGroup, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "agent refs", - Enabled: true, - IPBlacklistGroups: []uint{ipGroup.ID}, - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{ruleGroup.ID}); err != nil { - t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err) - } - if _, err = PublishConfigVersion("root", false); err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - - groups, err := ChangedWAFIPGroupsForAgent(nil, nil) - if err != nil { - t.Fatalf("ChangedWAFIPGroupsForAgent failed: %v", err) - } - if len(groups) != 1 || groups[0].ID != ipGroup.ID || groups[0].IPList[0] != "203.0.113.44" || groups[0].Checksum == "" { - t.Fatalf("unexpected changed groups: %#v", groups) - } - groupKey := strconv.FormatUint(uint64(ipGroup.ID), 10) - same, err := ChangedWAFIPGroupsForAgent(nil, map[string]string{groupKey: groups[0].Checksum}) - if err != nil { - t.Fatalf("ChangedWAFIPGroupsForAgent with checksum failed: %v", err) - } - if len(same) != 0 { - t.Fatalf("expected no delta for matching checksum, got %#v", same) - } - updated, err := UpdateWAFIPGroup(ipGroup.ID, WAFIPGroupInput{ - Name: "agent runtime group", - Type: WAFIPGroupTypeManual, - Enabled: true, - IPList: []string{"203.0.113.45"}, - }) - if err != nil { - t.Fatalf("UpdateWAFIPGroup failed: %v", err) - } - delta, err := ChangedWAFIPGroupsForAgent(nil, map[string]string{groupKey: groups[0].Checksum}) - if err != nil { - t.Fatalf("ChangedWAFIPGroupsForAgent after update failed: %v", err) - } - if len(delta) != 1 || delta[0].ID != updated.ID || delta[0].IPList[0] != "203.0.113.45" || delta[0].Checksum == groups[0].Checksum { - t.Fatalf("expected updated group delta, got %#v", delta) - } -} - -func TestRegisterNodeWithAccessToken(t *testing.T) { - setupServiceTestDB(t) - - // 1. Success path - latitude := 31.2304 - longitude := 121.4737 - node, err := CreateNode(NodeInput{ - Name: "reserved-node-1", - IP: "192.168.1.10", - GeoManualOverride: true, - GeoName: "Shanghai", - GeoLatitude: &latitude, - GeoLongitude: &longitude, - }) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - - stored, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to fetch stored node: %v", err) - } - - payload := AgentNodePayload{ - Name: "payload-name-should-be-ignored", - IP: "192.168.1.20", - Version: "v1.0.1", - ExtVersion: "1.27.1.3", - OpenrestyStatus: "healthy", - } - - resp, err := RegisterNodeWithAccessToken(stored, payload) - if err != nil { - t.Fatalf("RegisterNodeWithAccessToken failed: %v", err) - } - - if resp.NodeID != stored.NodeID || resp.AccessToken != stored.AccessToken || resp.Name != "reserved-node-1" { - t.Errorf("unexpected response: %+v", resp) - } - - // Verify that the node was updated in the DB - updated, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to fetch updated node: %v", err) - } - if updated.Version != "v1.0.1" || updated.ExtVersion != "1.27.1.3" || updated.OpenrestyStatus != "healthy" { - t.Errorf("node attributes were not updated: %+v", updated) - } - // Name should be preserved since preserveName is true - if updated.Name != "reserved-node-1" { - t.Errorf("expected name to be preserved, got %s", updated.Name) - } - - // 2. Fail path - Nil Node - _, err = RegisterNodeWithAccessToken(nil, payload) - if err == nil || !strings.Contains(err.Error(), "节点不存在") { - t.Errorf("expected error '节点不存在', got %v", err) - } - - // 3. Fail path - Invalid Payload (empty IP) - badPayload := payload - badPayload.IP = "" - _, err = RegisterNodeWithAccessToken(stored, badPayload) - if err == nil || !strings.Contains(err.Error(), "ip 不能为空") { - t.Errorf("expected error 'ip 不能为空', got %v", err) - } - - // 4. Name update if empty - emptyNameNode := &model.Node{ - NodeID: "node-empty-name", - Name: "", - AccessToken: "empty-name-token", - } - if err := emptyNameNode.Insert(); err != nil { - t.Fatalf("failed to insert emptyNameNode: %v", err) - } - payloadWithName := payload - payloadWithName.Name = "filled-name" - payloadWithName.IP = "192.168.1.30" - _, err = RegisterNodeWithAccessToken(emptyNameNode, payloadWithName) - if err != nil { - t.Fatalf("RegisterNodeWithAccessToken empty name node failed: %v", err) - } - updatedEmptyName, err := model.GetNodeByNodeID("node-empty-name") - if err != nil { - t.Fatalf("failed to fetch updatedEmptyName: %v", err) - } - if updatedEmptyName.Name != "filled-name" { - t.Errorf("expected name to be filled, got %s", updatedEmptyName.Name) - } -} - -func TestRegisterNodeWithDiscovery(t *testing.T) { - setupServiceTestDB(t) - - // 1. Success path - payload := AgentNodePayload{ - Name: "discovery-node", - IP: "192.168.2.10", - Version: "v1.0.0", - ExtVersion: "1.27.1.3", - OpenrestyStatus: "healthy", - } - - resp, err := RegisterNodeWithDiscovery(payload) - if err != nil { - t.Fatalf("RegisterNodeWithDiscovery failed: %v", err) - } - - if resp.NodeID == "" || resp.AccessToken == "" || resp.Name != "discovery-node" { - t.Errorf("unexpected response: %+v", resp) - } - - // Verify database persistence - node, err := model.GetNodeByNodeID(resp.NodeID) - if err != nil { - t.Fatalf("failed to fetch node: %v", err) - } - if node.IP != "192.168.2.10" || node.Version != "v1.0.0" || node.Name != "discovery-node" { - t.Errorf("unexpected stored node data: %+v", node) - } - - // 2. Name fallback if payload name is empty - payloadNoName := payload - payloadNoName.Name = "" - payloadNoName.IP = "192.168.2.20" - respNoName, err := RegisterNodeWithDiscovery(payloadNoName) - if err != nil { - t.Fatalf("RegisterNodeWithDiscovery no name failed: %v", err) - } - nodeNoName, err := model.GetNodeByNodeID(respNoName.NodeID) - if err != nil { - t.Fatalf("failed to fetch no-name node: %v", err) - } - if nodeNoName.Name != respNoName.NodeID { - t.Errorf("expected name fallback to NodeID, got %s", nodeNoName.Name) - } - - // 3. Fail path - Invalid Payload (empty AgentVersion) - badPayload := payload - badPayload.Version = "" - _, err = RegisterNodeWithDiscovery(badPayload) - if err == nil || !strings.Contains(err.Error(), "version 不能为空") { - t.Errorf("expected error 'version 不能为空', got %v", err) - } -} - -func TestReportApplyLog_Success(t *testing.T) { - setupServiceTestDB(t) - - // Seed node - node := &model.Node{ - NodeID: "node-apply-1", - Name: "apply-edge", - IP: "192.168.3.10", - AccessToken: "apply-token", - Version: "v1.0.0", - Status: NodeStatusOffline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - - payload := ApplyLogPayload{ - NodeID: "node-apply-1", - Version: "20260531-001", - Result: "success", - Message: "Configuration applied successfully", - Checksum: "chk-1", - MainConfigChecksum: "m-chk-1", - RouteConfigChecksum: "r-chk-1", - SupportFileCount: 3, - } - - log, err := ReportApplyLog(payload) - if err != nil { - t.Fatalf("ReportApplyLog failed: %v", err) - } - - if log.NodeID != "node-apply-1" || log.Result != "success" || log.Message != "Configuration applied successfully" { - t.Errorf("unexpected returned log: %+v", log) - } - - // Verify that the node status and current version are updated in the DB - updatedNode, err := model.GetNodeByNodeID("node-apply-1") - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if updatedNode.CurrentVersion != "20260531-001" || updatedNode.Status != NodeStatusOnline || updatedNode.LastError != "" { - t.Errorf("node was not updated correctly: %+v", updatedNode) - } - - // Verify apply log is stored - storedLogs, err := model.ListApplyLogs(model.ApplyLogQuery{NodeID: "node-apply-1", PageNo: 1, PageSize: 10}) - if err != nil { - t.Fatalf("ListApplyLogs failed: %v", err) - } - if len(storedLogs) != 1 || storedLogs[0].Checksum != "chk-1" { - t.Errorf("expected 1 log, got: %d", len(storedLogs)) - } -} - -func TestReportApplyLog_WarningAndFailure(t *testing.T) { - setupServiceTestDB(t) - - // Seed node - node := &model.Node{ - NodeID: "node-apply-2", - Name: "apply-edge-2", - IP: "192.168.3.20", - AccessToken: "apply-token-2", - Version: "v1.0.0", - CurrentVersion: "20260531-001", // Old version - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - - // 1. Report Failure - failPayload := ApplyLogPayload{ - NodeID: "node-apply-2", - Version: "20260531-002", // Target failed version - Result: "failed", - Message: "reload process exited with code 1", - } - - _, err := ReportApplyLog(failPayload) - if err != nil { - t.Fatalf("ReportApplyLog failed: %v", err) - } - - // Node CurrentVersion should NOT be updated. Node LastError should be updated. - updatedNode, err := model.GetNodeByNodeID("node-apply-2") - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if updatedNode.CurrentVersion != "20260531-001" { - t.Errorf("expected CurrentVersion to remain unchanged, got %s", updatedNode.CurrentVersion) - } - if updatedNode.LastError != "reload process exited with code 1" { - t.Errorf("expected LastError to be set, got %s", updatedNode.LastError) - } - - // 2. Report Warning (e.g. rolled back to old version successfully) - warningPayload := ApplyLogPayload{ - NodeID: "node-apply-2", - Version: "20260531-002", - Result: "warning", - Message: "reload failed, rolled back to 20260531-001 successfully", - } - - _, err = ReportApplyLog(warningPayload) - if err != nil { - t.Fatalf("ReportApplyLog warning failed: %v", err) - } - - updatedNodeWarning, err := model.GetNodeByNodeID("node-apply-2") - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - // CurrentVersion remains 20260531-001. LastError is the warning message. - if updatedNodeWarning.CurrentVersion != "20260531-001" { - t.Errorf("expected CurrentVersion to remain unchanged, got %s", updatedNodeWarning.CurrentVersion) - } - if updatedNodeWarning.LastError != "reload failed, rolled back to 20260531-001 successfully" { - t.Errorf("expected LastError to be warning message, got %s", updatedNodeWarning.LastError) - } -} - -func TestReportApplyLog_Failures(t *testing.T) { - setupServiceTestDB(t) - - // Seed node - node := &model.Node{ - NodeID: "node-apply-3", - Name: "apply-edge-3", - IP: "192.168.3.30", - AccessToken: "apply-token-3", - Version: "v1.0.0", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - - // 1. Missing NodeID - _, err := ReportApplyLog(ApplyLogPayload{Version: "v1", Result: "success"}) - if err == nil || !strings.Contains(err.Error(), "node_id 不能为空") { - t.Errorf("expected empty node_id error, got %v", err) - } - - // 2. Missing Version - _, err = ReportApplyLog(ApplyLogPayload{NodeID: "node-apply-3", Result: "success"}) - if err == nil || !strings.Contains(err.Error(), "version 不能为空") { - t.Errorf("expected empty version error, got %v", err) - } - - // 3. Invalid Result - _, err = ReportApplyLog(ApplyLogPayload{NodeID: "node-apply-3", Version: "v1", Result: "corrupted"}) - if err == nil || !strings.Contains(err.Error(), "result 仅支持 success、warning 或 failed") { - t.Errorf("expected invalid result error, got %v", err) - } - - // 4. Non-existent NodeID - _, err = ReportApplyLog(ApplyLogPayload{NodeID: "non-existent-node-xyz", Version: "v1", Result: "success"}) - if err == nil || !errors.Is(err, gorm.ErrRecordNotFound) { - t.Errorf("expected record not found error, got %v", err) - } - - // 5. Truncate excessively long message - veryLongMsg := strings.Repeat("A", 20000) - log, err := ReportApplyLog(ApplyLogPayload{ - NodeID: "node-apply-3", - Version: "v1", - Result: "success", - Message: veryLongMsg, - }) - if err != nil { - t.Fatalf("ReportApplyLog with very long message failed: %v", err) - } - if len(log.Message) != 16000 { - t.Errorf("expected message to be truncated to 16000, got %d", len(log.Message)) - } -} - -func TestListAndCleanupApplyLogs(t *testing.T) { - setupServiceTestDB(t) - - // Seed node - node := &model.Node{ - NodeID: "node-logs", - Name: "logs-edge", - IP: "192.168.4.10", - AccessToken: "logs-token", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - - now := time.Now() - // Seed logs of different ages - logs := []model.ApplyLog{ - {NodeID: "node-logs", Version: "v1", Result: "success", Message: "1", CreatedAt: now.Add(-10 * 24 * time.Hour)}, // 10 days ago - {NodeID: "node-logs", Version: "v2", Result: "success", Message: "2", CreatedAt: now.Add(-5 * 24 * time.Hour)}, // 5 days ago - {NodeID: "node-logs", Version: "v3", Result: "success", Message: "3", CreatedAt: now}, // Now - } - for i := range logs { - if err := model.DB.Create(&logs[i]).Error; err != nil { - t.Fatalf("failed to seed log: %v", err) - } - } - - // 1. Test pagination using ListApplyLogsPage - pageResult, err := ListApplyLogsPage(ApplyLogListQuery{ - NodeID: "node-logs", - PageNo: 1, - PageSize: 2, - }) - if err != nil { - t.Fatalf("ListApplyLogsPage failed: %v", err) - } - if pageResult.Total != 3 || len(pageResult.Rows) != 2 || pageResult.TotalPage != 2 { - t.Errorf("unexpected pagination result: %+v", pageResult) - } - - // 2. Test Cleanup with RetentionDays = 7 - cleanupResult, err := CleanupApplyLogs(ApplyLogCleanupInput{ - DeleteAll: false, - RetentionDays: 7, - }) - if err != nil { - t.Fatalf("CleanupApplyLogs failed: %v", err) - } - if cleanupResult.DeletedCount != 1 { - t.Errorf("expected 1 log to be deleted, got %d", cleanupResult.DeletedCount) - } - - // Verify remaining logs: newer logs (v2 and v3) should still be in the DB - remainingLogs, err := model.ListApplyLogs(model.ApplyLogQuery{NodeID: "node-logs", PageNo: 1, PageSize: 10}) - if err != nil { - t.Fatalf("ListApplyLogs failed: %v", err) - } - if len(remainingLogs) != 2 { - t.Errorf("expected 2 remaining logs, got %d", len(remainingLogs)) - } - - // 3. Test Cleanup with DeleteAll = true - cleanupAll, err := CleanupApplyLogs(ApplyLogCleanupInput{ - DeleteAll: true, - }) - if err != nil { - t.Fatalf("CleanupApplyLogs deleteAll failed: %v", err) - } - if cleanupAll.DeletedCount != 2 { - t.Errorf("expected 2 remaining logs to be deleted, got %d", cleanupAll.DeletedCount) - } - - // Verify DB is empty of apply logs - finalLogs, err := model.ListApplyLogs(model.ApplyLogQuery{NodeID: "node-logs", PageNo: 1, PageSize: 10}) - if err != nil { - t.Fatalf("ListApplyLogs failed: %v", err) - } - if len(finalLogs) != 0 { - t.Errorf("expected 0 remaining logs, got %d", len(finalLogs)) - } -} diff --git a/openflare-server/internal/service/agent_ws.go b/openflare-server/internal/service/agent_ws.go deleted file mode 100644 index d1df481b..00000000 --- a/openflare-server/internal/service/agent_ws.go +++ /dev/null @@ -1,141 +0,0 @@ -package service - -import ( - "encoding/json" - "log/slog" -) - -const ( - AgentWSMessageTypeStatus = "status" - AgentWSMessageTypeSettings = "settings" - AgentWSMessageTypeActiveConfig = "active_config" - AgentWSMessageTypeForceSyncConfig = "force_sync_config" - AgentWSMessageTypeWAFIPGroups = "waf_ip_groups" - AgentWSMessageTypePing = "ping" - AgentWSMessageTypePong = "pong" - - AgentWSConnectedLastSeenValue = "__OPENFLARE_WS_CONNECTED__" -) - -type AgentWSInboundMessage struct { - Type string `json:"type"` - Payload json.RawMessage `json:"payload,omitempty"` -} - -type AgentWSBroadcastResult struct { - Version string `json:"version"` - Checksum string `json:"checksum"` - ClientCount int `json:"client_count"` - SuccessCount int `json:"success_count"` - FailedNodes []string `json:"failed_nodes"` -} - -var DefaultAgentWSHub = NewWSHub("agent") - -func RegisterAgentWSClient(nodeID string) *WSClient { - return DefaultAgentWSHub.Register(nodeID) -} - -func UnregisterAgentWSClient(client *WSClient) { - DefaultAgentWSHub.Unregister(client) -} - -func DisconnectAgentWSClient(nodeID string) { - DefaultAgentWSHub.Disconnect(nodeID) -} - -func IsAgentWSConnected(nodeID string) bool { - return DefaultAgentWSHub.IsConnected(nodeID) -} - -func SendAgentWSSettings(nodeID string, settings *AgentSettings) bool { - if settings == nil { - return false - } - return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{ - Type: AgentWSMessageTypeSettings, - Payload: settings, - }) -} - -func SendAgentWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool { - if activeConfig == nil { - return false - } - return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{ - Type: AgentWSMessageTypeActiveConfig, - Payload: activeConfig, - }) -} - -func SendAgentWSForceSyncConfig(nodeID string, activeConfig *ActiveConfigMeta) bool { - if activeConfig == nil { - return false - } - return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{ - Type: AgentWSMessageTypeForceSyncConfig, - Payload: activeConfig, - }) -} - -func SendAgentWSWAFIPGroups(nodeID string, groups []AgentWAFIPGroup) bool { - if len(groups) == 0 { - return false - } - return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{ - Type: AgentWSMessageTypeWAFIPGroups, - Payload: groups, - }) -} - -func SendAgentWSPong(nodeID string) bool { - return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{ - Type: AgentWSMessageTypePong, - }) -} - -func BroadcastAgentWSActiveConfig(activeConfig *ActiveConfigMeta) AgentWSBroadcastResult { - if activeConfig == nil { - slog.Debug("agent ws broadcast skipped because active config is nil") - return AgentWSBroadcastResult{} - } - - res := DefaultAgentWSHub.Broadcast(WSMessage{ - Type: AgentWSMessageTypeActiveConfig, - Payload: activeConfig, - }) - - result := AgentWSBroadcastResult{ - Version: activeConfig.Version, - Checksum: activeConfig.Checksum, - ClientCount: res.ClientCount, - SuccessCount: res.SuccessCount, - FailedNodes: res.FailedIDs, - } - - slog.Debug("agent ws broadcast active config", - "version", result.Version, - "checksum", result.Checksum, - "client_count", result.ClientCount, - "success_count", result.SuccessCount, - "failed_nodes", result.FailedNodes, - ) - return result -} - -func BroadcastAgentWSWAFIPGroups(groups []AgentWAFIPGroup) WSBroadcastResult { - if len(groups) == 0 { - return WSBroadcastResult{} - } - result := DefaultAgentWSHub.Broadcast(WSMessage{ - Type: AgentWSMessageTypeWAFIPGroups, - Payload: groups, - }) - slog.Debug("agent ws broadcast waf ip groups", - "group_count", len(groups), - "client_count", result.ClientCount, - "success_count", result.SuccessCount, - "failed_nodes", result.FailedIDs, - ) - return result -} diff --git a/openflare-server/internal/service/auth_source.go b/openflare-server/internal/service/auth_source.go deleted file mode 100644 index 0a8a9189..00000000 --- a/openflare-server/internal/service/auth_source.go +++ /dev/null @@ -1,500 +0,0 @@ -package service - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -type PublicAuthSource struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - DisplayName string `json:"display_name"` - AuthorizeURL string `json:"authorize_url"` - IconURL string `json:"icon_url"` -} - -type OAuthProfile struct { - ExternalID string - ExternalUsername string - DisplayName string - Email string -} - -type OAuthCallbackResult struct { - Status string `json:"status"` - User *model.User `json:"user,omitempty"` -} - -type LinkExistingRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -type PendingExternalAccount struct { - AuthSourceID uint `json:"auth_source_id"` - ExternalID string `json:"external_id"` - ExternalUsername string `json:"external_username"` - DisplayName string `json:"display_name"` - Email string `json:"email"` -} - -type oidcDiscovery struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserInfoEndpoint string `json:"userinfo_endpoint"` - JWKSURI string `json:"jwks_uri"` - Issuer string `json:"issuer"` -} - -type oauthTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - IDToken string `json:"id_token"` - Scope string `json:"scope"` -} - -var oauthHTTPClient = &http.Client{Timeout: 8 * time.Second} - -func GenerateOAuthState() (string, error) { - buffer := make([]byte, 24) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buffer), nil -} - -func PublicAuthSources(baseAPIPath string) ([]PublicAuthSource, error) { - sources, err := model.GetActiveAuthSources() - if err != nil { - return nil, err - } - result := make([]PublicAuthSource, 0, len(sources)) - for _, source := range sources { - result = append(result, PublicAuthSource{ - ID: source.ID, - Name: source.Name, - Type: source.Type, - DisplayName: source.DisplayName, - AuthorizeURL: fmt.Sprintf("%s/oauth/%s/authorize", strings.TrimRight(baseAPIPath, "/"), url.PathEscape(source.Name)), - IconURL: source.IconURL, - }) - } - return result, nil -} - -func BuildAuthorizeURL(ctx context.Context, source *model.AuthSource, redirectURL string, state string) (string, error) { - source.Normalize() - switch source.Type { - case model.AuthSourceTypeGitHub: - authorizeURL, err := url.Parse("https://github.com/login/oauth/authorize") - if err != nil { - return "", err - } - values := authorizeURL.Query() - values.Set("client_id", source.ClientID) - values.Set("redirect_uri", redirectURL) - values.Set("scope", source.Scopes) - values.Set("state", state) - authorizeURL.RawQuery = values.Encode() - return authorizeURL.String(), nil - case model.AuthSourceTypeOIDC: - discovery, err := fetchOIDCDiscovery(ctx, source.OpenIDDiscoveryURL) - if err != nil { - return "", err - } - authorizeURL, err := url.Parse(discovery.AuthorizationEndpoint) - if err != nil { - return "", err - } - values := authorizeURL.Query() - values.Set("client_id", source.ClientID) - values.Set("redirect_uri", redirectURL) - values.Set("response_type", "code") - values.Set("scope", source.Scopes) - values.Set("state", state) - authorizeURL.RawQuery = values.Encode() - return authorizeURL.String(), nil - default: - return "", errors.New("不支持的认证源类型") - } -} - -func ExchangeOAuthProfile(ctx context.Context, source *model.AuthSource, code string, redirectURL string) (*OAuthProfile, error) { - if strings.TrimSpace(code) == "" { - return nil, errors.New("授权 code 不能为空") - } - source.Normalize() - switch source.Type { - case model.AuthSourceTypeGitHub: - return exchangeGitHubProfile(ctx, source, code, redirectURL) - case model.AuthSourceTypeOIDC: - return exchangeOIDCProfile(ctx, source, code, redirectURL) - default: - return nil, errors.New("不支持的认证源类型") - } -} - -func CompleteOAuthLogin(source *model.AuthSource, profile *OAuthProfile, currentUserID *int) (*OAuthCallbackResult, *PendingExternalAccount, error) { - if source == nil || profile == nil || strings.TrimSpace(profile.ExternalID) == "" { - return nil, nil, errors.New("第三方账号资料不完整") - } - - account, err := model.FindExternalAccount(source.ID, profile.ExternalID) - if err == nil { - user, err := model.GetUserById(account.UserID, false) - if err != nil { - return nil, nil, err - } - if user.Status != common.UserStatusEnabled { - return nil, nil, errors.New("用户已被封禁") - } - return &OAuthCallbackResult{Status: "logged_in", User: user}, nil, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, err - } - - if currentUserID != nil && *currentUserID > 0 { - user, err := model.GetUserById(*currentUserID, false) - if err != nil { - return nil, nil, err - } - if user.Status != common.UserStatusEnabled { - return nil, nil, errors.New("用户已被封禁") - } - if err := model.LinkExternalAccount(&model.ExternalAccount{ - AuthSourceID: source.ID, - UserID: user.Id, - ExternalID: profile.ExternalID, - ExternalUsername: profile.ExternalUsername, - Email: profile.Email, - }); err != nil { - return nil, nil, err - } - return &OAuthCallbackResult{Status: "linked", User: user}, nil, nil - } - - pending := &PendingExternalAccount{ - AuthSourceID: source.ID, - ExternalID: profile.ExternalID, - ExternalUsername: profile.ExternalUsername, - DisplayName: profile.DisplayName, - Email: profile.Email, - } - return &OAuthCallbackResult{Status: "link_required"}, pending, nil -} - -func LinkPendingExternalAccount(pending *PendingExternalAccount, input LinkExistingRequest) (*model.User, error) { - if pending == nil || pending.AuthSourceID == 0 || pending.ExternalID == "" { - return nil, errors.New("待绑定第三方账号已失效,请重新登录") - } - user := model.User{ - Username: strings.TrimSpace(input.Username), - Password: input.Password, - } - if err := user.ValidateAndFill(); err != nil { - return nil, err - } - if user.Status != common.UserStatusEnabled { - return nil, errors.New("用户已被封禁") - } - - if existing, err := model.FindExternalAccount(pending.AuthSourceID, pending.ExternalID); err == nil { - if existing.UserID != user.Id { - return nil, errors.New("该第三方账号已绑定其他用户") - } - return &user, nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - - if err := model.LinkExternalAccount(&model.ExternalAccount{ - AuthSourceID: pending.AuthSourceID, - UserID: user.Id, - ExternalID: pending.ExternalID, - ExternalUsername: pending.ExternalUsername, - Email: pending.Email, - }); err != nil { - return nil, err - } - return &user, nil -} - -// CreateUserFromOAuthProfile 根据 OAuth 资料创建新用户 -func createUserFromOAuthProfile(source *model.AuthSource, profile *OAuthProfile) (*model.User, error) { - displayName := strings.TrimSpace(profile.DisplayName) - if displayName == "" { - displayName = strings.TrimSpace(profile.ExternalUsername) - } - if displayName == "" { - displayName = source.DisplayName + " User" - } - if len([]rune(displayName)) > 20 { - displayName = string([]rune(displayName)[:20]) - } - - prefix := source.Type - if prefix == "" { - prefix = "oauth" - } - var username string - for index := 0; index < 20; index++ { - username = fmt.Sprintf("%s_%d", prefix, model.GetMaxUserId()+1+index) - if !model.IsUsernameAlreadyTaken(username) { - break - } - } - - user := &model.User{ - Username: username, - DisplayName: displayName, - Email: profile.Email, - Role: common.RoleCommonUser, - Status: common.UserStatusEnabled, - } - if err := user.Insert(); err != nil { - return nil, err - } - if err := model.LinkExternalAccount(&model.ExternalAccount{ - AuthSourceID: source.ID, - UserID: user.Id, - ExternalID: profile.ExternalID, - ExternalUsername: profile.ExternalUsername, - Email: profile.Email, - }); err != nil { - return nil, err - } - return user, nil -} - -func exchangeGitHubProfile(ctx context.Context, source *model.AuthSource, code string, redirectURL string) (*OAuthProfile, error) { - values := map[string]string{ - "client_id": source.ClientID, - "client_secret": source.ClientSecret, - "code": code, - "redirect_uri": redirectURL, - } - body, err := json.Marshal(values) - if err != nil { - return nil, err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://github.com/login/oauth/access_token", bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - resp, err := oauthHTTPClient.Do(req) - if err != nil { - slog.Error("github oauth access token request failed", "error", err) - return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试") - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("GitHub token 接口返回异常状态: %s", resp.Status) - } - var token oauthTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { - return nil, err - } - if token.AccessToken == "" { - return nil, errors.New("GitHub 未返回 access token") - } - - req, err = http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+token.AccessToken) - req.Header.Set("Accept", "application/vnd.github+json") - resp, err = oauthHTTPClient.Do(req) - if err != nil { - slog.Error("github user info request failed", "error", err) - return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试") - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("GitHub 用户接口返回异常状态: %s", resp.Status) - } - var githubUser struct { - ID int64 `json:"id"` - Login string `json:"login"` - Name string `json:"name"` - Email string `json:"email"` - } - if err := json.NewDecoder(resp.Body).Decode(&githubUser); err != nil { - return nil, err - } - if githubUser.ID == 0 && githubUser.Login == "" { - return nil, errors.New("GitHub 用户资料缺少唯一标识") - } - return &OAuthProfile{ - ExternalID: githubUser.Login, - ExternalUsername: githubUser.Login, - DisplayName: firstNonEmpty(githubUser.Name, githubUser.Login), - Email: githubUser.Email, - }, nil -} - -func exchangeOIDCProfile(ctx context.Context, source *model.AuthSource, code string, redirectURL string) (*OAuthProfile, error) { - discovery, err := fetchOIDCDiscovery(ctx, source.OpenIDDiscoveryURL) - if err != nil { - return nil, err - } - token, err := exchangeOIDCToken(ctx, discovery.TokenEndpoint, source, code, redirectURL) - if err != nil { - return nil, err - } - if token.AccessToken == "" { - return nil, errors.New("OIDC 未返回 access token") - } - claims, err := fetchOIDCUserInfo(ctx, discovery.UserInfoEndpoint, token.AccessToken) - if err != nil { - return nil, err - } - if len(claims) == 0 && token.IDToken != "" { - claims = decodeJWTClaims(token.IDToken) - } - profile := profileFromClaims(claims) - if profile.ExternalID == "" { - return nil, errors.New("OIDC 用户资料缺少 sub") - } - return profile, nil -} - -func fetchOIDCDiscovery(ctx context.Context, discoveryURL string) (*oidcDiscovery, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) - if err != nil { - return nil, err - } - resp, err := oauthHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("无法获取 OIDC discovery 配置: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("OIDC discovery 返回异常状态: %s", resp.Status) - } - var discovery oidcDiscovery - if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil { - return nil, err - } - if discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint == "" { - return nil, errors.New("OIDC discovery 缺少授权或 token 端点") - } - return &discovery, nil -} - -func exchangeOIDCToken(ctx context.Context, tokenEndpoint string, source *model.AuthSource, code string, redirectURL string) (*oauthTokenResponse, error) { - form := url.Values{} - form.Set("grant_type", "authorization_code") - form.Set("client_id", source.ClientID) - form.Set("client_secret", source.ClientSecret) - form.Set("code", code) - form.Set("redirect_uri", redirectURL) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - resp, err := oauthHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("OIDC token 请求失败: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, fmt.Errorf("OIDC token 接口返回异常状态: %s %s", resp.Status, strings.TrimSpace(string(raw))) - } - var token oauthTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { - return nil, err - } - return &token, nil -} - -func fetchOIDCUserInfo(ctx context.Context, endpoint string, accessToken string) (map[string]any, error) { - if endpoint == "" { - return map[string]any{}, nil - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Accept", "application/json") - resp, err := oauthHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("OIDC userinfo 请求失败: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, fmt.Errorf("OIDC userinfo 返回异常状态: %s %s", resp.Status, strings.TrimSpace(string(raw))) - } - var claims map[string]any - if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil { - return nil, err - } - return claims, nil -} - -func decodeJWTClaims(token string) map[string]any { - parts := strings.Split(token, ".") - if len(parts) < 2 { - return map[string]any{} - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return map[string]any{} - } - var claims map[string]any - if err := json.Unmarshal(payload, &claims); err != nil { - return map[string]any{} - } - return claims -} - -func profileFromClaims(claims map[string]any) *OAuthProfile { - stringClaim := func(keys ...string) string { - for _, key := range keys { - if value, ok := claims[key].(string); ok && strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" - } - return &OAuthProfile{ - ExternalID: stringClaim("sub"), - ExternalUsername: stringClaim("preferred_username", "nickname", "name", "email"), - DisplayName: stringClaim("name", "preferred_username", "nickname", "email"), - Email: stringClaim("email"), - } -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} diff --git a/openflare-server/internal/service/auth_source_test.go b/openflare-server/internal/service/auth_source_test.go deleted file mode 100644 index 83c5f71c..00000000 --- a/openflare-server/internal/service/auth_source_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package service - -import ( - "testing" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestCompleteOAuthLoginRequiresLinkWhenRegistrationDisabled(t *testing.T) { - setupServiceTestDB(t) - - source := createTestAuthSource(t) - result, pending, err := CompleteOAuthLogin(source, &OAuthProfile{ - ExternalID: "external-1", - ExternalUsername: "external-user", - DisplayName: "External User", - Email: "external@example.com", - }, nil) - if err != nil { - t.Fatalf("CompleteOAuthLogin failed: %v", err) - } - if result.Status != "link_required" || pending == nil { - t.Fatalf("expected link_required with pending account, got %#v pending=%#v", result, pending) - } - - user, err := LinkPendingExternalAccount(pending, LinkExistingRequest{ - Username: "root", - Password: "123456", - }) - if err != nil { - t.Fatalf("LinkPendingExternalAccount failed: %v", err) - } - if user.Username != "root" { - t.Fatalf("expected root user, got %s", user.Username) - } - account, err := model.FindExternalAccount(source.ID, "external-1") - if err != nil { - t.Fatalf("expected external account to be linked: %v", err) - } - if account.UserID != user.Id { - t.Fatalf("expected external account user %d, got %d", user.Id, account.UserID) - } -} - -func createTestAuthSource(t *testing.T) *model.AuthSource { - t.Helper() - source := &model.AuthSource{ - Name: "test-oidc", - Type: model.AuthSourceTypeOIDC, - DisplayName: "Test OIDC", - ClientID: "client-id", - ClientSecret: "client-secret", - Scopes: "openid profile email", - OpenIDDiscoveryURL: "https://idp.example.com/.well-known/openid-configuration", - } - if err := model.CreateAuthSource(source); err != nil { - t.Fatalf("CreateAuthSource failed: %v", err) - } - return source -} diff --git a/openflare-server/internal/service/cap.go b/openflare-server/internal/service/cap.go deleted file mode 100644 index b269e5c8..00000000 --- a/openflare-server/internal/service/cap.go +++ /dev/null @@ -1,68 +0,0 @@ -package service - -import ( - "context" - "log/slog" - "time" - - "github.com/go-redis/redis/v8" - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/utils/cap" -) - -// RedisCapStore wraps the shared Redis client to implement the cap.Store interface -type RedisCapStore struct{} - -func (s *RedisCapStore) Get(ctx context.Context, key string) (string, bool, error) { - val, err := common.RDB.Get(ctx, key).Result() - if err != nil { - if err == redis.Nil { - return "", false, nil - } - return "", false, err - } - return val, true, nil -} - -func (s *RedisCapStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error { - return common.RDB.Set(ctx, key, val, ttl).Err() -} - -func (s *RedisCapStore) Delete(ctx context.Context, key string) error { - return common.RDB.Del(ctx, key).Err() -} - -// CapManager is the global CAPTCHA manager instance -var CapManager *cap.Manager - -// InitCap initializes the global CAPTCHA manager -func InitCap() { - var store cap.Store - if common.RedisEnabled { - store = &RedisCapStore{} - slog.Info("CAPTCHA service initialized with Redis store") - } else { - store = cap.NewMemoryStore(1 * time.Minute) - slog.Info("CAPTCHA service initialized with Memory store") - } - - secret := common.JWTSecret - if secret == "" { - secret = common.SessionSecret - } - secretBytes := []byte(secret) - if len(secretBytes) < 16 { - // CAPTCHA JWT verification requires a key of at least 16 bytes - padding := make([]byte, 16-len(secretBytes)) - secretBytes = append(secretBytes, padding...) - } - - CapManager = cap.NewManager(cap.Config{ - Secret: secretBytes, - ChallengeCount: 50, - ChallengeSize: 32, - ChallengeDifficulty: 4, - ChallengeTTL: 10 * time.Minute, - TokenTTL: 20 * time.Minute, - }, store) -} diff --git a/openflare-server/internal/service/config_version.go b/openflare-server/internal/service/config_version.go deleted file mode 100644 index b0970ed7..00000000 --- a/openflare-server/internal/service/config_version.go +++ /dev/null @@ -1,1356 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "path" - "sort" - "strconv" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - openrestyrender "github.com/rain-kl/openflare/pkg/render/openresty" - - "gorm.io/gorm" -) - -type ReleaseResult struct { - Version *model.ConfigVersion `json:"version"` - Routes []*model.ProxyRoute `json:"routes"` -} - -type SupportFile struct { - Path string `json:"path"` - Content string `json:"content"` -} - -type ConfigPreviewResult struct { - SnapshotJSON string `json:"snapshot_json"` - MainConfig string `json:"main_config"` - RouteConfig string `json:"route_config"` - RenderedConfig string `json:"rendered_config"` - SupportFiles []SupportFile `json:"support_files"` - Checksum string `json:"checksum"` - RouteCount int `json:"route_count"` - WebsiteCount int `json:"website_count"` -} - -type ConfigVersionSummary = model.ConfigVersionSummary - -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"` - WAFConfigChanged bool `json:"waf_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 { - Key string `json:"key"` - PreviousValue string `json:"previous_value"` - CurrentValue string `json:"current_value"` -} - -type snapshotRoute struct { - ID uint `json:"id,omitempty"` - SiteName string `json:"site_name,omitempty"` - Domain string `json:"domain"` - Domains []string `json:"domains,omitempty"` - OriginURL string `json:"origin_url"` - OriginHost string `json:"origin_host,omitempty"` - Upstreams []string `json:"upstreams,omitempty"` - Enabled bool `json:"enabled"` - EnableHTTPS bool `json:"enable_https"` - CertID *uint `json:"cert_id,omitempty"` - CertIDs []uint `json:"cert_ids,omitempty"` - DomainCertIDs []uint `json:"domain_cert_ids,omitempty"` - RedirectHTTP bool `json:"redirect_http"` - LimitConnPerServer int `json:"limit_conn_per_server,omitempty"` - LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"` - LimitRate string `json:"limit_rate,omitempty"` - CacheEnabled bool `json:"cache_enabled"` - CachePolicy string `json:"cache_policy,omitempty"` - CacheRules []string `json:"cache_rules,omitempty"` - CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"` - BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"` - BasicAuthUsername string `json:"basic_auth_username,omitempty"` - BasicAuthPassword string `json:"basic_auth_password,omitempty"` - Remark string `json:"remark,omitempty"` - UpstreamType string `json:"upstream_type,omitempty"` - TunnelNodeID *uint `json:"tunnel_node_id,omitempty"` - TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"` - TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"` - PagesProjectID *uint `json:"pages_project_id,omitempty"` - PagesDeployment *snapshotPagesDeployment `json:"pages_deployment,omitempty"` -} - -type snapshotPagesDeployment struct { - ProjectID uint `json:"project_id"` - ProjectSlug string `json:"project_slug"` - DeploymentID uint `json:"deployment_id"` - DeploymentNumber int `json:"deployment_number"` - Checksum string `json:"checksum"` - EntryFile string `json:"entry_file"` - SPAFallbackEnabled bool `json:"spa_fallback_enabled"` - SPAFallbackPath string `json:"spa_fallback_path"` - APIProxyEnabled bool `json:"api_proxy_enabled"` - APIProxyPath string `json:"api_proxy_path"` - APIProxyPass string `json:"api_proxy_pass"` - APIProxyRewrite string `json:"api_proxy_rewrite"` - LocalRoot string `json:"local_root"` -} - -type snapshotWAFRuleGroup struct { - ID uint `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - IsGlobal bool `json:"is_global"` - BlockStatusCode int `json:"block_status_code"` - BlockResponseBody string `json:"block_response_body,omitempty"` - IPWhitelist []string `json:"ip_whitelist,omitempty"` - IPBlacklist []string `json:"ip_blacklist,omitempty"` - IPWhitelistGroups []uint `json:"ip_whitelist_group_ids,omitempty"` - IPBlacklistGroups []uint `json:"ip_blacklist_group_ids,omitempty"` - CountryWhitelist []string `json:"country_whitelist,omitempty"` - CountryBlacklist []string `json:"country_blacklist,omitempty"` - RegionWhitelist []string `json:"region_whitelist,omitempty"` - RegionBlacklist []string `json:"region_blacklist,omitempty"` - PoWEnabled bool `json:"pow_enabled,omitempty"` - PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"` -} - -type snapshotWAFIPGroup struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Enabled bool `json:"enabled"` - IPList []string `json:"ip_list,omitempty"` -} - -type snapshotWAFBinding struct { - RouteID uint `json:"route_id"` - SiteName string `json:"site_name"` - RuleGroupIDs []uint `json:"rule_group_ids"` -} - -type snapshotWAFDocument struct { - RuleGroups []snapshotWAFRuleGroup `json:"rule_groups"` - IPGroups []snapshotWAFIPGroup `json:"ip_groups,omitempty"` - Bindings []snapshotWAFBinding `json:"bindings"` -} - -type openRestyConfigSnapshot struct { - DefaultServerReturnStatus int `json:"default_server_return_status"` - WorkerProcesses string `json:"worker_processes"` - WorkerConnections int `json:"worker_connections"` - WorkerRlimitNofile int `json:"worker_rlimit_nofile"` - EventsUse string `json:"events_use,omitempty"` - EventsMultiAcceptEnabled bool `json:"events_multi_accept_enabled"` - KeepaliveTimeout int `json:"keepalive_timeout"` - KeepaliveRequests int `json:"keepalive_requests"` - ClientHeaderTimeout int `json:"client_header_timeout"` - ClientBodyTimeout int `json:"client_body_timeout"` - ClientMaxBodySize string `json:"client_max_body_size"` - LargeClientHeaderBuffers string `json:"large_client_header_buffers"` - SendTimeout int `json:"send_timeout"` - ProxyConnectTimeout int `json:"proxy_connect_timeout"` - ProxySendTimeout int `json:"proxy_send_timeout"` - ProxyReadTimeout int `json:"proxy_read_timeout"` - WebsocketEnabled bool `json:"websocket_enabled"` - HTTP3Enabled bool `json:"http3_enabled"` - ProxyRequestBuffering bool `json:"proxy_request_buffering"` - ProxyBufferingEnabled bool `json:"proxy_buffering_enabled"` - ProxyBuffers string `json:"proxy_buffers"` - ProxyBufferSize string `json:"proxy_buffer_size"` - ProxyBusyBuffersSize string `json:"proxy_busy_buffers_size"` - GzipEnabled bool `json:"gzip_enabled"` - GzipMinLength int `json:"gzip_min_length"` - GzipCompLevel int `json:"gzip_comp_level"` - Resolvers string `json:"resolvers,omitempty"` - CacheEnabled bool `json:"cache_enabled"` - CachePath string `json:"cache_path,omitempty"` - CacheLevels string `json:"cache_levels"` - CacheInactive string `json:"cache_inactive"` - CacheMaxSize string `json:"cache_max_size"` - CacheKeyTemplate string `json:"cache_key_template"` - CacheLockEnabled bool `json:"cache_lock_enabled"` - CacheLockTimeout string `json:"cache_lock_timeout"` - CacheUseStale string `json:"cache_use_stale"` - MainConfigTemplate string `json:"main_config_template,omitempty"` -} - -type snapshotDocument struct { - Routes []snapshotRoute `json:"routes"` - OpenRestyConfig openRestyConfigSnapshot `json:"openresty_config"` - WAF snapshotWAFDocument `json:"waf"` -} - -type configBundle struct { - Routes []*model.ProxyRoute - SnapshotRoutes []snapshotRoute - WAFSnapshot snapshotWAFDocument - OpenRestyConfig openRestyConfigSnapshot - SnapshotJSON string - MainConfig string - RouteConfig string - SupportFiles []SupportFile - Checksum string - ChangedOptionKeys []string -} - -func ListConfigVersions() ([]*ConfigVersionSummary, error) { - return model.ListConfigVersionSummaries() -} - -func GetConfigVersionDetail(id uint) (*ConfigVersionDetail, error) { - return model.GetConfigVersionByID(id) -} - -func GetActiveConfigVersion() (*ConfigVersionDetail, error) { - return model.GetActiveConfigVersion() -} - -func PreviewConfigVersion() (*ConfigPreviewResult, error) { - bundle, err := buildCurrentConfigBundle(false) - if err != nil { - return nil, err - } - return &ConfigPreviewResult{ - SnapshotJSON: bundle.SnapshotJSON, - MainConfig: bundle.MainConfig, - RouteConfig: bundle.RouteConfig, - RenderedConfig: bundle.RouteConfig, - SupportFiles: bundle.SupportFiles, - Checksum: bundle.Checksum, - RouteCount: len(bundle.Routes), - WebsiteCount: len(bundle.SnapshotRoutes), - }, nil -} - -func DiffConfigVersion() (*ConfigDiffResult, error) { - bundle, err := buildCurrentConfigBundle(false) - if err != nil { - 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.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 - } - result.ActiveVersion = activeVersion.Version - activeSnapshot, err := parseSnapshotDocument(activeVersion.SnapshotJSON) - if err != nil { - return nil, err - } - 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) - } - } - 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 { - result.AddedDomains = append(result.AddedDomains, domain) - continue - } - if !snapshotRouteConfigEqual(activeRoute, currentRoute) { - result.ModifiedDomains = append(result.ModifiedDomains, domain) - } - } - for domain := range activeMap { - if _, ok := currentMap[domain]; !ok { - result.RemovedDomains = append(result.RemovedDomains, domain) - } - } - result.MainConfigChanged = activeVersion.MainConfig != bundle.MainConfig - result.WAFConfigChanged = !snapshotWAFConfigEqual(activeSnapshot.WAF, bundle.WAFSnapshot) - 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) - sort.Strings(result.ChangedOptionKeys) - return result, nil -} - -func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error) { - bundle, err := buildCurrentConfigBundle(true) - if err != nil { - return nil, err - } - if len(bundle.Routes) == 0 { - return nil, errors.New("没有可发布的启用规则") - } - activeVersion, err := model.GetActiveConfigVersion() - if !force && err == nil && activeVersion.Checksum == bundle.Checksum { - return nil, errors.New("当前规则没有变更,不能重复发布") - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - supportFilesJSON, err := json.Marshal(bundle.SupportFiles) - if err != nil { - return nil, err - } - version, err := nextVersionNumber(time.Now()) - if err != nil { - return nil, err - } - record := &model.ConfigVersion{ - Version: version, - SnapshotJSON: bundle.SnapshotJSON, - MainConfig: bundle.MainConfig, - RenderedConfig: bundle.RouteConfig, - SupportFilesJSON: string(supportFilesJSON), - Checksum: bundle.Checksum, - IsActive: true, - CreatedBy: createdBy, - } - err = model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.ConfigVersion{}).Where("is_active = ?", true).Update("is_active", false).Error; err != nil { - return err - } - if err := tx.Create(record).Error; err != nil { - return err - } - return nil - }) - if err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("版本号生成冲突,请重试") - } - return nil, err - } - activeConfig := &ActiveConfigMeta{ - Version: record.Version, - Checksum: record.Checksum, - } - BroadcastAgentWSActiveConfig(activeConfig) - BroadcastFlaredWSActiveConfig(activeConfig) - return &ReleaseResult{ - Version: record, - Routes: bundle.Routes, - }, nil -} - -func sourceSupportFiles(files []SupportFile) []SupportFile { - if len(files) == 0 { - return nil - } - result := make([]SupportFile, 0, len(files)) - for _, file := range files { - if isRuntimeGeneratedSupportFile(file.Path) { - continue - } - result = append(result, file) - } - return result -} - -func isRuntimeGeneratedSupportFile(path string) bool { - switch strings.TrimSpace(path) { - case "pow_config.json", "waf_config.json", openrestyrender.SourceConfigFileName: - return true - default: - return false - } -} - -func ActivateConfigVersion(id uint) (*model.ConfigVersion, error) { - version, err := model.GetConfigVersionByID(id) - if err != nil { - return nil, err - } - err = model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.ConfigVersion{}).Where("is_active = ?", true).Update("is_active", false).Error; err != nil { - return err - } - if err := tx.Model(version).Update("is_active", true).Error; err != nil { - return err - } - return nil - }) - if err != nil { - return nil, err - } - version.IsActive = true - activeConfig := &ActiveConfigMeta{ - Version: version.Version, - Checksum: version.Checksum, - } - BroadcastAgentWSActiveConfig(activeConfig) - BroadcastFlaredWSActiveConfig(activeConfig) - return version, nil -} - -func CleanupConfigVersions(keepCount int) (int64, error) { - if keepCount < 3 { - keepCount = 3 - } - var versions []model.ConfigVersion - if err := model.DB.Select("id", "is_active").Order("id desc").Find(&versions).Error; err != nil { - return 0, err - } - if len(versions) <= keepCount { - return 0, nil - } - var deleteIDs []uint - for i, v := range versions { - if i < keepCount { - continue - } - if v.IsActive { - continue - } - deleteIDs = append(deleteIDs, v.ID) - } - if len(deleteIDs) == 0 { - return 0, nil - } - result := model.DB.Where("id IN ?", deleteIDs).Delete(&model.ConfigVersion{}) - return result.RowsAffected, result.Error -} - -func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) { - routes, err := model.GetEnabledProxyRoutes() - if err != nil { - return nil, err - } - if requireRoutes && len(routes) == 0 { - return nil, errors.New("没有可发布的启用规则") - } - snapshotRoutes, err := buildSnapshotRoutes(routes) - if err != nil { - return nil, err - } - wafSnapshot, err := buildSnapshotWAFDocument(routes) - if err != nil { - return nil, err - } - openRestyConfig := buildOpenRestyConfigSnapshot() - snapshotDoc := snapshotDocument{ - Routes: snapshotRoutes, - OpenRestyConfig: openRestyConfig, - WAF: wafSnapshot, - } - snapshotJSON, err := json.Marshal(snapshotDoc) - if err != nil { - return nil, err - } - certificateFiles, err := buildCertificateSupportFiles(snapshotRoutes) - if err != nil { - return nil, err - } - rendered, err := renderSnapshotConfig(string(snapshotJSON), certificateFiles) - if err != nil { - return nil, err - } - return &configBundle{ - Routes: routes, - SnapshotRoutes: snapshotRoutes, - WAFSnapshot: wafSnapshot, - OpenRestyConfig: openRestyConfig, - SnapshotJSON: string(snapshotJSON), - MainConfig: rendered.MainConfig, - RouteConfig: rendered.RouteConfig, - SupportFiles: fromOpenRestySupportFiles(rendered.SupportFiles), - Checksum: rendered.Checksum, - ChangedOptionKeys: openRestyOptionKeys(), - }, nil -} - -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) - } - upstreamType := normalizeUpstreamType(route.UpstreamType) - originURL := route.OriginURL - upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) - if err != nil { - return nil, fmt.Errorf("路由 %s 上游配置无效", route.Domain) - } - var tunnelNodeID *uint - var tunnelTargetAddr string - var tunnelTargetProtocol string - var pagesProjectID *uint - var pagesDeployment *snapshotPagesDeployment - if upstreamType == "tunnel" { - originURL = resolveTunnelOpenRestyUpstreamURL() - upstreams = []string{originURL} - tunnelNodeID = route.TunnelNodeID - tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr) - tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol) - } else if upstreamType == "pages" { - deployment, err := buildSnapshotPagesDeployment(route.PagesProjectID) - if err != nil { - return nil, fmt.Errorf("路由 %s Pages 配置无效: %w", route.Domain, err) - } - originURL = fmt.Sprintf("openflare-pages://project/%d", deployment.ProjectID) - upstreams = []string{originURL} - pagesProjectID = route.PagesProjectID - pagesDeployment = deployment - } - cacheRules, err := decodeStoredCacheRules(route.CacheRules) - if err != nil { - return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain) - } - items = append(items, snapshotRoute{ - ID: route.ID, - SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]), - Domain: domains[0], - Domains: domains, - OriginURL: 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, - BasicAuthEnabled: route.BasicAuthEnabled, - BasicAuthUsername: route.BasicAuthUsername, - BasicAuthPassword: route.BasicAuthPassword, - Remark: route.Remark, - UpstreamType: upstreamType, - TunnelNodeID: tunnelNodeID, - TunnelTargetAddr: tunnelTargetAddr, - TunnelTargetProto: tunnelTargetProtocol, - PagesProjectID: pagesProjectID, - PagesDeployment: pagesDeployment, - }) - } - return items, nil -} - -func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, error) { - if projectID == nil || *projectID == 0 { - return nil, errors.New("pages_project_id is required") - } - project, err := model.GetPagesProjectByID(*projectID) - if err != nil { - return nil, err - } - if !project.Enabled { - return nil, errors.New("Pages 项目未启用") - } - if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 { - return nil, errors.New("Pages 项目没有激活部署") - } - deployment, err := model.GetPagesDeploymentByID(*project.ActiveDeploymentID) - if err != nil { - return nil, err - } - if deployment.ProjectID != project.ID { - return nil, errors.New("Pages 激活部署不属于当前项目") - } - localRoot := fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID) - cleanedRootDir := strings.TrimSpace(project.RootDir) - if cleanedRootDir != "" { - localRoot = path.Join(localRoot, cleanedRootDir) - } - return &snapshotPagesDeployment{ - ProjectID: project.ID, - ProjectSlug: project.Slug, - DeploymentID: deployment.ID, - DeploymentNumber: deployment.DeploymentNumber, - Checksum: deployment.Checksum, - EntryFile: project.EntryFile, - SPAFallbackEnabled: project.SPAFallbackEnabled, - SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath), - APIProxyEnabled: project.APIProxyEnabled, - APIProxyPath: project.APIProxyPath, - APIProxyPass: project.APIProxyPass, - APIProxyRewrite: project.APIProxyRewrite, - LocalRoot: localRoot, - }, nil -} - -func resolveTunnelOpenRestyUpstreamURL() string { - relayNodes, err := model.ListNodesByType("tunnel_relay") - if err == nil && len(relayNodes) > 0 { - for _, node := range relayNodes { - if node != nil { - addr := relayAgentAddress(node) - if addr != "" { - return "http://" + addr - } - } - } - } - return "http://127.0.0.1:8080" -} - -func buildSnapshotWAFDocument(routes []*model.ProxyRoute) (snapshotWAFDocument, error) { - if err := EnsureDefaultWAFRuleGroup(); err != nil { - return snapshotWAFDocument{}, err - } - views, err := ListWAFRuleGroups() - if err != nil { - return snapshotWAFDocument{}, err - } - ruleGroups := make([]snapshotWAFRuleGroup, 0, len(views)) - for _, view := range views { - if !view.Enabled { - continue - } - ruleGroups = append(ruleGroups, snapshotWAFRuleGroup{ - ID: view.ID, - Name: view.Name, - Enabled: view.Enabled, - IsGlobal: view.IsGlobal, - BlockStatusCode: view.BlockStatusCode, - BlockResponseBody: view.BlockResponseBody, - IPWhitelist: view.IPWhitelist, - IPBlacklist: view.IPBlacklist, - IPWhitelistGroups: view.IPWhitelistGroups, - IPBlacklistGroups: view.IPBlacklistGroups, - CountryWhitelist: view.CountryWhitelist, - CountryBlacklist: view.CountryBlacklist, - RegionWhitelist: view.RegionWhitelist, - RegionBlacklist: view.RegionBlacklist, - PoWEnabled: view.PoWEnabled, - PoWConfig: view.PoWConfig, - }) - } - ipGroups, err := buildSnapshotWAFIPGroups(ruleGroups) - if err != nil { - return snapshotWAFDocument{}, err - } - enabledRouteIDs := make(map[uint]string, len(routes)) - for _, route := range routes { - if route == nil { - continue - } - siteName := strings.TrimSpace(route.SiteName) - if siteName == "" { - siteName = route.Domain - } - enabledRouteIDs[route.ID] = siteName - } - var rawBindings []model.WAFRuleGroupBinding - if err := model.DB.Order("proxy_route_id asc").Order("rule_group_id asc").Find(&rawBindings).Error; err != nil { - return snapshotWAFDocument{}, err - } - groupIDsByRoute := make(map[uint][]uint, len(rawBindings)) - for _, binding := range rawBindings { - if _, ok := enabledRouteIDs[binding.ProxyRouteID]; !ok { - continue - } - groupIDsByRoute[binding.ProxyRouteID] = append(groupIDsByRoute[binding.ProxyRouteID], binding.RuleGroupID) - } - bindings := make([]snapshotWAFBinding, 0, len(groupIDsByRoute)) - for routeID, groupIDs := range groupIDsByRoute { - sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) - bindings = append(bindings, snapshotWAFBinding{ - RouteID: routeID, - SiteName: enabledRouteIDs[routeID], - RuleGroupIDs: groupIDs, - }) - } - sort.Slice(bindings, func(i, j int) bool { - if bindings[i].SiteName == bindings[j].SiteName { - return bindings[i].RouteID < bindings[j].RouteID - } - return bindings[i].SiteName < bindings[j].SiteName - }) - return snapshotWAFDocument{RuleGroups: ruleGroups, IPGroups: ipGroups, Bindings: bindings}, nil -} - -func buildSnapshotWAFIPGroups(ruleGroups []snapshotWAFRuleGroup) ([]snapshotWAFIPGroup, error) { - idSet := make(map[uint]struct{}) - for _, group := range ruleGroups { - for _, id := range group.IPWhitelistGroups { - idSet[id] = struct{}{} - } - for _, id := range group.IPBlacklistGroups { - idSet[id] = struct{}{} - } - } - if len(idSet) == 0 { - return []snapshotWAFIPGroup{}, nil - } - ids := make([]uint, 0, len(idSet)) - for id := range idSet { - ids = append(ids, id) - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - groups, err := model.ListWAFIPGroupsByIDs(ids) - if err != nil { - return nil, err - } - groupByID := make(map[uint]*model.WAFIPGroup, len(groups)) - for _, group := range groups { - groupByID[group.ID] = group - } - snapshots := make([]snapshotWAFIPGroup, 0, len(ids)) - for _, id := range ids { - group := groupByID[id] - if group == nil { - return nil, fmt.Errorf("IP 组 %d 不存在", id) - } - snapshots = append(snapshots, snapshotWAFIPGroup{ - ID: group.ID, - Name: group.Name, - Type: group.Type, - Enabled: group.Enabled, - }) - } - return snapshots, 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 == "" { - return &snapshotDocument{Routes: []snapshotRoute{}}, nil - } - if strings.HasPrefix(text, "[") { - var routes []snapshotRoute - if err := json.Unmarshal([]byte(text), &routes); err != nil { - return nil, errors.New("历史版本快照格式不合法") - } - return &snapshotDocument{Routes: normalizeSnapshotRoutes(routes)}, nil - } - var snapshot snapshotDocument - if err := json.Unmarshal([]byte(text), &snapshot); err != nil { - return nil, errors.New("历史版本快照格式不合法") - } - snapshot.Routes = normalizeSnapshotRoutes(snapshot.Routes) - return &snapshot, nil -} - -func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { - if len(routes) == 0 { - 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] - routes[index].Upstreams = normalizedUpstreams - } - normalizedCacheRules, err := normalizeCacheRules(routes[index].CacheEnabled, routes[index].CachePolicy, routes[index].CacheRules) - if err == nil { - 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].BasicAuthEnabled { - routes[index].BasicAuthUsername = "" - routes[index].BasicAuthPassword = "" - } - routes[index].UpstreamType = normalizeUpstreamType(routes[index].UpstreamType) - if routes[index].UpstreamType == "tunnel" { - routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr) - routes[index].TunnelTargetProto = normalizeTunnelTargetProtocol(routes[index].TunnelTargetProto) - routes[index].PagesProjectID = nil - routes[index].PagesDeployment = nil - } else if routes[index].UpstreamType == "pages" { - routes[index].TunnelNodeID = nil - routes[index].TunnelTargetAddr = "" - routes[index].TunnelTargetProto = "" - } else { - routes[index].TunnelNodeID = nil - routes[index].TunnelTargetAddr = "" - routes[index].TunnelTargetProto = "" - routes[index].PagesProjectID = nil - routes[index].PagesDeployment = nil - } - } - return routes -} - -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.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.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintPtrEqual(left.PagesProjectID, right.PagesProjectID) || !snapshotPagesDeploymentEqual(left.PagesDeployment, right.PagesDeployment) || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) { - return false - } - if len(left.Domains) != len(right.Domains) { - 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 - } - for index := range left.Upstreams { - if left.Upstreams[index] != right.Upstreams[index] { - return false - } - } - if len(left.CacheRules) != len(right.CacheRules) { - return false - } - for index := range left.CacheRules { - if left.CacheRules[index] != right.CacheRules[index] { - return false - } - } - if len(left.CustomHeaders) != len(right.CustomHeaders) { - return false - } - for index := range left.CustomHeaders { - if left.CustomHeaders[index] != right.CustomHeaders[index] { - return false - } - } - return true -} - -func snapshotPagesDeploymentEqual(left *snapshotPagesDeployment, right *snapshotPagesDeployment) bool { - if left == nil || right == nil { - return left == nil && right == nil - } - leftJSON, err := json.Marshal(left) - if err != nil { - return false - } - rightJSON, err := json.Marshal(right) - if err != nil { - return false - } - return string(leftJSON) == string(rightJSON) -} - -func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument) bool { - leftJSON, err := json.Marshal(left) - if err != nil { - return false - } - rightJSON, err := json.Marshal(right) - if err != nil { - return false - } - return string(leftJSON) == string(rightJSON) -} - -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 -} - -func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot { - return openRestyConfigSnapshot{ - DefaultServerReturnStatus: common.OpenRestyDefaultServerReturnStatus, - WorkerProcesses: common.OpenRestyWorkerProcesses, - WorkerConnections: common.OpenRestyWorkerConnections, - WorkerRlimitNofile: common.OpenRestyWorkerRlimitNofile, - EventsUse: common.OpenRestyEventsUse, - EventsMultiAcceptEnabled: common.OpenRestyEventsMultiAcceptEnabled, - KeepaliveTimeout: common.OpenRestyKeepaliveTimeout, - KeepaliveRequests: common.OpenRestyKeepaliveRequests, - ClientHeaderTimeout: common.OpenRestyClientHeaderTimeout, - ClientBodyTimeout: common.OpenRestyClientBodyTimeout, - ClientMaxBodySize: common.OpenRestyClientMaxBodySize, - LargeClientHeaderBuffers: common.OpenRestyLargeClientHeaderBuffers, - SendTimeout: common.OpenRestySendTimeout, - ProxyConnectTimeout: common.OpenRestyProxyConnectTimeout, - ProxySendTimeout: common.OpenRestyProxySendTimeout, - ProxyReadTimeout: common.OpenRestyProxyReadTimeout, - WebsocketEnabled: common.OpenRestyWebsocketEnabled, - HTTP3Enabled: common.OpenRestyHTTP3Enabled, - ProxyRequestBuffering: common.OpenRestyProxyRequestBufferingEnabled, - ProxyBufferingEnabled: common.OpenRestyProxyBufferingEnabled, - ProxyBuffers: common.OpenRestyProxyBuffers, - ProxyBufferSize: common.OpenRestyProxyBufferSize, - ProxyBusyBuffersSize: common.OpenRestyProxyBusyBuffersSize, - GzipEnabled: common.OpenRestyGzipEnabled, - GzipMinLength: common.OpenRestyGzipMinLength, - GzipCompLevel: common.OpenRestyGzipCompLevel, - Resolvers: common.OpenRestyResolvers, - CacheEnabled: common.OpenRestyCacheEnabled, - CachePath: common.OpenRestyCachePath, - CacheLevels: common.OpenRestyCacheLevels, - CacheInactive: common.OpenRestyCacheInactive, - CacheMaxSize: common.OpenRestyCacheMaxSize, - CacheKeyTemplate: common.OpenRestyCacheKeyTemplate, - CacheLockEnabled: common.OpenRestyCacheLockEnabled, - CacheLockTimeout: common.OpenRestyCacheLockTimeout, - CacheUseStale: common.OpenRestyCacheUseStale, - MainConfigTemplate: common.OpenRestyMainConfigTemplate, - } -} - -func renderSnapshotConfig(sourceJSON string, certificateFiles []SupportFile) (*openrestyrender.Result, error) { - return openrestyrender.RenderJSON(sourceJSON, toOpenRestySupportFiles(certificateFiles)) -} - -func buildCertificateSupportFiles(routes []snapshotRoute) ([]SupportFile, error) { - certIDSet := make(map[uint]struct{}) - for _, route := range routes { - for _, certID := range route.CertIDs { - if certID != 0 { - certIDSet[certID] = struct{}{} - } - } - } - if len(certIDSet) == 0 { - return nil, nil - } - certIDs := make([]uint, 0, len(certIDSet)) - for certID := range certIDSet { - certIDs = append(certIDs, certID) - } - sort.Slice(certIDs, func(i, j int) bool { return certIDs[i] < certIDs[j] }) - certificates, err := loadTLSCertificates(certIDs) - if err != nil { - return nil, err - } - files := make([]SupportFile, 0, len(certificates)*2) - for _, certificate := range certificates { - if certificate == nil { - continue - } - files = append(files, - SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)}, - SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)}, - ) - } - return dedupeSupportFiles(files), nil -} - -func toOpenRestySupportFiles(files []SupportFile) []openrestyrender.SupportFile { - if len(files) == 0 { - return nil - } - result := make([]openrestyrender.SupportFile, 0, len(files)) - for _, file := range files { - result = append(result, openrestyrender.SupportFile{Path: file.Path, Content: file.Content}) - } - return result -} - -func fromOpenRestySupportFiles(files []openrestyrender.SupportFile) []SupportFile { - if len(files) == 0 { - return nil - } - result := make([]SupportFile, 0, len(files)) - for _, file := range files { - result = append(result, SupportFile{Path: file.Path, Content: file.Content}) - } - return result -} - -func buildInitialOpenRestyOptionDiffs(current openRestyConfigSnapshot) []ConfigOptionDiffItem { - details := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, current) - for index := range details { - details[index].PreviousValue = "" - } - return details -} - -func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyConfigSnapshot) []ConfigOptionDiffItem { - changes := make([]ConfigOptionDiffItem, 0) - appendIfChanged := func(key string, previous string, current string) { - if previous == current { - return - } - changes = append(changes, ConfigOptionDiffItem{ - Key: key, - PreviousValue: previous, - CurrentValue: current, - }) - } - appendIfChanged("OpenRestyDefaultServerReturnStatus", fmt.Sprintf("%d", left.DefaultServerReturnStatus), fmt.Sprintf("%d", right.DefaultServerReturnStatus)) - appendIfChanged("OpenRestyWorkerProcesses", left.WorkerProcesses, right.WorkerProcesses) - appendIfChanged("OpenRestyWorkerConnections", fmt.Sprintf("%d", left.WorkerConnections), fmt.Sprintf("%d", right.WorkerConnections)) - appendIfChanged("OpenRestyWorkerRlimitNofile", fmt.Sprintf("%d", left.WorkerRlimitNofile), fmt.Sprintf("%d", right.WorkerRlimitNofile)) - appendIfChanged("OpenRestyEventsUse", left.EventsUse, right.EventsUse) - appendIfChanged("OpenRestyEventsMultiAcceptEnabled", fmt.Sprintf("%t", left.EventsMultiAcceptEnabled), fmt.Sprintf("%t", right.EventsMultiAcceptEnabled)) - appendIfChanged("OpenRestyKeepaliveTimeout", fmt.Sprintf("%d", left.KeepaliveTimeout), fmt.Sprintf("%d", right.KeepaliveTimeout)) - appendIfChanged("OpenRestyKeepaliveRequests", fmt.Sprintf("%d", left.KeepaliveRequests), fmt.Sprintf("%d", right.KeepaliveRequests)) - appendIfChanged("OpenRestyClientHeaderTimeout", fmt.Sprintf("%d", left.ClientHeaderTimeout), fmt.Sprintf("%d", right.ClientHeaderTimeout)) - appendIfChanged("OpenRestyClientBodyTimeout", fmt.Sprintf("%d", left.ClientBodyTimeout), fmt.Sprintf("%d", right.ClientBodyTimeout)) - appendIfChanged("OpenRestyClientMaxBodySize", left.ClientMaxBodySize, right.ClientMaxBodySize) - appendIfChanged("OpenRestyLargeClientHeaderBuffers", left.LargeClientHeaderBuffers, right.LargeClientHeaderBuffers) - appendIfChanged("OpenRestySendTimeout", fmt.Sprintf("%d", left.SendTimeout), fmt.Sprintf("%d", right.SendTimeout)) - appendIfChanged("OpenRestyProxyConnectTimeout", fmt.Sprintf("%d", left.ProxyConnectTimeout), fmt.Sprintf("%d", right.ProxyConnectTimeout)) - appendIfChanged("OpenRestyProxySendTimeout", fmt.Sprintf("%d", left.ProxySendTimeout), fmt.Sprintf("%d", right.ProxySendTimeout)) - appendIfChanged("OpenRestyProxyReadTimeout", fmt.Sprintf("%d", left.ProxyReadTimeout), fmt.Sprintf("%d", right.ProxyReadTimeout)) - appendIfChanged("OpenRestyWebsocketEnabled", fmt.Sprintf("%t", left.WebsocketEnabled), fmt.Sprintf("%t", right.WebsocketEnabled)) - appendIfChanged("OpenRestyHTTP3Enabled", fmt.Sprintf("%t", left.HTTP3Enabled), fmt.Sprintf("%t", right.HTTP3Enabled)) - appendIfChanged("OpenRestyProxyRequestBufferingEnabled", fmt.Sprintf("%t", left.ProxyRequestBuffering), fmt.Sprintf("%t", right.ProxyRequestBuffering)) - appendIfChanged("OpenRestyProxyBufferingEnabled", fmt.Sprintf("%t", left.ProxyBufferingEnabled), fmt.Sprintf("%t", right.ProxyBufferingEnabled)) - appendIfChanged("OpenRestyProxyBuffers", left.ProxyBuffers, right.ProxyBuffers) - appendIfChanged("OpenRestyProxyBufferSize", left.ProxyBufferSize, right.ProxyBufferSize) - appendIfChanged("OpenRestyProxyBusyBuffersSize", left.ProxyBusyBuffersSize, right.ProxyBusyBuffersSize) - appendIfChanged("OpenRestyGzipEnabled", fmt.Sprintf("%t", left.GzipEnabled), fmt.Sprintf("%t", right.GzipEnabled)) - appendIfChanged("OpenRestyGzipMinLength", fmt.Sprintf("%d", left.GzipMinLength), fmt.Sprintf("%d", right.GzipMinLength)) - appendIfChanged("OpenRestyGzipCompLevel", fmt.Sprintf("%d", left.GzipCompLevel), fmt.Sprintf("%d", right.GzipCompLevel)) - appendIfChanged("OpenRestyResolvers", left.Resolvers, right.Resolvers) - appendIfChanged("OpenRestyCacheEnabled", fmt.Sprintf("%t", left.CacheEnabled), fmt.Sprintf("%t", right.CacheEnabled)) - appendIfChanged("OpenRestyCachePath", left.CachePath, right.CachePath) - appendIfChanged("OpenRestyCacheLevels", left.CacheLevels, right.CacheLevels) - appendIfChanged("OpenRestyCacheInactive", left.CacheInactive, right.CacheInactive) - appendIfChanged("OpenRestyCacheMaxSize", left.CacheMaxSize, right.CacheMaxSize) - appendIfChanged("OpenRestyCacheKeyTemplate", left.CacheKeyTemplate, right.CacheKeyTemplate) - appendIfChanged("OpenRestyCacheLockEnabled", fmt.Sprintf("%t", left.CacheLockEnabled), fmt.Sprintf("%t", right.CacheLockEnabled)) - appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout, right.CacheLockTimeout) - appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale, right.CacheUseStale) - return changes -} - -func extractOptionDiffKeys(details []ConfigOptionDiffItem) []string { - keys := make([]string, 0, len(details)) - for _, item := range details { - keys = append(keys, item.Key) - } - return keys -} - -func openRestyOptionKeys() []string { - return []string{ - "OpenRestyDefaultServerReturnStatus", - "OpenRestyWorkerProcesses", - "OpenRestyWorkerConnections", - "OpenRestyWorkerRlimitNofile", - "OpenRestyEventsUse", - "OpenRestyEventsMultiAcceptEnabled", - "OpenRestyKeepaliveTimeout", - "OpenRestyKeepaliveRequests", - "OpenRestyClientHeaderTimeout", - "OpenRestyClientBodyTimeout", - "OpenRestyClientMaxBodySize", - "OpenRestyLargeClientHeaderBuffers", - "OpenRestySendTimeout", - "OpenRestyProxyConnectTimeout", - "OpenRestyProxySendTimeout", - "OpenRestyProxyReadTimeout", - "OpenRestyWebsocketEnabled", - "OpenRestyHTTP3Enabled", - "OpenRestyProxyRequestBufferingEnabled", - "OpenRestyProxyBufferingEnabled", - "OpenRestyProxyBuffers", - "OpenRestyProxyBufferSize", - "OpenRestyProxyBusyBuffersSize", - "OpenRestyGzipEnabled", - "OpenRestyGzipMinLength", - "OpenRestyGzipCompLevel", - "OpenRestyCacheEnabled", - "OpenRestyCachePath", - "OpenRestyCacheLevels", - "OpenRestyCacheInactive", - "OpenRestyCacheMaxSize", - "OpenRestyCacheKeyTemplate", - "OpenRestyCacheLockEnabled", - "OpenRestyCacheLockTimeout", - "OpenRestyCacheUseStale", - } -} - -func ValidateOpenRestyMainConfigTemplate(templateText string) error { - return openrestyrender.ValidateMainConfigTemplate(templateText) -} - -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 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 uintPtrEqual(left *uint, right *uint) bool { - if left == nil || right == nil { - return left == nil && right == nil - } - return *left == *right -} - -func nextVersionNumber(now time.Time) (string, error) { - prefix := now.Format("20060102") - var latest model.ConfigVersion - err := model.DB. - Select("version"). - Where("version LIKE ?", prefix+"-%"). - Order("version desc"). - First(&latest).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Sprintf("%s-%03d", prefix, 1), nil - } - if err != nil { - return "", err - } - suffix := strings.TrimPrefix(latest.Version, prefix+"-") - sequence, err := strconv.Atoi(suffix) - if err != nil { - return "", fmt.Errorf("invalid config version sequence %q: %w", latest.Version, err) - } - return fmt.Sprintf("%s-%03d", prefix, sequence+1), nil -} - -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 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 certificateCertFileName(id uint) string { - return fmt.Sprintf("%d.crt", id) -} - -func certificateKeyFileName(id uint) string { - return fmt.Sprintf("%d.key", id) -} - -func normalizePEM(content string) string { - return strings.TrimSpace(content) + "\n" -} - -func dedupeSupportFiles(files []SupportFile) []SupportFile { - if len(files) == 0 { - return nil - } - unique := make(map[string]SupportFile, len(files)) - for _, file := range files { - unique[file.Path] = file - } - result := make([]SupportFile, 0, len(unique)) - for _, file := range unique { - result = append(result, file) - } - return result -} diff --git a/openflare-server/internal/service/dashboard.go b/openflare-server/internal/service/dashboard.go deleted file mode 100644 index 9fa52356..00000000 --- a/openflare-server/internal/service/dashboard.go +++ /dev/null @@ -1,254 +0,0 @@ -package service - -import ( - "sort" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -type DashboardOverviewView struct { - GeneratedAt time.Time `json:"generated_at"` - Summary DashboardSummary `json:"summary"` - Traffic DashboardTraffic `json:"traffic"` - Capacity DashboardCapacity `json:"capacity"` - Distributions TrafficDistributions `json:"distributions"` - Trends DashboardTrends `json:"trends"` - Nodes []DashboardNodeHealth `json:"nodes"` -} - -type DashboardSummary struct { - TotalNodes int `json:"total_nodes"` - OnlineNodes int `json:"online_nodes"` - OfflineNodes int `json:"offline_nodes"` - PendingNodes int `json:"pending_nodes"` - UnhealthyNodes int `json:"unhealthy_nodes"` -} - -type DashboardTraffic struct { - RequestCount int64 `json:"request_count"` - UniqueVisitors int64 `json:"unique_visitors"` - ErrorCount int64 `json:"error_count"` - EstimatedQPS float64 `json:"estimated_qps"` - ReportedNodes int `json:"reported_nodes"` -} - -type DashboardCapacity struct { - AverageCPUUsagePercent float64 `json:"average_cpu_usage_percent"` - AverageMemoryUsagePercent float64 `json:"average_memory_usage_percent"` - HighCPUNodes int `json:"high_cpu_nodes"` - HighMemoryNodes int `json:"high_memory_nodes"` - HighStorageNodes int `json:"high_storage_nodes"` -} - -type DashboardTrends struct { - Traffic24h []TrafficTrendPoint `json:"traffic_24h"` - Capacity24h []CapacityTrendPoint `json:"capacity_24h"` - Network24h []NetworkTrendPoint `json:"network_24h"` - DiskIO24h []DiskIOTrendPoint `json:"disk_io_24h"` -} - -type DashboardNodeHealth struct { - ID uint `json:"id"` - NodeID string `json:"node_id"` - Name string `json:"name"` - GeoName string `json:"geo_name"` - GeoLatitude *float64 `json:"geo_latitude"` - GeoLongitude *float64 `json:"geo_longitude"` - Status string `json:"status"` - OpenrestyStatus string `json:"openresty_status"` - CurrentVersion string `json:"current_version"` - LastSeenAt any `json:"last_seen_at"` - ActiveEventCount int `json:"active_event_count"` - CPUUsagePercent float64 `json:"cpu_usage_percent"` - MemoryUsagePercent float64 `json:"memory_usage_percent"` - StorageUsagePercent float64 `json:"storage_usage_percent"` - RequestCount int64 `json:"request_count"` - ErrorCount int64 `json:"error_count"` - UniqueVisitorCount int64 `json:"unique_visitor_count"` -} - -func GetDashboardOverview() (*DashboardOverviewView, error) { - now := time.Now() - since := now.Add(-24 * time.Hour) - - nodes, err := model.ListNodes() - if err != nil { - return nil, err - } - - snapshots, err := model.ListMetricSnapshotsSince(since) - if err != nil { - return nil, err - } - reports, err := model.ListRequestReportsSince(since) - if err != nil { - return nil, err - } - accessLogRegions, err := model.ListNodeAccessLogRegionCounts("", since, 8) - if err != nil { - return nil, err - } - activeEvents, err := model.ListActiveNodeHealthEvents() - if err != nil { - return nil, err - } - - openrestySnapshots, err := model.ListNodeObservationOpenresty("", since, 0) - if err != nil { - return nil, err - } - - view := &DashboardOverviewView{ - GeneratedAt: now, - Nodes: make([]DashboardNodeHealth, 0, len(nodes)), - Distributions: buildTrafficDistributions(reports, accessLogRegions, 8), - Trends: DashboardTrends{ - Traffic24h: buildTrafficTrendPoints(now, reports), - Capacity24h: buildCapacityTrendPoints(now, snapshots), - Network24h: buildNetworkTrendPoints(now, snapshots, openrestySnapshots), - DiskIO24h: buildDiskIOTrendPoints(now, snapshots), - }, - } - - var cpuNodeCount int - var memoryNodeCount int - latestSnapshots := latestMetricSnapshotsByNode(snapshots) - latestTrafficReports := latestTrafficReportsByNode(reports) - activeEventsByNode := activeHealthEventsByNode(activeEvents) - - for _, node := range nodes { - computedStatus := computeNodeStatus(node) - switch computedStatus { - case NodeStatusOnline: - view.Summary.OnlineNodes++ - case NodeStatusOffline: - view.Summary.OfflineNodes++ - case NodeStatusPending: - view.Summary.PendingNodes++ - } - if node.OpenrestyStatus == OpenrestyStatusUnhealthy { - view.Summary.UnhealthyNodes++ - } - - latestSnapshot := latestSnapshots[node.NodeID] - latestTraffic := latestTrafficReports[node.NodeID] - nodeActiveEvents := activeEventsByNode[node.NodeID] - - nodeHealth := DashboardNodeHealth{ - ID: node.ID, - NodeID: node.NodeID, - Name: node.Name, - GeoName: node.GeoName, - GeoLatitude: node.GeoLatitude, - GeoLongitude: node.GeoLongitude, - Status: computedStatus, - OpenrestyStatus: node.OpenrestyStatus, - CurrentVersion: node.CurrentVersion, - LastSeenAt: nodeViewLastSeenAt(node), - ActiveEventCount: len(nodeActiveEvents), - } - - if latestSnapshot != nil { - nodeHealth.CPUUsagePercent = latestSnapshot.CPUUsagePercent - nodeHealth.MemoryUsagePercent = percentage(latestSnapshot.MemoryUsedBytes, latestSnapshot.MemoryTotalBytes) - nodeHealth.StorageUsagePercent = percentage(latestSnapshot.StorageUsedBytes, latestSnapshot.StorageTotalBytes) - if latestSnapshot.CPUUsagePercent > 0 { - view.Capacity.AverageCPUUsagePercent += latestSnapshot.CPUUsagePercent - cpuNodeCount++ - } - if nodeHealth.MemoryUsagePercent > 0 { - view.Capacity.AverageMemoryUsagePercent += nodeHealth.MemoryUsagePercent - memoryNodeCount++ - } - if latestSnapshot.CPUUsagePercent >= 80 { - view.Capacity.HighCPUNodes++ - } - if nodeHealth.MemoryUsagePercent >= 85 { - view.Capacity.HighMemoryNodes++ - } - if nodeHealth.StorageUsagePercent >= 85 { - view.Capacity.HighStorageNodes++ - } - } - - if latestTraffic != nil { - nodeHealth.RequestCount = latestTraffic.RequestCount - nodeHealth.ErrorCount = latestTraffic.ErrorCount - nodeHealth.UniqueVisitorCount = latestTraffic.UniqueVisitorCount - view.Traffic.RequestCount += latestTraffic.RequestCount - view.Traffic.UniqueVisitors += latestTraffic.UniqueVisitorCount - view.Traffic.ErrorCount += latestTraffic.ErrorCount - if duration := latestTraffic.WindowEndedAt.Sub(latestTraffic.WindowStartedAt).Seconds(); duration > 0 { - view.Traffic.EstimatedQPS += float64(latestTraffic.RequestCount) / duration - } - view.Traffic.ReportedNodes++ - } - - view.Nodes = append(view.Nodes, nodeHealth) - } - - view.Summary.TotalNodes = len(nodes) - - if cpuNodeCount > 0 { - view.Capacity.AverageCPUUsagePercent /= float64(cpuNodeCount) - } - if memoryNodeCount > 0 { - view.Capacity.AverageMemoryUsagePercent /= float64(memoryNodeCount) - } - - sort.Slice(view.Nodes, func(i int, j int) bool { - if view.Nodes[i].ActiveEventCount == view.Nodes[j].ActiveEventCount { - return view.Nodes[i].CPUUsagePercent > view.Nodes[j].CPUUsagePercent - } - return view.Nodes[i].ActiveEventCount > view.Nodes[j].ActiveEventCount - }) - - return view, nil -} - -func percentage(used int64, total int64) float64 { - if used <= 0 || total <= 0 { - return 0 - } - return (float64(used) / float64(total)) * 100 -} - -func latestMetricSnapshotsByNode(snapshots []*model.NodeMetricSnapshot) map[string]*model.NodeMetricSnapshot { - result := make(map[string]*model.NodeMetricSnapshot, len(snapshots)) - for _, snapshot := range snapshots { - if snapshot == nil || snapshot.NodeID == "" { - continue - } - if existing, ok := result[snapshot.NodeID]; ok && !snapshot.CapturedAt.After(existing.CapturedAt) { - continue - } - result[snapshot.NodeID] = snapshot - } - return result -} - -func latestTrafficReportsByNode(reports []*model.NodeRequestReport) map[string]*model.NodeRequestReport { - result := make(map[string]*model.NodeRequestReport, len(reports)) - for _, report := range reports { - if report == nil || report.NodeID == "" { - continue - } - if existing, ok := result[report.NodeID]; ok && !report.WindowEndedAt.After(existing.WindowEndedAt) { - continue - } - result[report.NodeID] = report - } - return result -} - -func activeHealthEventsByNode(events []*model.NodeHealthEvent) map[string][]*model.NodeHealthEvent { - result := make(map[string][]*model.NodeHealthEvent) - for _, event := range events { - if event == nil || event.NodeID == "" { - continue - } - result[event.NodeID] = append(result[event.NodeID], event) - } - return result -} diff --git a/openflare-server/internal/service/database_maintenance.go b/openflare-server/internal/service/database_maintenance.go deleted file mode 100644 index 4ef79616..00000000 --- a/openflare-server/internal/service/database_maintenance.go +++ /dev/null @@ -1,183 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -const ( - DatabaseCleanupTargetAccessLogs = "node_access_logs" - DatabaseCleanupTargetMetricSnapshots = "node_metric_snapshots" - DatabaseCleanupTargetRequestReports = "node_request_reports" -) - -var databaseCleanupTargets = map[string]string{ - DatabaseCleanupTargetAccessLogs: "访问日志", - DatabaseCleanupTargetMetricSnapshots: "性能快照", - DatabaseCleanupTargetRequestReports: "请求聚合", -} - -type DatabaseCleanupInput struct { - Target string `json:"target"` - RetentionDays *int `json:"retention_days"` -} - -type DatabaseCleanupResult struct { - Target string `json:"target"` - TargetLabel string `json:"target_label"` - DeletedCount int64 `json:"deleted_count"` - DeleteAll bool `json:"delete_all"` - RetentionDays *int `json:"retention_days,omitempty"` - Cutoff *time.Time `json:"cutoff,omitempty"` -} - -type DatabaseAutoCleanupSummary struct { - RetentionDays int `json:"retention_days"` - ExecutedAt time.Time `json:"executed_at"` - Results []DatabaseCleanupResult `json:"results"` -} - -func CleanupDatabaseObservability(input DatabaseCleanupInput) (*DatabaseCleanupResult, error) { - target := strings.TrimSpace(input.Target) - targetLabel, ok := databaseCleanupTargets[target] - if !ok { - return nil, errors.New("unsupported cleanup target") - } - if input.RetentionDays != nil && *input.RetentionDays <= 0 { - return nil, errors.New("retention_days 必须为大于 0 的整数") - } - - result := &DatabaseCleanupResult{ - Target: target, - TargetLabel: targetLabel, - DeleteAll: input.RetentionDays == nil, - } - - if input.RetentionDays == nil { - deleted, err := deleteAllObservabilityRows(target) - if err != nil { - return nil, err - } - result.DeletedCount = deleted - return result, nil - } - - retentionDays := *input.RetentionDays - cutoff := time.Now().UTC().Add(-time.Duration(retentionDays) * 24 * time.Hour) - deleted, err := deleteObservabilityRowsBefore(target, cutoff) - if err != nil { - return nil, err - } - result.DeletedCount = deleted - result.RetentionDays = &retentionDays - result.Cutoff = &cutoff - return result, nil -} - -func RunDatabaseAutoCleanupOnce(now time.Time) (*DatabaseAutoCleanupSummary, error) { - if !common.DatabaseAutoCleanupEnabled { - return nil, nil - } - if common.DatabaseAutoCleanupRetentionDays < 1 { - return nil, fmt.Errorf("database auto cleanup retention_days must be at least 1") - } - - retentionDays := common.DatabaseAutoCleanupRetentionDays - results := make([]DatabaseCleanupResult, 0, len(databaseCleanupTargets)) - for _, target := range []string{ - DatabaseCleanupTargetAccessLogs, - DatabaseCleanupTargetMetricSnapshots, - DatabaseCleanupTargetRequestReports, - } { - result, err := CleanupDatabaseObservability(DatabaseCleanupInput{ - Target: target, - RetentionDays: &retentionDays, - }) - if err != nil { - return nil, err - } - results = append(results, *result) - } - - return &DatabaseAutoCleanupSummary{ - RetentionDays: retentionDays, - ExecutedAt: now.UTC(), - Results: results, - }, nil -} - -func StartDatabaseAutoCleanupScheduler(ctx context.Context) { - go func() { - for { - wait := time.Until(nextDatabaseAutoCleanupTime(time.Now())) - timer := time.NewTimer(wait) - select { - case <-ctx.Done(): - timer.Stop() - return - case <-timer.C: - } - - summary, err := RunDatabaseAutoCleanupOnce(time.Now()) - if err != nil { - slog.Error("database auto cleanup failed", "error", err) - continue - } - if summary == nil { - continue - } - totalDeleted := int64(0) - for _, item := range summary.Results { - totalDeleted += item.DeletedCount - } - slog.Info( - "database auto cleanup completed", - "retention_days", - summary.RetentionDays, - "deleted_count", - totalDeleted, - ) - } - }() -} - -func nextDatabaseAutoCleanupTime(now time.Time) time.Time { - next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location()) - if !next.After(now) { - next = next.Add(24 * time.Hour) - } - return next -} - -func deleteAllObservabilityRows(target string) (int64, error) { - switch target { - case DatabaseCleanupTargetAccessLogs: - return model.DeleteAllNodeAccessLogs(nil) - case DatabaseCleanupTargetMetricSnapshots: - return model.DeleteAllNodeMetricSnapshots(nil) - case DatabaseCleanupTargetRequestReports: - return model.DeleteAllNodeRequestReports(nil) - default: - return 0, errors.New("unsupported cleanup target") - } -} - -func deleteObservabilityRowsBefore(target string, cutoff time.Time) (int64, error) { - switch target { - case DatabaseCleanupTargetAccessLogs: - return model.DeleteNodeAccessLogsBefore(cutoff) - case DatabaseCleanupTargetMetricSnapshots: - return model.DeleteNodeMetricSnapshotsBefore(nil, cutoff) - case DatabaseCleanupTargetRequestReports: - return model.DeleteNodeRequestReportsBefore(nil, cutoff) - default: - return 0, errors.New("unsupported cleanup target") - } -} diff --git a/openflare-server/internal/service/database_maintenance_test.go b/openflare-server/internal/service/database_maintenance_test.go deleted file mode 100644 index ed217e89..00000000 --- a/openflare-server/internal/service/database_maintenance_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package service - -import ( - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestCleanupDatabaseObservabilityDeletesTargetedRows(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - if err := model.DB.Create(&model.NodeMetricSnapshot{ - NodeID: "node-a", - CapturedAt: now.Add(-10 * 24 * time.Hour), - CPUUsagePercent: 10, - }).Error; err != nil { - t.Fatalf("seed old metric snapshot: %v", err) - } - if err := model.DB.Create(&model.NodeMetricSnapshot{ - NodeID: "node-a", - CapturedAt: now.Add(-12 * time.Hour), - CPUUsagePercent: 20, - }).Error; err != nil { - t.Fatalf("seed recent metric snapshot: %v", err) - } - - retentionDays := 7 - result, err := CleanupDatabaseObservability(DatabaseCleanupInput{ - Target: DatabaseCleanupTargetMetricSnapshots, - RetentionDays: &retentionDays, - }) - if err != nil { - t.Fatalf("CleanupDatabaseObservability failed: %v", err) - } - if result.DeleteAll { - t.Fatal("expected retention cleanup instead of delete_all") - } - if result.DeletedCount != 1 { - t.Fatalf("expected 1 deleted row, got %+v", result) - } - - rows, err := model.ListMetricSnapshotsSince(time.Time{}) - if err != nil { - t.Fatalf("ListMetricSnapshotsSince failed: %v", err) - } - if len(rows) != 1 || rows[0].CPUUsagePercent != 20 { - t.Fatalf("unexpected remaining metric snapshots: %+v", rows) - } -} - -func TestCleanupDatabaseObservabilityDeletesAllRowsWhenRetentionMissing(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - if err := model.DB.Create(&model.NodeAccessLog{ - NodeID: "node-a", - LoggedAt: now.Add(-3 * time.Hour), - RemoteAddr: "203.0.113.1", - Host: "example.com", - Path: "/one", - StatusCode: 200, - }).Error; err != nil { - t.Fatalf("seed first access log: %v", err) - } - if err := model.DB.Create(&model.NodeAccessLog{ - NodeID: "node-a", - LoggedAt: now.Add(-2 * time.Hour), - RemoteAddr: "203.0.113.2", - Host: "example.com", - Path: "/two", - StatusCode: 502, - }).Error; err != nil { - t.Fatalf("seed second access log: %v", err) - } - - result, err := CleanupDatabaseObservability(DatabaseCleanupInput{ - Target: DatabaseCleanupTargetAccessLogs, - }) - if err != nil { - t.Fatalf("CleanupDatabaseObservability failed: %v", err) - } - if !result.DeleteAll || result.DeletedCount != 2 { - t.Fatalf("unexpected delete-all result: %+v", result) - } - - rows, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10}) - if err != nil { - t.Fatalf("ListNodeAccessLogs failed: %v", err) - } - if len(rows) != 0 { - t.Fatalf("expected all access logs deleted, got %+v", rows) - } -} - -func TestRunDatabaseAutoCleanupOnceDeletesAllObservabilityTargets(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - if err := model.DB.Create(&model.NodeAccessLog{ - NodeID: "node-a", - LoggedAt: now.Add(-48 * time.Hour), - RemoteAddr: "203.0.113.10", - Host: "example.com", - Path: "/access", - StatusCode: 200, - }).Error; err != nil { - t.Fatalf("seed access log: %v", err) - } - if err := model.DB.Create(&model.NodeMetricSnapshot{ - NodeID: "node-a", - CapturedAt: now.Add(-48 * time.Hour), - CPUUsagePercent: 10, - }).Error; err != nil { - t.Fatalf("seed metric snapshot: %v", err) - } - if err := model.DB.Create(&model.NodeRequestReport{ - NodeID: "node-a", - WindowStartedAt: now.Add(-49 * time.Hour), - WindowEndedAt: now.Add(-48 * time.Hour), - RequestCount: 15, - }).Error; err != nil { - t.Fatalf("seed request report: %v", err) - } - - previousEnabled := common.DatabaseAutoCleanupEnabled - previousRetentionDays := common.DatabaseAutoCleanupRetentionDays - common.DatabaseAutoCleanupEnabled = true - common.DatabaseAutoCleanupRetentionDays = 1 - t.Cleanup(func() { - common.DatabaseAutoCleanupEnabled = previousEnabled - common.DatabaseAutoCleanupRetentionDays = previousRetentionDays - }) - - summary, err := RunDatabaseAutoCleanupOnce(now) - if err != nil { - t.Fatalf("RunDatabaseAutoCleanupOnce failed: %v", err) - } - if summary == nil || len(summary.Results) != 3 { - t.Fatalf("unexpected auto cleanup summary: %+v", summary) - } - - accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{Page: 0, PageSize: 10}) - if err != nil { - t.Fatalf("ListNodeAccessLogs failed: %v", err) - } - if len(accessLogs) != 0 { - t.Fatalf("expected auto cleanup to delete access logs, got %+v", accessLogs) - } - metricSnapshots, err := model.ListMetricSnapshotsSince(time.Time{}) - if err != nil { - t.Fatalf("ListMetricSnapshotsSince failed: %v", err) - } - if len(metricSnapshots) != 0 { - t.Fatalf("expected auto cleanup to delete metric snapshots, got %+v", metricSnapshots) - } - requestReports, err := model.ListRequestReportsSince(time.Time{}) - if err != nil { - t.Fatalf("ListRequestReportsSince failed: %v", err) - } - if len(requestReports) != 0 { - t.Fatalf("expected auto cleanup to delete request reports, got %+v", requestReports) - } -} diff --git a/openflare-server/internal/service/flared_ws.go b/openflare-server/internal/service/flared_ws.go deleted file mode 100644 index 3a8aef8f..00000000 --- a/openflare-server/internal/service/flared_ws.go +++ /dev/null @@ -1,54 +0,0 @@ -package service - -const ( - FlaredWSConnectedLastSeenValue = "__OPENFLARE_FLARED_WS_CONNECTED__" - - FlaredWSMessageTypeActiveConfig = "active_config" - FlaredWSMessageTypeForceSync = "force_sync" - FlaredWSMessageTypePong = "pong" -) - -var DefaultFlaredWSHub = NewWSHub("flared") - -func RegisterFlaredWSClient(nodeID string) *WSClient { - return DefaultFlaredWSHub.Register(nodeID) -} - -func UnregisterFlaredWSClient(client *WSClient) { - DefaultFlaredWSHub.Unregister(client) -} - -func DisconnectFlaredWSClient(nodeID string) { - DefaultFlaredWSHub.Disconnect(nodeID) -} - -func IsFlaredWSConnected(nodeID string) bool { - return DefaultFlaredWSHub.IsConnected(nodeID) -} - -func SendFlaredWSPong(nodeID string) bool { - return DefaultFlaredWSHub.SendMessage(nodeID, WSMessage{ - Type: FlaredWSMessageTypePong, - }) -} - -func SendFlaredWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool { - if activeConfig == nil { - return false - } - return DefaultFlaredWSHub.SendMessage(nodeID, WSMessage{ - Type: FlaredWSMessageTypeActiveConfig, - Payload: activeConfig, - }) -} - -func BroadcastFlaredWSActiveConfig(activeConfig *ActiveConfigMeta) WSBroadcastResult { - if activeConfig == nil { - return WSBroadcastResult{} - } - result := DefaultFlaredWSHub.Broadcast(WSMessage{ - Type: FlaredWSMessageTypeActiveConfig, - Payload: activeConfig, - }) - return result -} diff --git a/openflare-server/internal/service/geoip_lookup.go b/openflare-server/internal/service/geoip_lookup.go deleted file mode 100644 index 9a7c941e..00000000 --- a/openflare-server/internal/service/geoip_lookup.go +++ /dev/null @@ -1,51 +0,0 @@ -package service - -import ( - "errors" - "net" - "strings" - - "github.com/rain-kl/openflare/pkg/geoip" -) - -type GeoIPLookupView struct { - Provider string `json:"provider"` - IP string `json:"ip"` - ISOCode string `json:"iso_code"` - Name string `json:"name"` - Latitude *float64 `json:"latitude,omitempty"` - Longitude *float64 `json:"longitude,omitempty"` -} - -func LookupGeoIP(provider string, rawIP string) (*GeoIPLookupView, error) { - trimmedProvider := strings.TrimSpace(provider) - if !geoip.IsValidProvider(trimmedProvider) { - return nil, errors.New("归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo") - } - - trimmedIP := strings.TrimSpace(rawIP) - if trimmedIP == "" { - return nil, errors.New("IP 不能为空") - } - parsedIP := net.ParseIP(trimmedIP) - if parsedIP == nil { - return nil, errors.New("IP 格式无效") - } - - info, err := geoip.LookupGeoInfoWithProvider(trimmedProvider, parsedIP) - if err != nil { - return nil, err - } - if info == nil { - return nil, errors.New("未获取到 IP 归属结果") - } - - return &GeoIPLookupView{ - Provider: trimmedProvider, - IP: parsedIP.String(), - ISOCode: info.ISOCode, - Name: info.Name, - Latitude: info.Latitude, - Longitude: info.Longitude, - }, nil -} diff --git a/openflare-server/internal/service/geoip_lookup_test.go b/openflare-server/internal/service/geoip_lookup_test.go deleted file mode 100644 index 7b5e9336..00000000 --- a/openflare-server/internal/service/geoip_lookup_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package service - -import ( - "net" - "testing" - - "github.com/rain-kl/openflare/pkg/geoip" -) - -type fakeLookupProvider struct{} - -func (f *fakeLookupProvider) Name() string { - return "fake-lookup" -} - -func (f *fakeLookupProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) { - return &geoip.GeoInfo{ - ISOCode: "US", - Name: "United States", - Latitude: geoipFloat(37.7749), - Longitude: geoipFloat(-122.4194), - }, nil -} - -func (f *fakeLookupProvider) UpdateDatabase() error { - return nil -} - -func (f *fakeLookupProvider) Close() error { - return nil -} - -func TestLookupGeoIP(t *testing.T) { - previousFactory := geoip.ProviderFactoryForTest() - geoip.SetProviderFactoryForTest(func(provider string) (geoip.GeoIPService, error) { - return &fakeLookupProvider{}, nil - }) - defer geoip.SetProviderFactoryForTest(previousFactory) - - view, err := LookupGeoIP("ipinfo", "8.8.8.8") - if err != nil { - t.Fatalf("LookupGeoIP failed: %v", err) - } - if view.Provider != "ipinfo" { - t.Fatalf("expected provider ipinfo, got %s", view.Provider) - } - if view.IP != "8.8.8.8" { - t.Fatalf("expected IP 8.8.8.8, got %s", view.IP) - } - if view.ISOCode != "US" || view.Name != "United States" { - t.Fatalf("unexpected lookup view: %+v", view) - } - if view.Latitude == nil || view.Longitude == nil { - t.Fatalf("expected coordinates, got %+v", view) - } -} - -func TestLookupGeoIPRejectsInvalidInput(t *testing.T) { - if _, err := LookupGeoIP("invalid", "8.8.8.8"); err == nil { - t.Fatal("expected invalid provider to fail") - } - if _, err := LookupGeoIP("ipinfo", "not-an-ip"); err == nil { - t.Fatal("expected invalid IP to fail") - } -} diff --git a/openflare-server/internal/service/https_phase1_test.go b/openflare-server/internal/service/https_phase1_test.go deleted file mode 100644 index 16339f19..00000000 --- a/openflare-server/internal/service/https_phase1_test.go +++ /dev/null @@ -1,1472 +0,0 @@ -package service - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" - "math/big" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) { - setupServiceTestDB(t) - - certPEM, keyPEM := generateCertificatePair(t, []string{"app.example.com"}) - certificate, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "app-example", - CertPEM: certPEM, - KeyPEM: keyPEM, - Remark: "test cert", - }) - if err != nil { - t.Fatalf("CreateTLSCertificate failed: %v", err) - } - if certificate.NotAfter.Before(certificate.NotBefore) { - t.Fatal("expected certificate validity period to be parsed") - } - - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "app.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - EnableHTTPS: true, - CertID: &certificate.ID, - RedirectHTTP: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if !route.EnableHTTPS || route.CertID == nil { - t.Fatal("expected https fields to be persisted") - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") { - t.Fatal("expected main config to include managed route config placeholder") - } - if !strings.Contains(result.Version.MainConfig, "access_log __OPENFLARE_ACCESS_LOG__ openflare_json;") { - t.Fatal("expected main config to include managed access log placeholder") - } - if !strings.Contains(result.Version.MainConfig, "error_log __OPENFLARE_ERROR_LOG__ warn;") { - t.Fatal("expected main config to include managed error log placeholder") - } - if !strings.Contains(result.Version.MainConfig, "log_by_lua_file __OPENFLARE_LUA_DIR__/observability/log.lua;") { - t.Fatal("expected main config to include managed openresty lua log hook") - } - if !strings.Contains(result.Version.MainConfig, "listen __OPENFLARE_OBSERVABILITY_LISTEN__;") { - t.Fatal("expected main config to include managed openresty observability listen placeholder") - } - if strings.Contains(result.Version.MainConfig, "resolver ") { - t.Fatal("expected main config to omit resolver directive when no resolvers are configured") - } - if !strings.Contains(result.Version.MainConfig, "use epoll;") { - t.Fatal("expected main config to default to epoll event model") - } - if !strings.Contains(result.Version.MainConfig, "multi_accept on;") { - t.Fatal("expected main config to default multi_accept to on") - } - if !strings.Contains(result.Version.MainConfig, "keepalive_timeout 20;") { - t.Fatal("expected main config to default keepalive_timeout to 20") - } - if !strings.Contains(result.Version.MainConfig, "proxy_connect_timeout 3;") { - t.Fatal("expected main config to default proxy_connect_timeout to 3") - } - if strings.Contains(result.Version.MainConfig, "allow 127.0.0.1;") { - t.Fatal("expected main config to avoid hard-coded allow rules on observability server") - } - if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;") { - t.Fatal("expected rendered config to include https ssl listener") - } - if !strings.Contains(result.Version.RenderedConfig, "http2 on;") { - t.Fatal("expected rendered config to enable http2 with dedicated directive") - } - if strings.Contains(result.Version.RenderedConfig, `if ($host != "app.example.com") {`) { - t.Fatal("expected rendered config to avoid per-route host guard") - } - if !strings.Contains(result.Version.RenderedConfig, "return 301 https://$host$request_uri;") { - t.Fatal("expected rendered config to include http redirect") - } - if !strings.Contains(result.Version.RenderedConfig, "__OPENFLARE_CERT_DIR__/") { - t.Fatal("expected rendered config to keep cert dir placeholder for certificates") - } - if !strings.Contains(result.Version.SupportFilesJSON, ".crt") || !strings.Contains(result.Version.SupportFilesJSON, ".key") { - t.Fatal("expected support files to contain certificate and key") - } -} - -func TestNextVersionNumberUsesMaxDailySequence(t *testing.T) { - setupServiceTestDB(t) - - seed := []model.ConfigVersion{ - { - Version: "20260526-001", - SnapshotJSON: "{}", - RenderedConfig: "server {}", - SupportFilesJSON: "[]", - Checksum: "checksum-1", - CreatedBy: "root", - }, - { - Version: "20260526-003", - SnapshotJSON: "{}", - RenderedConfig: "server {}", - SupportFilesJSON: "[]", - Checksum: "checksum-3", - CreatedBy: "root", - }, - } - for _, version := range seed { - if err := model.DB.Create(&version).Error; err != nil { - t.Fatalf("failed to seed config version %s: %v", version.Version, err) - } - } - - next, err := nextVersionNumber(time.Date(2026, 5, 26, 12, 0, 0, 0, time.Local)) - if err != nil { - t.Fatalf("nextVersionNumber failed: %v", err) - } - if next != "20260526-004" { - t.Fatalf("expected next version to follow max suffix, got %s", next) - } -} - -func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "secure.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - EnableHTTPS: true, - }) - 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 { - t.Fatalf("UpdateOption OpenRestyWebsocketEnabled failed: %v", err) - } - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "custom.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - CustomHeaders: []ProxyRouteCustomHeaderInput{ - {Key: "X-Trace-Id", Value: "$request_id"}, - {Key: "X-Env", Value: "staging edge"}, - }, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, `proxy_set_header X-Trace-Id "$request_id";`) { - t.Fatal("expected rendered config to include custom header") - } - if !strings.Contains(result.Version.RenderedConfig, `proxy_set_header X-Env "staging edge";`) { - t.Fatal("expected rendered config to include quoted custom header value") - } - if !strings.Contains(result.Version.SnapshotJSON, "custom_headers") { - t.Fatal("expected snapshot to include custom headers") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_http_version 1.1;") { - t.Fatal("expected rendered config to enable HTTP/1.1 proxying for websocket upgrades") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Upgrade $http_upgrade;") { - t.Fatal("expected rendered config to forward websocket upgrade header") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_set_header Connection $connection_upgrade;") { - t.Fatal("expected rendered config to use normalized websocket connection header") - } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_custom_example_com_1 {") { - t.Fatal("expected hostname origin to render named upstream") - } - if !strings.Contains(result.Version.RenderedConfig, "server origin.internal max_fails=3 fail_timeout=10s;") { - t.Fatal("expected hostname origin to render upstream server entry") - } - if !strings.Contains(result.Version.RenderedConfig, "keepalive 128;") { - t.Fatal("expected named upstream to enable keepalive") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_custom_example_com_1;") { - t.Fatal("expected hostname origin to proxy through named upstream") - } -} - -func TestCreateProxyRouteRejectsCachePolicyWithoutRules(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "cache.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - CacheEnabled: true, - CachePolicy: proxyRouteCachePolicySuffix, - }) - if err == nil || !strings.Contains(err.Error(), "at least one suffix") { - t.Fatalf("expected cache rule validation error, got %v", err) - } -} - -func TestPublishConfigVersionRendersRouteLevelCachePolicy(t *testing.T) { - setupServiceTestDB(t) - if err := model.UpdateOption("OpenRestyCacheEnabled", "true"); err != nil { - t.Fatalf("UpdateOption OpenRestyCacheEnabled failed: %v", err) - } - if err := model.UpdateOption("OpenRestyCachePath", "/var/cache/openresty/openflare"); err != nil { - t.Fatalf("UpdateOption OpenRestyCachePath failed: %v", err) - } - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "static.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - CacheEnabled: true, - CachePolicy: proxyRouteCachePolicySuffix, - CacheRules: []string{"jpg", ".css", "js"}, - }) - if err != nil { - t.Fatalf("CreateProxyRoute cached failed: %v", err) - } - _, err = CreateProxyRoute(ProxyRouteInput{ - Domain: "nocache.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute uncached failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.MainConfig, "proxy_cache_path /var/cache/openresty/openflare") { - t.Fatal("expected main config to include cache zone when cache infra is enabled") - } - if !strings.Contains(result.Version.MainConfig, `proxy_cache_key "$scheme$host$request_uri";`) { - t.Fatal("expected main config to default cache key to host dimension") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_methods GET;") { - t.Fatal("expected rendered config to only cache GET requests") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_cache_bypass $openflare_skip_cache;") { - t.Fatal("expected rendered config to bypass cache when request is unsafe") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_no_cache $openflare_skip_cache;") { - t.Fatal("expected rendered config to avoid storing unsafe requests in cache") - } - if !strings.Contains(result.Version.RenderedConfig, "if ($http_authorization != \"\")") { - t.Fatal("expected rendered config to bypass authenticated requests") - } - if !strings.Contains(result.Version.RenderedConfig, "if ($request_method != GET)") { - t.Fatal("expected rendered config to bypass non-GET requests") - } - if !strings.Contains(result.Version.RenderedConfig, "if ($uri !~* \"\\\\.(?:jpg|css|js)$\")") { - t.Fatal("expected rendered config to render suffix cache matching rule") - } - if strings.Count(result.Version.RenderedConfig, "proxy_cache openflare_cache;") != 1 { - t.Fatal("expected only cache-enabled route to include proxy_cache directive") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_static_example_com_1;") { - t.Fatal("expected cache-enabled hostname route to proxy through named upstream") - } - if !strings.Contains(result.Version.SnapshotJSON, `"cache_enabled":true`) { - t.Fatal("expected snapshot to include route cache toggle") - } - if !strings.Contains(result.Version.SnapshotJSON, `"cache_policy":"suffix"`) { - t.Fatal("expected snapshot to include route cache policy") - } -} - -func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) { - setupServiceTestDB(t) - - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "lb.example.com", - OriginURL: "http://10.0.0.11:39010", - Upstreams: []string{"http://10.0.0.12:39010", "http://10.0.0.13:39010"}, - Enabled: true, - OriginHost: "lb.example.com", - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if !strings.Contains(route.Upstreams, "10.0.0.12:39010") { - t.Fatalf("expected route upstreams to persist, got %s", route.Upstreams) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_lb_example_com_1 {") { - t.Fatal("expected rendered config to define upstream block for load balancing route") - } - if strings.Count(result.Version.RenderedConfig, "max_fails=3 fail_timeout=10s;") < 3 { - t.Fatal("expected rendered config to include every upstream server") - } - if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.11:39010 max_fails=3 fail_timeout=10s;") { - t.Fatal("expected rendered config to include primary upstream server") - } - if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.12:39010 max_fails=3 fail_timeout=10s;") { - t.Fatal("expected rendered config to include secondary upstream server") - } - if !strings.Contains(result.Version.RenderedConfig, "server 10.0.0.13:39010 max_fails=3 fail_timeout=10s;") { - t.Fatal("expected rendered config to include tertiary upstream server") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_lb_example_com_1;") { - t.Fatal("expected rendered config to proxy through load balancing upstream") - } - if !strings.Contains(result.Version.SnapshotJSON, `"upstreams":["http://10.0.0.11:39010","http://10.0.0.12:39010","http://10.0.0.13:39010"]`) { - t.Fatal("expected snapshot to include upstream list") - } -} - -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", false) - 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", false) - 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) - if err := model.UpdateOption("OpenRestyHTTP3Enabled", "false"); err != nil { - t.Fatalf("UpdateOption failed: %v", err) - } - - 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", false) - 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", false); 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", false) - 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) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "hostname-lb.example.com", - OriginURL: "http://c1:39010", - Upstreams: []string{"http://c2:39010"}, - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_hostname_lb_example_com_1 {") { - t.Fatal("expected hostname load balancing route to define named upstream") - } - if !strings.Contains(result.Version.RenderedConfig, "server c1:39010 max_fails=3 fail_timeout=10s;") { - t.Fatal("expected rendered config to include primary hostname upstream") - } - if !strings.Contains(result.Version.RenderedConfig, "server c2:39010 max_fails=3 fail_timeout=10s;") { - t.Fatal("expected rendered config to include secondary hostname upstream") - } - if strings.Contains(result.Version.RenderedConfig, " resolve ") { - t.Fatal("expected hostname upstreams to avoid resolver-based server parameters") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_hostname_lb_example_com_1;") { - t.Fatal("expected hostname load balancing route to proxy through named upstream") - } -} - -func TestPublishConfigVersionOverridesOriginHostHeader(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "git.arctel.de", - OriginURL: "https://git.arctel.net", - OriginHost: "git.arctel.net", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, `proxy_set_header Host "git.arctel.net";`) { - t.Fatal("expected rendered config to override host header for origin routing") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_ssl_server_name on;") { - t.Fatal("expected rendered config to enable proxy ssl server name for https origin") - } - if !strings.Contains(result.Version.RenderedConfig, `proxy_ssl_name "git.arctel.net";`) { - t.Fatal("expected rendered config to set proxy ssl name from origin host override") - } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_git_arctel_de_1 {") { - t.Fatal("expected hostname origin to render named upstream") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_git_arctel_de_1;") { - t.Fatal("expected rendered config to proxy through named upstream for hostname origin") - } - if !strings.Contains(result.Version.SnapshotJSON, `"origin_host":"git.arctel.net"`) { - t.Fatal("expected snapshot to include origin_host override") - } -} - -func TestPublishConfigVersionUsesNamedUpstreamForOriginBasePath(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "resolver.example.com", - OriginURL: "https://origin.internal/api/", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_resolver_example_com_1 {") { - t.Fatal("expected hostname origin with base path to still render named upstream") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_resolver_example_com_1/api/;") { - t.Fatal("expected rendered config to preserve base path while proxying through named upstream") - } -} - -func TestPublishConfigVersionUsesNamedUpstreamForHostnameOrigins(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "resolver-upstream.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_resolver_upstream_example_com_1 {") { - t.Fatal("expected rendered config to define named upstream for hostname origin") - } - if !strings.Contains(result.Version.RenderedConfig, "server origin.internal max_fails=3 fail_timeout=10s;") { - t.Fatal("expected rendered config to include hostname upstream server entry") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass https://backend_resolver_upstream_example_com_1;") { - t.Fatal("expected rendered config to proxy through named upstream for hostname origin") - } -} - -func TestPublishConfigVersionUsesNamedUpstreamForIPOrigins(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "ip-origin.example.com", - OriginURL: "http://10.0.0.8:8080", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, "upstream backend_ip_origin_example_com_1 {") { - t.Fatal("expected rendered config to define named upstream for static IP origins") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_ip_origin_example_com_1;") { - t.Fatal("expected rendered config to proxy through named upstream for IP origin") - } - if strings.Contains(result.Version.RenderedConfig, `set $openflare_upstream "http://10.0.0.8:8080"`) { - t.Fatal("expected rendered config to avoid runtime resolver variables for IP origin") - } -} - -func TestPreviewConfigVersionCanDisableWebsocketHeaders(t *testing.T) { - setupServiceTestDB(t) - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "ws-off.example.com", - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if err := model.UpdateOption("OpenRestyWebsocketEnabled", "false"); err != nil { - t.Fatalf("UpdateOption OpenRestyWebsocketEnabled failed: %v", err) - } - - preview, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - if !strings.Contains(preview.RenderedConfig, "proxy_http_version 1.1;") { - t.Fatal("expected preview config to keep HTTP/1.1 proxying for named upstream keepalive") - } - if !strings.Contains(preview.RenderedConfig, `proxy_set_header Connection "";`) { - t.Fatal("expected preview config to clear connection header when websocket upgrades are disabled") - } - if strings.Contains(preview.RenderedConfig, "proxy_set_header Upgrade $http_upgrade;") { - t.Fatal("expected preview config to omit websocket upgrade header when disabled") - } -} - -func TestPreviewAndDiffConfigVersion(t *testing.T) { - setupServiceTestDB(t) - if err := model.UpdateOption("OpenRestyWebsocketEnabled", "true"); err != nil { - t.Fatalf("UpdateOption OpenRestyWebsocketEnabled failed: %v", err) - } - - stableRoute, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "stable.example.com", - OriginURL: "https://origin-a.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute stable failed: %v", err) - } - modifiedRoute, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "api.example.com", - OriginURL: "https://origin-api-a.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute modified failed: %v", err) - } - removedRoute, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "old.example.com", - OriginURL: "https://origin-old.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute removed failed: %v", err) - } - if _, err = PublishConfigVersion("root", false); err != nil { - t.Fatalf("initial PublishConfigVersion failed: %v", err) - } - - if _, err = UpdateProxyRoute(modifiedRoute.ID, ProxyRouteInput{ - Domain: "api.example.com", - OriginURL: "https://origin-api-b.internal", - Enabled: true, - CustomHeaders: []ProxyRouteCustomHeaderInput{ - {Key: "X-Release", Value: "candidate"}, - }, - }); err != nil { - t.Fatalf("UpdateProxyRoute failed: %v", err) - } - if _, err = UpdateProxyRoute(removedRoute.ID, ProxyRouteInput{ - Domain: "old.example.com", - OriginURL: "https://origin-old.internal", - Enabled: false, - }); err != nil { - t.Fatalf("disable removed route failed: %v", err) - } - if _, err = CreateProxyRoute(ProxyRouteInput{ - Domain: "new.example.com", - OriginURL: "https://origin-new.internal", - Enabled: true, - }); err != nil { - t.Fatalf("CreateProxyRoute new failed: %v", err) - } - if _, err = UpdateProxyRoute(stableRoute.ID, ProxyRouteInput{ - Domain: stableRoute.Domain, - OriginURL: stableRoute.OriginURL, - Enabled: true, - Remark: "remark only change", - }); err != nil { - t.Fatalf("UpdateProxyRoute stable failed: %v", err) - } - - preview, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - if !strings.Contains(preview.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") { - t.Fatal("expected preview main config to include managed route config placeholder") - } - if !strings.Contains(preview.MainConfig, "log_by_lua_file __OPENFLARE_LUA_DIR__/observability/log.lua;") { - t.Fatal("expected preview main config to include managed openresty lua log hook") - } - if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) { - t.Fatal("expected preview config to include modified custom header") - } - if preview.RouteCount != 3 { - t.Fatalf("expected 3 enabled routes in preview, got %d", preview.RouteCount) - } - - diff, err := DiffConfigVersion() - if err != nil { - t.Fatalf("DiffConfigVersion failed: %v", err) - } - if len(diff.AddedDomains) != 1 || diff.AddedDomains[0] != "new.example.com" { - t.Fatalf("unexpected added domains: %#v", diff.AddedDomains) - } - if len(diff.RemovedDomains) != 1 || diff.RemovedDomains[0] != "old.example.com" { - t.Fatalf("unexpected removed domains: %#v", diff.RemovedDomains) - } - if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "api.example.com" { - t.Fatalf("unexpected modified domains: %#v", diff.ModifiedDomains) - } - if diff.MainConfigChanged { - t.Fatal("expected main config to remain unchanged when only routes change") - } - - if err = model.UpdateOption("OpenRestyProxyReadTimeout", "120"); err != nil { - t.Fatalf("UpdateOption failed: %v", err) - } - if err = model.UpdateOption("OpenRestyWebsocketEnabled", "false"); err != nil { - t.Fatalf("UpdateOption OpenRestyWebsocketEnabled failed: %v", err) - } - diff, err = DiffConfigVersion() - if err != nil { - t.Fatalf("DiffConfigVersion after option change failed: %v", err) - } - if !diff.MainConfigChanged { - t.Fatal("expected main config change after OpenResty option update") - } - if len(diff.ChangedOptionKeys) == 0 || diff.ChangedOptionKeys[0] == "" { - t.Fatal("expected changed OpenResty option keys to be reported") - } - if len(diff.ChangedOptionDetails) == 0 { - t.Fatal("expected changed OpenResty option details to be reported") - } - found := false - foundWebsocket := false - for _, item := range diff.ChangedOptionDetails { - if item.Key == "OpenRestyProxyReadTimeout" { - found = true - if item.PreviousValue != "60" || item.CurrentValue != "120" { - t.Fatalf("unexpected option diff values: %+v", item) - } - } - if item.Key == "OpenRestyWebsocketEnabled" { - foundWebsocket = true - if item.PreviousValue != "true" || item.CurrentValue != "false" { - t.Fatalf("unexpected websocket option diff values: %+v", item) - } - } - } - if !found { - t.Fatal("expected OpenRestyProxyReadTimeout diff detail") - } - if !foundWebsocket { - t.Fatal("expected OpenRestyWebsocketEnabled diff detail") - } -} - -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", false) - if err != nil { - t.Fatalf("initial PublishConfigVersion failed: %v", err) - } - if !strings.Contains(firstRelease.Version.SupportFilesJSON, `"path":"waf_config.json"`) { - t.Fatal("expected publish to include waf_config.json support file") - } - - group, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "pow group", - Enabled: true, - BlockStatusCode: 418, - PoWEnabled: true, - PoWConfig: json.RawMessage(`{"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":[]}}`), - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - - if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{group.ID}); err != nil { - t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err) - } - - diff, err := DiffConfigVersion() - if err != nil { - t.Fatalf("DiffConfigVersion failed: %v", err) - } - if !diff.WAFConfigChanged { - t.Fatal("expected PoW change (via WAF Rule Group) to trigger WAF config change") - } - - secondRelease, err := PublishConfigVersion("root", false) - 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.RenderedConfig, " if not string.find(package.path, \"__OPENFLARE_LUA_DIR__/?.lua\", 1, true) then\n package.path = \"__OPENFLARE_LUA_DIR__/?.lua;__OPENFLARE_LUA_DIR__/?/init.lua;\" .. package.path\n end\n require(\"waf.runtime\").check()\n if ngx.ctx.openflare_waf_blocked then\n return\n end\n require(\"pow.runtime\").check()") { - t.Fatal("expected combined WAF and PoW access handler to short-circuit before PoW") - } - locationStart := strings.Index(secondRelease.Version.RenderedConfig, " location / {\n") - if locationStart < 0 { - t.Fatal("expected rendered config to include root proxy location") - } - locationEnd := strings.Index(secondRelease.Version.RenderedConfig[locationStart:], " }\n") - if locationEnd < 0 { - t.Fatal("expected rendered config to close root proxy location") - } - rootLocationBlock := secondRelease.Version.RenderedConfig[locationStart : locationStart+locationEnd] - if strings.Contains(rootLocationBlock, "access_by_lua") { - t.Fatal("expected root proxy location to avoid mixing access_by_lua with proxy_pass") - } - 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) - } - foundWafSupportFile := false - for _, file := range supportFiles { - if file.Path != "waf_config.json" { - continue - } - foundWafSupportFile = true - if !strings.Contains(file.Content, `"difficulty":5`) { - t.Fatalf("expected waf support file to persist pow config, got %s", file.Content) - } - } - if !foundWafSupportFile { - t.Fatal("expected publish to include waf_config.json support file") - } -} - -func TestPublishConfigVersionRendersBasicAuthWithPoW(t *testing.T) { - setupServiceTestDB(t) - - certPEM, keyPEM := generateCertificatePair(t, []string{"xbot.example.com"}) - certificate, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "xbot-example", - CertPEM: certPEM, - KeyPEM: keyPEM, - }) - if err != nil { - t.Fatalf("CreateTLSCertificate failed: %v", err) - } - - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "xbot.example.com", - OriginURL: "http://c1:36185", - Enabled: true, - EnableHTTPS: true, - CertID: &certificate.ID, - RedirectHTTP: true, - BasicAuthEnabled: true, - BasicAuthUsername: "admin", - BasicAuthPassword: "123", - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - group, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "pow group", - Enabled: true, - BlockStatusCode: 418, - PoWEnabled: true, - PoWConfig: json.RawMessage(`{"difficulty":4,"algorithm":"fast","session_ttl":600,"challenge_ttl":300,"whitelist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`), - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - - if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{group.ID}); err != nil { - t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, `if auth ~= "Basic YWRtaW46MTIz" then`) { - t.Fatal("expected rendered config to include encoded basic auth credentials") - } - if !strings.Contains(result.Version.RenderedConfig, " return ngx.exit(401)\n end\n }\n") { - t.Fatal("expected rendered basic auth Lua block to close the if statement before the nginx block") - } - if !strings.Contains(result.Version.RenderedConfig, `require("pow.runtime").check()`) { - t.Fatal("expected PoW access handler to remain at server scope") - } - if !strings.Contains(result.Version.RenderedConfig, "proxy_pass http://backend_xbot_example_com_1;") { - t.Fatal("expected proxy_pass to stay in the root location after basic auth") - } - if !strings.Contains(result.Version.SnapshotJSON, `"basic_auth_enabled":true`) { - t.Fatal("expected snapshot to include basic auth enabled state") - } - if !strings.Contains(result.Version.SnapshotJSON, `"basic_auth_username":"admin"`) { - t.Fatal("expected snapshot to include basic auth username") - } - if !strings.Contains(result.Version.SnapshotJSON, `"basic_auth_password":"123"`) { - t.Fatal("expected snapshot to include basic auth password") - } -} - -func TestDiffConfigVersionDetectsBasicAuthChanges(t *testing.T) { - setupServiceTestDB(t) - - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "auth.example.com", - OriginURL: "http://c1:8080", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if _, err = PublishConfigVersion("root", false); err != nil { - t.Fatalf("initial PublishConfigVersion failed: %v", err) - } - - _, err = UpdateProxyRoute(route.ID, ProxyRouteInput{ - Domain: route.Domain, - OriginURL: route.OriginURL, - Enabled: true, - BasicAuthEnabled: true, - BasicAuthUsername: "admin", - BasicAuthPassword: "123", - }) - 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] != "auth.example.com" { - t.Fatalf("expected basic auth change to mark domain as modified, got %#v", diff.ModifiedDomains) - } - if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "auth.example.com" { - t.Fatalf("expected basic auth change to mark site as modified, got %#v", diff.ModifiedSites) - } -} - -func TestRenderConfigUsesDefaultServerFallback(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "git.arctel.net", - OriginURL: "http://127.0.0.1:8080", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - preview, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - - if !strings.Contains(preview.RenderedConfig, `server_name git.arctel.net;`) { - t.Fatal("expected rendered config to include exact server_name") - } - if strings.Contains(preview.RenderedConfig, `if ($host != "git.arctel.net") {`) { - t.Fatal("expected rendered config to avoid per-route host guard") - } - 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 421;") { - t.Fatal("expected preview main config to return 421 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) { - setupServiceTestDB(t) - - _, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "broken-cert", - CertPEM: "invalid", - KeyPEM: "invalid", - }) - if err == nil { - t.Fatal("expected invalid pem to fail") - } -} - -func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) { - setupServiceTestDB(t) - - customTemplate := strings.ReplaceAll( - common.OpenRestyMainConfigTemplate, - "pid logs/nginx.pid;", - "pid logs/nginx.pid;\nworker_shutdown_timeout 10s;", - ) - if err := ValidateOpenRestyMainConfigTemplate(customTemplate); err != nil { - t.Fatalf("ValidateOpenRestyMainConfigTemplate failed: %v", err) - } - if err := model.UpdateOption("OpenRestyMainConfigTemplate", customTemplate); err != nil { - t.Fatalf("UpdateOption OpenRestyMainConfigTemplate failed: %v", err) - } - - preview, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - if !strings.Contains(preview.MainConfig, "worker_shutdown_timeout 10s;") { - t.Fatal("expected preview main config to include custom template content") - } - if strings.Contains(preview.MainConfig, "{{OpenRestyWorkerProcesses}}") { - t.Fatal("expected preview main config placeholders to be rendered") - } - if !strings.Contains(preview.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") { - t.Fatal("expected preview main config to preserve managed route include") - } - if !strings.Contains(preview.MainConfig, "access_log __OPENFLARE_ACCESS_LOG__ openflare_json;") { - t.Fatal("expected preview main config to preserve managed access log placeholder") - } - if !strings.Contains(preview.MainConfig, "error_log __OPENFLARE_ERROR_LOG__ warn;") { - t.Fatal("expected preview main config to preserve managed error log placeholder") - } - if !strings.Contains(preview.MainConfig, "map $http_upgrade $connection_upgrade {") { - t.Fatal("expected preview main config to preserve managed websocket upgrade map") - } - 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, - "{{OpenRestyRouteConfigInclude}}", - "", - ) - if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil { - t.Fatal("expected template without managed route placeholder to fail validation") - } - - invalidTemplate = strings.ReplaceAll( - common.OpenRestyMainConfigTemplate, - "{{OpenRestyAccessLogPath}}", - "", - ) - if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil { - t.Fatal("expected template without managed access log placeholder to fail validation") - } - - invalidTemplate = strings.ReplaceAll( - common.OpenRestyMainConfigTemplate, - "{{OpenRestyConnectionUpgradeMap}}", - "", - ) - if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil { - t.Fatal("expected template without managed websocket upgrade map placeholder to fail validation") - } - - invalidTemplate = strings.ReplaceAll( - common.OpenRestyMainConfigTemplate, - "{{OpenRestyErrorLogPath}}", - "", - ) - if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil { - t.Fatal("expected template without managed error log placeholder to fail validation") - } -} - -func TestOpenRestyCommonRequestOptionsRender(t *testing.T) { - setupServiceTestDB(t) - - if err := model.UpdateOption("OpenRestyClientMaxBodySize", "128m"); err != nil { - t.Fatalf("UpdateOption OpenRestyClientMaxBodySize failed: %v", err) - } - if err := model.UpdateOption("OpenRestyLargeClientHeaderBuffers", "8 32k"); err != nil { - t.Fatalf("UpdateOption OpenRestyLargeClientHeaderBuffers failed: %v", err) - } - if err := model.UpdateOption("OpenRestyProxyRequestBufferingEnabled", "false"); err != nil { - t.Fatalf("UpdateOption OpenRestyProxyRequestBufferingEnabled failed: %v", err) - } - - preview, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - if !strings.Contains(preview.MainConfig, "client_max_body_size 128m;") { - t.Fatal("expected preview main config to include client_max_body_size") - } - if !strings.Contains(preview.MainConfig, "large_client_header_buffers 8 32k;") { - t.Fatal("expected preview main config to include large_client_header_buffers") - } - if !strings.Contains(preview.MainConfig, "proxy_request_buffering off;") { - t.Fatal("expected preview main config to include proxy_request_buffering off") - } -} - -func TestOpenRestyProxyRequestBufferingDefaultsToOff(t *testing.T) { - setupServiceTestDB(t) - - preview, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - if !strings.Contains(preview.MainConfig, "proxy_request_buffering off;") { - t.Fatal("expected preview main config to default proxy_request_buffering to off") - } -} - -func TestPreviewConfigVersionSupportsHTTP3(t *testing.T) { - setupServiceTestDB(t) - - appCertPEM, appKeyPEM := generateCertificatePair(t, []string{"h3.example.com"}) - appCertificate, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "h3-cert", - CertPEM: appCertPEM, - KeyPEM: appKeyPEM, - }) - if err != nil { - t.Fatalf("CreateTLSCertificate failed: %v", err) - } - - _, err = CreateProxyRoute(ProxyRouteInput{ - SiteName: "h3-site", - Domains: []string{"h3.example.com"}, - OriginURL: "https://origin.internal", - Enabled: true, - EnableHTTPS: true, - CertID: &appCertificate.ID, - DomainCertIDs: []uint{appCertificate.ID}, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - if err := model.UpdateOption("OpenRestyHTTP3Enabled", "true"); err != nil { - t.Fatalf("UpdateOption OpenRestyHTTP3Enabled failed: %v", err) - } - - preview, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - - if !strings.Contains(preview.MainConfig, "listen 443 ssl default_server;\n listen 443 quic reuseport default_server;") { - t.Fatalf("expected default server block to contain quic reuseport listener, main config: %s", preview.MainConfig) - } - - if !strings.Contains(preview.RenderedConfig, "listen 443 quic;") { - t.Fatalf("expected routing server block to contain listen 443 quic, rendered config: %s", preview.RenderedConfig) - } - if !strings.Contains(preview.RenderedConfig, "add_header Alt-Svc 'h3=\":443\"; ma=86400';") { - t.Fatalf("expected routing server block to contain Alt-Svc header, rendered config: %s", preview.RenderedConfig) - } - - if !strings.Contains(preview.RenderedConfig, "listen 443 ssl;") { - t.Fatal("expected standard listen 443 ssl to be preserved") - } - if !strings.Contains(preview.RenderedConfig, "http2 on;") { - t.Fatal("expected standard http2 on to be preserved") - } - - if err := model.UpdateOption("OpenRestyHTTP3Enabled", "false"); err != nil { - t.Fatalf("UpdateOption OpenRestyHTTP3Enabled failed: %v", err) - } - - previewOff, err := PreviewConfigVersion() - if err != nil { - t.Fatalf("PreviewConfigVersion failed: %v", err) - } - - if strings.Contains(previewOff.MainConfig, "listen 443 quic reuseport default_server;") { - t.Fatal("expected default server block to omit quic listener when disabled") - } - - if strings.Contains(previewOff.RenderedConfig, "listen 443 quic;") { - t.Fatal("expected routing server block to omit quic listener when disabled") - } - if strings.Contains(previewOff.RenderedConfig, "add_header Alt-Svc") { - t.Fatal("expected routing server block to omit Alt-Svc header when disabled") - } -} - -func setupServiceTestDB(t *testing.T) { - t.Helper() - nodeAccessTokenCache.reset() - common.SQLitePath = filepath.Join(t.TempDir(), "service.db") - if err := model.InitDB(); err != nil { - t.Fatalf("failed to init db: %v", err) - } - t.Cleanup(func() { - nodeAccessTokenCache.reset() - if err := model.CloseDB(); err != nil { - t.Fatalf("failed to close db: %v", err) - } - }) -} - -func generateCertificatePair(t *testing.T, dnsNames []string) (string, string) { - t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("GenerateKey failed: %v", err) - } - template := &x509.Certificate{ - Subject: pkix.Name{ - CommonName: dnsNames[0], - }, - DNSNames: dnsNames, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - IsCA: false, - SerialNumber: big.NewInt(time.Now().UnixNano()), - } - certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) - if err != nil { - t.Fatalf("CreateCertificate failed: %v", err) - } - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}) - return string(certPEM), string(keyPEM) -} diff --git a/openflare-server/internal/service/lego_client.go b/openflare-server/internal/service/lego_client.go deleted file mode 100644 index c1991350..00000000 --- a/openflare-server/internal/service/lego_client.go +++ /dev/null @@ -1,101 +0,0 @@ -package service - -import ( - "fmt" - "strings" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/utils/acme" -) - -func ObtainSSL(cert *model.TLSCertificate) error { - cert.ApplyStatus = "applying" - model.DB.Save(cert) - - acmeAccount, err := model.GetAcmeAccountByID(cert.AcmeAccountID) - if err != nil { - // Fallback to default ACME account if the specified one is not found (e.g. ID 0 during testing) - acmeAccount, err = model.GetDefaultAcmeAccount() - if err != nil { - updateCertError(cert, fmt.Sprintf("Failed to get ACME account: %v", err)) - return err - } - // Self-heal the certificate - cert.AcmeAccountID = acmeAccount.ID - model.DB.Save(cert) - } - - dnsAccount, err := model.GetDnsAccountByID(cert.DnsAccountID) - if err != nil { - updateCertError(cert, fmt.Sprintf("Failed to get DNS account: %v", err)) - return err - } - - domains := []string{cert.PrimaryDomain} - if cert.OtherDomains != "" { - for _, d := range strings.Split(cert.OtherDomains, "\n") { - d = strings.TrimSpace(d) - if d != "" { - domains = append(domains, d) - } - } - } - - newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL( - acmeAccount.Email, - acmeAccount.PrivateKey, - acmeAccount.URL, - dnsAccount.Type, - dnsAccount.Authorization, - cert.DNS1, - cert.DNS2, - cert.DisableCNAME, - cert.SkipDNS, - cert.KeyAlgorithm, - domains, - ) - - // If new key or URL was generated, save them to the DB - if (newPrivateKeyPEM != "" && acmeAccount.PrivateKey != newPrivateKeyPEM) || (newAccountURL != "" && acmeAccount.URL != newAccountURL) { - if newPrivateKeyPEM != "" { - acmeAccount.PrivateKey = newPrivateKeyPEM - } - if newAccountURL != "" { - acmeAccount.URL = newAccountURL - } - if acmeAccount.ID == 0 { - if dbErr := model.DB.Create(acmeAccount).Error; dbErr != nil { - updateCertError(cert, fmt.Sprintf("Failed to create ACME account: %v", dbErr)) - return dbErr - } - } else { - if dbErr := model.DB.Save(acmeAccount).Error; dbErr != nil { - updateCertError(cert, fmt.Sprintf("Failed to save ACME account: %v", dbErr)) - return dbErr - } - } - // Self-heal the cert - cert.AcmeAccountID = acmeAccount.ID - model.DB.Save(cert) - } - - if err != nil { - updateCertError(cert, err.Error()) - return err - } - - cert.CertPEM = result.CertPEM - cert.KeyPEM = result.KeyPEM - cert.NotBefore = result.NotBefore - cert.NotAfter = result.NotAfter - cert.ApplyStatus = "ready" - cert.ApplyMessage = "" - - return model.DB.Save(cert).Error -} - -func updateCertError(cert *model.TLSCertificate, message string) { - cert.ApplyStatus = "error" - cert.ApplyMessage = message - model.DB.Save(cert) -} diff --git a/openflare-server/internal/service/managed_domain.go b/openflare-server/internal/service/managed_domain.go deleted file mode 100644 index f35563ca..00000000 --- a/openflare-server/internal/service/managed_domain.go +++ /dev/null @@ -1,228 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "sort" - "strings" - "unicode" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -const ( - ManagedDomainMatchTypeExact = "exact" - ManagedDomainMatchTypeWildcard = "wildcard" -) - -type ManagedDomainInput struct { - Domain string `json:"domain"` - CertID *uint `json:"cert_id"` - Enabled bool `json:"enabled"` - Remark string `json:"remark"` -} - -type ManagedDomainMatchCandidate struct { - ManagedDomainID uint `json:"managed_domain_id"` - Domain string `json:"domain"` - MatchType string `json:"match_type"` - CertificateID uint `json:"certificate_id"` - CertificateName string `json:"certificate_name"` -} - -type ManagedDomainMatchResult struct { - Domain string `json:"domain"` - Matched bool `json:"matched"` - Candidate *ManagedDomainMatchCandidate `json:"candidate,omitempty"` - Candidates []ManagedDomainMatchCandidate `json:"candidates"` -} - -func ListManagedDomains() ([]*model.ManagedDomain, error) { - return model.ListManagedDomains() -} - -func CreateManagedDomain(input ManagedDomainInput) (*model.ManagedDomain, error) { - domain, err := buildManagedDomain(nil, input) - if err != nil { - return nil, err - } - if err = domain.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("域名已存在") - } - return nil, err - } - return domain, nil -} - -func UpdateManagedDomain(id uint, input ManagedDomainInput) (*model.ManagedDomain, error) { - domain, err := model.GetManagedDomainByID(id) - if err != nil { - return nil, err - } - domain, err = buildManagedDomain(domain, input) - if err != nil { - return nil, err - } - if err = domain.Update(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("域名已存在") - } - return nil, err - } - return domain, nil -} - -func DeleteManagedDomain(id uint) error { - domain, err := model.GetManagedDomainByID(id) - if err != nil { - return err - } - return domain.Delete() -} - -func MatchManagedDomainCertificate(rawDomain string) (*ManagedDomainMatchResult, error) { - domain := normalizeManagedDomain(rawDomain) - if err := validateManagedDomainPattern(domain); err != nil { - return nil, err - } - managedDomains, err := model.ListEnabledManagedDomainsWithCertificate() - if err != nil { - return nil, err - } - candidates := make([]ManagedDomainMatchCandidate, 0) - for _, item := range managedDomains { - if item.CertID == nil || *item.CertID == 0 { - continue - } - matchType := detectManagedDomainMatchType(item.Domain, domain) - if matchType == "" { - continue - } - certificate, err := model.GetTLSCertificateByID(*item.CertID) - if err != nil { - return nil, fmt.Errorf("托管域名 %s 关联证书不存在", item.Domain) - } - candidates = append(candidates, ManagedDomainMatchCandidate{ - ManagedDomainID: item.ID, - Domain: item.Domain, - MatchType: matchType, - CertificateID: certificate.ID, - CertificateName: certificate.Name, - }) - } - sortManagedDomainCandidates(candidates) - result := &ManagedDomainMatchResult{ - Domain: domain, - Matched: len(candidates) > 0, - Candidates: candidates, - } - if len(candidates) > 0 { - candidate := candidates[0] - result.Candidate = &candidate - } - return result, nil -} - -func buildManagedDomain(existing *model.ManagedDomain, input ManagedDomainInput) (*model.ManagedDomain, error) { - domain := normalizeManagedDomain(input.Domain) - remark := strings.TrimSpace(input.Remark) - if err := validateManagedDomainPattern(domain); err != nil { - return nil, err - } - if input.CertID != nil && *input.CertID != 0 { - if _, err := model.GetTLSCertificateByID(*input.CertID); err != nil { - return nil, errors.New("所选证书不存在") - } - } else { - input.CertID = nil - } - if existing == nil { - existing = &model.ManagedDomain{} - } - existing.Domain = domain - existing.CertID = input.CertID - existing.Enabled = input.Enabled - existing.Remark = remark - return existing, nil -} - -func normalizeManagedDomain(domain string) string { - return strings.ToLower(strings.TrimSpace(domain)) -} - -func validateManagedDomainPattern(domain string) error { - if domain == "" { - return errors.New("域名不能为空") - } - if strings.Contains(domain, "://") || strings.Contains(domain, "/") { - return errors.New("域名格式不合法") - } - if strings.Contains(domain, "*") { - if !strings.HasPrefix(domain, "*.") || strings.Count(domain, "*") != 1 { - return errors.New("通配符域名仅支持 *.example.com 格式") - } - return validateHostname(strings.TrimPrefix(domain, "*.")) - } - return validateHostname(domain) -} - -func validateHostname(domain string) error { - if domain == "" { - return errors.New("域名不能为空") - } - if len(domain) > 253 { - return errors.New("域名格式不合法") - } - labels := strings.Split(domain, ".") - if len(labels) < 2 { - return errors.New("域名格式不合法") - } - for _, label := range labels { - if len(label) == 0 || len(label) > 63 { - return errors.New("域名格式不合法") - } - if label[0] == '-' || label[len(label)-1] == '-' { - return errors.New("域名格式不合法") - } - for _, r := range label { - if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { - continue - } - return errors.New("域名格式不合法") - } - } - return nil -} - -func detectManagedDomainMatchType(pattern string, domain string) string { - if pattern == domain { - return ManagedDomainMatchTypeExact - } - if !strings.HasPrefix(pattern, "*.") { - return "" - } - suffix := strings.TrimPrefix(pattern, "*.") - if !strings.HasSuffix(domain, "."+suffix) { - return "" - } - prefix := strings.TrimSuffix(domain, "."+suffix) - if prefix == "" || strings.Contains(prefix, ".") { - return "" - } - return ManagedDomainMatchTypeWildcard -} - -func sortManagedDomainCandidates(candidates []ManagedDomainMatchCandidate) { - sort.Slice(candidates, func(i int, j int) bool { - left := candidates[i] - right := candidates[j] - if left.MatchType != right.MatchType { - return left.MatchType == ManagedDomainMatchTypeExact - } - if len(left.Domain) != len(right.Domain) { - return len(left.Domain) > len(right.Domain) - } - return left.ManagedDomainID < right.ManagedDomainID - }) -} diff --git a/openflare-server/internal/service/managed_domain_phase2_test.go b/openflare-server/internal/service/managed_domain_phase2_test.go deleted file mode 100644 index ecc6af6e..00000000 --- a/openflare-server/internal/service/managed_domain_phase2_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package service - -import "testing" - -func TestMatchManagedDomainCertificatePrefersExactMatch(t *testing.T) { - setupServiceTestDB(t) - - wildcardCertPEM, wildcardKeyPEM := generateCertificatePair(t, []string{"*.example.com"}) - wildcardCert, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "wildcard-cert", - CertPEM: wildcardCertPEM, - KeyPEM: wildcardKeyPEM, - }) - if err != nil { - t.Fatalf("failed to create wildcard certificate: %v", err) - } - exactCertPEM, exactKeyPEM := generateCertificatePair(t, []string{"api.example.com"}) - exactCert, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "exact-cert", - CertPEM: exactCertPEM, - KeyPEM: exactKeyPEM, - }) - if err != nil { - t.Fatalf("failed to create exact certificate: %v", err) - } - if _, err = CreateManagedDomain(ManagedDomainInput{ - Domain: "*.example.com", - CertID: &wildcardCert.ID, - Enabled: true, - }); err != nil { - t.Fatalf("failed to create wildcard managed domain: %v", err) - } - if _, err = CreateManagedDomain(ManagedDomainInput{ - Domain: "api.example.com", - CertID: &exactCert.ID, - Enabled: true, - }); err != nil { - t.Fatalf("failed to create exact managed domain: %v", err) - } - - result, err := MatchManagedDomainCertificate("api.example.com") - if err != nil { - t.Fatalf("MatchManagedDomainCertificate failed: %v", err) - } - if !result.Matched || result.Candidate == nil { - t.Fatal("expected exact domain to be matched") - } - if result.Candidate.MatchType != ManagedDomainMatchTypeExact { - t.Fatalf("expected exact match first, got %s", result.Candidate.MatchType) - } - if result.Candidate.CertificateID != exactCert.ID { - t.Fatalf("expected exact certificate %d, got %d", exactCert.ID, result.Candidate.CertificateID) - } - if len(result.Candidates) != 2 { - t.Fatalf("expected 2 match candidates, got %d", len(result.Candidates)) - } -} - -func TestMatchManagedDomainCertificateSupportsWildcard(t *testing.T) { - setupServiceTestDB(t) - - certPEM, keyPEM := generateCertificatePair(t, []string{"*.example.com"}) - certificate, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "wildcard-cert", - CertPEM: certPEM, - KeyPEM: keyPEM, - }) - if err != nil { - t.Fatalf("failed to create certificate: %v", err) - } - if _, err = CreateManagedDomain(ManagedDomainInput{ - Domain: "*.example.com", - CertID: &certificate.ID, - Enabled: true, - }); err != nil { - t.Fatalf("failed to create managed domain: %v", err) - } - - result, err := MatchManagedDomainCertificate("edge.example.com") - if err != nil { - t.Fatalf("MatchManagedDomainCertificate failed: %v", err) - } - if !result.Matched || result.Candidate == nil { - t.Fatal("expected wildcard domain to be matched") - } - if result.Candidate.MatchType != ManagedDomainMatchTypeWildcard { - t.Fatalf("expected wildcard match, got %s", result.Candidate.MatchType) - } - - deepResult, err := MatchManagedDomainCertificate("deep.edge.example.com") - if err != nil { - t.Fatalf("MatchManagedDomainCertificate failed: %v", err) - } - if deepResult.Matched { - t.Fatal("expected single-level wildcard not to match deep subdomain") - } -} - -func TestCreateManagedDomainRejectsInvalidWildcard(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreateManagedDomain(ManagedDomainInput{ - Domain: "*.*.example.com", - Enabled: true, - }) - if err == nil { - t.Fatal("expected invalid wildcard domain to fail") - } -} diff --git a/openflare-server/internal/service/node.go b/openflare-server/internal/service/node.go deleted file mode 100644 index d09562b5..00000000 --- a/openflare-server/internal/service/node.go +++ /dev/null @@ -1,661 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "log/slog" - "net" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/pkg/geoip" - "github.com/rain-kl/openflare/pkg/geoip/iputil" -) - -type NodeInput struct { - Name string `json:"name"` - IP string `json:"ip"` - IPManualOverride *bool `json:"ip_manual_override"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - GeoName string `json:"geo_name"` - GeoLatitude *float64 `json:"geo_latitude"` - GeoLongitude *float64 `json:"geo_longitude"` - GeoManualOverride bool `json:"geo_manual_override"` - // TunnelRelay fields - NodeType string `json:"node_type"` - RelayBindPort int `json:"relay_bind_port"` - RelayVhostHTTPPort int `json:"relay_vhost_http_port"` - RelayAgentAccessAddr string `json:"relay_agent_access_addr"` - RelayClientAccessAddr string `json:"relay_client_access_addr"` - RelayClientProxyURL string `json:"relay_client_proxy_url"` - RelayWebServerEnabled bool `json:"relay_web_server_enabled"` -} - -type NodeAgentUpdateInput struct { - Channel string `json:"channel"` - TagName string `json:"tag_name"` -} - -type NodeAgentReleaseInfo struct { - TagName string `json:"tag_name"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` - PublishedAt string `json:"published_at"` - CurrentVersion string `json:"current_version"` - HasUpdate bool `json:"has_update"` - Channel string `json:"channel"` - Prerelease bool `json:"prerelease"` - UpdateRequested bool `json:"update_requested"` - RequestedChannel string `json:"requested_channel"` - RequestedTag string `json:"requested_tag"` -} - -type NodeBootstrapView struct { - DiscoveryToken string `json:"discovery_token"` -} - -type AgentRegistrationResponse struct { - NodeID string `json:"node_id"` - AccessToken string `json:"access_token"` - Name string `json:"name"` -} - -func CreateNode(input NodeInput) (*NodeView, error) { - name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input) - if name == "" { - return nil, errors.New("节点名不能为空") - } - ipManualOverride := resolveNodeIPManualOverride(input, nil, ip) - node := &model.Node{ - Name: name, - IP: ip, - IPManualOverride: ipManualOverride, - GeoName: geoName, - GeoLatitude: geoLatitude, - GeoLongitude: geoLongitude, - GeoManualOverride: geoManualOverride, - Version: "", - ExtVersion: "", - Status: NodeStatusPending, - AutoUpdateEnabled: input.AutoUpdateEnabled, - NodeType: normalizeNodeType(input.NodeType), - } - node.NodeID, err = newServerNodeID() - if err != nil { - return nil, err - } - node.AccessToken, err = newRandomToken() - if err != nil { - return nil, err - } - if node.NodeType == "tunnel_relay" { - node.RelayBindPort = normalizeRelayPort(input.RelayBindPort, 7000) - node.RelayVhostHTTPPort = normalizeRelayPort(input.RelayVhostHTTPPort, 8080) - node.RelayAuthToken, err = newRandomToken() - if err != nil { - return nil, err - } - node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr) - node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr) - node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL) - node.RelayWebServerEnabled = input.RelayWebServerEnabled - } - if !node.GeoManualOverride { - applyGeoInfoFromIP(node, node.IP) - } - if err := node.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("节点标识生成冲突,请重试") - } - return nil, err - } - refreshAccessTokenCache(node) - slog.Info("node created", "name", node.Name, "node_id", node.NodeID) - return buildNodeView(node), nil -} - -func UpdateNode(id uint, input NodeInput) (*NodeView, error) { - name, ip, geoName, geoLatitude, geoLongitude, geoManualOverride, err := normalizeNodeInput(input) - if name == "" { - return nil, errors.New("节点名不能为空") - } - node, err := model.GetNodeByID(id) - if err != nil { - return nil, err - } - ipManualOverride := resolveNodeIPManualOverride(input, node, ip) - node.Name = name - node.IP = ip - node.IPManualOverride = ipManualOverride - node.GeoName = geoName - node.GeoLatitude = geoLatitude - node.GeoLongitude = geoLongitude - node.GeoManualOverride = geoManualOverride - node.AutoUpdateEnabled = input.AutoUpdateEnabled - if node.NodeType == "tunnel_relay" { - node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr) - node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr) - node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL) - node.RelayWebServerEnabled = input.RelayWebServerEnabled - if input.RelayBindPort > 0 { - node.RelayBindPort = input.RelayBindPort - } - if input.RelayVhostHTTPPort > 0 { - node.RelayVhostHTTPPort = input.RelayVhostHTTPPort - } - } - if !node.GeoManualOverride { - applyGeoInfoFromIP(node, strings.TrimSpace(node.IP)) - } - if err = node.Update(); err != nil { - return nil, err - } - refreshAccessTokenCache(node) - slog.Info("node updated", "name", node.Name, "node_id", node.NodeID) - return buildNodeView(node), nil -} - -func DeleteNode(id uint) error { - node, err := model.GetNodeByID(id) - if err != nil { - return err - } - slog.Info("node deleted", "name", node.Name, "node_id", node.NodeID) - if err := node.Delete(); err != nil { - return err - } - invalidateAccessTokenCache(node.AccessToken) - DisconnectAgentWSClient(node.NodeID) - DisconnectFlaredWSClient(node.NodeID) - return nil -} - -func GetNodeAgentRelease(ctx context.Context, id uint, channel string) (*NodeAgentReleaseInfo, error) { - node, err := model.GetNodeByID(id) - if err != nil { - return nil, err - } - release, err := fetchLatestGitHubRelease(ctx, common.AgentUpdateRepo, normalizeReleaseChannel(channel)) - if err != nil { - return nil, err - } - return buildNodeAgentReleaseView(node, release, normalizeReleaseChannel(channel)), nil -} - -func RequestNodeAgentUpdate(id uint, input NodeAgentUpdateInput) (*NodeView, error) { - node, err := model.GetNodeByID(id) - if err != nil { - return nil, err - } - channel := normalizeReleaseChannel(input.Channel) - tagName := strings.TrimSpace(input.TagName) - if tagName != "" { - release, releaseErr := fetchGitHubReleaseByTag(context.Background(), common.AgentUpdateRepo, tagName) - if releaseErr != nil { - return nil, releaseErr - } - if channel == ReleaseChannelPreview && !release.Prerelease { - return nil, errors.New("指定版本不是 preview 发布") - } - if channel == ReleaseChannelStable && release.Prerelease { - return nil, errors.New("正式版更新不能选择 preview 发布") - } - } - node.UpdateRequested = true - node.UpdateChannel = channel.String() - node.UpdateTag = tagName - if err = model.DB.Model(node).Select("update_requested", "update_channel", "update_tag").Updates(node).Error; err != nil { - return nil, err - } - refreshAccessTokenCache(node) - if SendAgentWSSettings(node.NodeID, buildAgentSettings(node, true, channel.String(), tagName, node.RestartOpenrestyRequested)) { - slog.Debug("agent manual update pushed via ws", "node_id", node.NodeID, "channel", channel.String(), "tag", tagName) - } else { - slog.Debug("agent manual update waiting for next heartbeat", "node_id", node.NodeID, "channel", channel.String(), "tag", tagName) - } - slog.Info("agent manual update requested", "node_id", node.NodeID, "name", node.Name, "channel", channel.String(), "tag", tagName) - return buildNodeView(node), nil -} - -func RequestNodeOpenrestyRestart(id uint) (*NodeView, error) { - node, err := model.GetNodeByID(id) - if err != nil { - return nil, err - } - node.RestartOpenrestyRequested = true - if err = model.DB.Model(node).Select("restart_openresty_requested").Updates(node).Error; err != nil { - return nil, err - } - refreshAccessTokenCache(node) - slog.Info("openresty restart requested", "node_id", node.NodeID, "name", node.Name) - return buildNodeView(node), nil -} - -func RequestNodeForceSync(id uint) (*NodeView, error) { - node, err := model.GetNodeByID(id) - if err != nil { - return nil, err - } - activeConfig, err := GetActiveConfigMetaForAgent() - if err != nil { - return nil, errors.New("无法获取当前激活的配置版本:" + err.Error()) - } - if !SendAgentWSForceSyncConfig(node.NodeID, activeConfig) { - return nil, errors.New("节点不在线或通过 WebSocket 发送同步指令失败") - } - slog.Info("force sync requested via ws", "node_id", node.NodeID, "name", node.Name) - return buildNodeView(node), nil -} - -func AuthenticateAccessToken(token string) (*model.Node, error) { - token = strings.TrimSpace(token) - if token == "" { - return nil, errors.New("缺少 Agent Token") - } - return authenticateAccessTokenWithCache(token) -} - -func ValidateDiscoveryToken(token string) error { - token = strings.TrimSpace(token) - if token == "" { - return errors.New("缺少 Discovery Token") - } - discoveryToken, err := EnsureGlobalDiscoveryToken() - if err != nil { - return err - } - if token != discoveryToken { - return errors.New("Discovery Token 无效") - } - return nil -} - -func EnsureGlobalDiscoveryToken() (string, error) { - common.OptionMapRWMutex.RLock() - needsInit := common.OptionMap == nil - common.OptionMapRWMutex.RUnlock() - if needsInit { - model.InitOptionMap() - } - common.OptionMapRWMutex.RLock() - token := strings.TrimSpace(common.AgentDiscoveryToken) - common.OptionMapRWMutex.RUnlock() - if token != "" { - return token, nil - } - token, err := newRandomToken() - if err != nil { - return "", err - } - if err = model.UpdateOption("AgentDiscoveryToken", token); err != nil { - return "", err - } - return token, nil -} - -func GetNodeBootstrapView() (*NodeBootstrapView, error) { - token, err := EnsureGlobalDiscoveryToken() - if err != nil { - return nil, err - } - return &NodeBootstrapView{DiscoveryToken: token}, nil -} - -func RotateGlobalDiscoveryToken() (*NodeBootstrapView, error) { - token, err := newRandomToken() - if err != nil { - return nil, err - } - if err = model.UpdateOption("AgentDiscoveryToken", token); err != nil { - return nil, err - } - return &NodeBootstrapView{DiscoveryToken: token}, nil -} - -func buildNodeView(node *model.Node) *NodeView { - status := computeNodeStatus(node) - view := &NodeView{ - ID: node.ID, - NodeID: node.NodeID, - Name: node.Name, - IP: node.IP, - IPManualOverride: node.IPManualOverride, - GeoName: strings.TrimSpace(node.GeoName), - GeoLatitude: node.GeoLatitude, - GeoLongitude: node.GeoLongitude, - GeoManualOverride: node.GeoManualOverride, - AccessToken: node.AccessToken, - UpdateChannel: strings.TrimSpace(node.UpdateChannel), - UpdateTag: strings.TrimSpace(node.UpdateTag), - RestartOpenrestyRequested: node.RestartOpenrestyRequested, - Version: node.Version, - ExtVersion: node.ExtVersion, - OpenrestyStatus: normalizeOpenrestyStatus(node.OpenrestyStatus), - OpenrestyMessage: strings.TrimSpace(node.OpenrestyMessage), - Status: status, - CurrentVersion: node.CurrentVersion, - LastSeenAt: nodeViewLastSeenAt(node), - LastError: node.LastError, - CreatedAt: node.CreatedAt, - UpdatedAt: node.UpdatedAt, - AutoUpdateEnabled: node.AutoUpdateEnabled, - UpdateRequested: node.UpdateRequested, - } - if view.UpdateChannel == "" { - view.UpdateChannel = ReleaseChannelStable.String() - } - view.NodeType = node.NodeType - if view.NodeType == "" { - view.NodeType = "edge_node" - } - view.RelayBindPort = node.RelayBindPort - view.RelayVhostHTTPPort = node.RelayVhostHTTPPort - view.RelayAgentAccessAddr = node.RelayAgentAccessAddr - view.RelayClientAccessAddr = node.RelayClientAccessAddr - view.RelayClientProxyURL = node.RelayClientProxyURL - view.RelayStatus = node.RelayStatus - view.RelayWebServerEnabled = node.RelayWebServerEnabled - view.Version = node.Version - view.ExtVersion = node.ExtVersion - return view -} - -func nodeViewLastSeenAt(node *model.Node) any { - if node == nil { - return time.Time{} - } - if node.NodeType == "tunnel_relay" && IsRelayWSConnected(node.NodeID) { - return RelayWSConnectedLastSeenValue - } - if node.NodeType == "tunnel_client" && IsFlaredWSConnected(node.NodeID) { - return FlaredWSConnectedLastSeenValue - } - if IsAgentWSConnected(node.NodeID) { - return AgentWSConnectedLastSeenValue - } - return node.LastSeenAt -} - -func normalizeNodeInput(input NodeInput) (string, string, string, *float64, *float64, bool, error) { - name := strings.TrimSpace(input.Name) - ip := strings.TrimSpace(input.IP) - geoName := strings.TrimSpace(input.GeoName) - manualOverride := input.GeoManualOverride || geoName != "" || input.GeoLatitude != nil || input.GeoLongitude != nil - if len(ip) > 64 { - return "", "", "", nil, nil, false, errors.New("节点 IP 不能超过 64 个字符") - } - if ip != "" && net.ParseIP(ip) == nil { - return "", "", "", nil, nil, false, errors.New("节点 IP 格式无效") - } - if input.IPManualOverride != nil && *input.IPManualOverride && ip == "" { - return "", "", "", nil, nil, false, errors.New("锁定节点 IP 时必须填写节点 IP") - } - if len(geoName) > 128 { - return "", "", "", nil, nil, false, errors.New("节点位置名不能超过 128 个字符") - } - - geoLatitude := cloneCoordinate(input.GeoLatitude) - geoLongitude := cloneCoordinate(input.GeoLongitude) - if (geoLatitude == nil) != (geoLongitude == nil) { - return "", "", "", nil, nil, false, errors.New("地图坐标必须同时填写纬度和经度") - } - if geoLatitude != nil && (*geoLatitude < -90 || *geoLatitude > 90) { - return "", "", "", nil, nil, false, errors.New("纬度必须在 -90 到 90 之间") - } - if geoLongitude != nil && (*geoLongitude < -180 || *geoLongitude > 180) { - return "", "", "", nil, nil, false, errors.New("经度必须在 -180 到 180 之间") - } - - if !manualOverride { - return name, ip, "", nil, nil, false, nil - } - if geoLatitude == nil && geoLongitude == nil && geoName == "" { - return name, ip, "", nil, nil, false, nil - } - - return name, ip, geoName, geoLatitude, geoLongitude, true, nil -} - -func resolveNodeIPManualOverride(input NodeInput, existing *model.Node, normalizedIP string) bool { - if input.IPManualOverride != nil { - return *input.IPManualOverride - } - if existing == nil { - return strings.TrimSpace(normalizedIP) != "" - } - if existing.IPManualOverride { - return true - } - return strings.TrimSpace(normalizedIP) != "" && strings.TrimSpace(normalizedIP) != strings.TrimSpace(existing.IP) -} - -func cloneCoordinate(value *float64) *float64 { - if value == nil { - return nil - } - cloned := *value - return &cloned -} - -func ResolveReportedNodeIP(reportedIP string, remoteAddr string) string { - reported := iputil.NormalizeIP(reportedIP) - remote := iputil.NormalizeRemoteAddr(remoteAddr) - if reported == "" { - return remote - } - if !shouldPreferRemoteNodeIP(reported) { - return reported - } - if isPublicNodeIP(remote) { - return remote - } - return reported -} - -func shouldPreferRemoteNodeIP(ip string) bool { - return !isPublicNodeIP(ip) -} - -func isPublicNodeIP(raw string) bool { - return iputil.IsPublicString(raw) -} - -func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo { - currentVersion := strings.TrimSpace(node.Version) - view := &NodeAgentReleaseInfo{ - CurrentVersion: currentVersion, - Channel: channel.String(), - UpdateRequested: node.UpdateRequested, - RequestedChannel: normalizeReleaseChannel(node.UpdateChannel).String(), - RequestedTag: strings.TrimSpace(node.UpdateTag), - } - if release == nil { - return view - } - view.TagName = release.TagName - view.Body = release.Body - view.HTMLURL = release.HTMLURL - view.PublishedAt = release.PublishedAt - view.Prerelease = release.Prerelease - view.HasUpdate = isVersionNewer(currentVersion, release.TagName) - return view -} - -func RegisterNodeWithAccessToken(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) { - payload = normalizeAgentNodePayload(payload) - if node == nil { - return nil, errors.New("节点不存在") - } - if err := validateAgentNodePayload(payload); err != nil { - return nil, err - } - applyNodeRuntime(node, payload, true) - if err := node.Update(); err != nil { - return nil, err - } - refreshAccessTokenCache(node) - slog.Info("agent register succeeded on reserved node", "node_id", node.NodeID, "name", node.Name) - return &AgentRegistrationResponse{ - NodeID: node.NodeID, - AccessToken: node.AccessToken, - Name: node.Name, - }, nil -} - -func RegisterNodeWithDiscovery(payload AgentNodePayload) (*AgentRegistrationResponse, error) { - payload = normalizeAgentNodePayload(payload) - if err := validateAgentNodePayload(payload); err != nil { - return nil, err - } - nodeID, err := newServerNodeID() - if err != nil { - return nil, err - } - agentToken, err := newRandomToken() - if err != nil { - return nil, err - } - nodeName := payload.Name - if nodeName == "" { - nodeName = nodeID - } - node := &model.Node{ - NodeID: nodeID, - Name: nodeName, - AccessToken: agentToken, - } - applyNodeRuntime(node, payload, false) - if err = node.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("节点标识生成冲突,请重试") - } - return nil, err - } - refreshAccessTokenCache(node) - slog.Info("agent discovery register succeeded", "node_id", node.NodeID, "name", node.Name) - return &AgentRegistrationResponse{ - NodeID: node.NodeID, - AccessToken: node.AccessToken, - Name: node.Name, - }, nil -} - -func normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload { - payload.Name = strings.TrimSpace(payload.Name) - payload.IP = strings.TrimSpace(payload.IP) - payload.Version = strings.TrimSpace(payload.Version) - payload.ExtVersion = strings.TrimSpace(payload.ExtVersion) - payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion) - payload.LastError = truncateForDatabase(payload.LastError, 16000) - payload.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus) - payload.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000) - return payload -} - -func validateAgentNodePayload(payload AgentNodePayload) error { - if payload.IP == "" { - return errors.New("ip 不能为空") - } - if net.ParseIP(payload.IP) == nil { - return errors.New("ip 格式无效") - } - if payload.Version == "" { - return errors.New("version 不能为空") - } - return nil -} - -func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName bool) { - if !preserveName || strings.TrimSpace(node.Name) == "" { - if strings.TrimSpace(payload.Name) != "" { - node.Name = strings.TrimSpace(payload.Name) - } - } - if !node.IPManualOverride { - node.IP = strings.TrimSpace(payload.IP) - } - node.Version = strings.TrimSpace(payload.Version) - node.ExtVersion = strings.TrimSpace(payload.ExtVersion) - node.OpenrestyStatus = normalizeOpenrestyStatus(payload.OpenrestyStatus) - node.OpenrestyMessage = truncateForDatabase(payload.OpenrestyMessage, 16000) - node.Status = NodeStatusOnline - node.CurrentVersion = strings.TrimSpace(payload.CurrentVersion) - node.LastSeenAt = time.Now() - node.LastError = truncateForDatabase(payload.LastError, 16000) - if !node.GeoManualOverride { - applyGeoInfoFromIP(node, node.IP) - } -} - -func applyGeoInfoFromIP(node *model.Node, rawIP string) { - if node == nil { - return - } - node.GeoName = "" - node.GeoLatitude = nil - node.GeoLongitude = nil - ip := net.ParseIP(strings.TrimSpace(rawIP)) - if ip == nil { - return - } - info, err := geoip.GetGeoInfo(ip) - if err != nil || info == nil { - return - } - if strings.TrimSpace(info.Name) != "" { - node.GeoName = strings.TrimSpace(info.Name) - } - if info.Latitude != nil && info.Longitude != nil { - node.GeoLatitude = cloneCoordinate(info.Latitude) - node.GeoLongitude = cloneCoordinate(info.Longitude) - } -} - -func normalizeOpenrestyStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case OpenrestyStatusHealthy: - return OpenrestyStatusHealthy - case OpenrestyStatusUnhealthy: - return OpenrestyStatusUnhealthy - default: - return OpenrestyStatusUnknown - } -} - -func newRandomToken() (string, error) { - buf := make([]byte, 16) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return hex.EncodeToString(buf), nil -} - -func newServerNodeID() (string, error) { - token, err := newRandomToken() - if err != nil { - return "", err - } - return "node-" + token, nil -} - -func normalizeNodeType(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "tunnel_relay": - return "tunnel_relay" - case "tunnel_client": - return "tunnel_client" - default: - return "edge_node" - } -} - -func normalizeRelayPort(port int, defaultPort int) int { - if port <= 0 || port > 65535 { - return defaultPort - } - return port -} diff --git a/openflare-server/internal/service/node_agent_token_cache.go b/openflare-server/internal/service/node_agent_token_cache.go deleted file mode 100644 index b411ec37..00000000 --- a/openflare-server/internal/service/node_agent_token_cache.go +++ /dev/null @@ -1,178 +0,0 @@ -package service - -import ( - "errors" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - ristretto "github.com/dgraph-io/ristretto/v2" - "gorm.io/gorm" -) - -const ( - agentTokenPositiveCacheTTL = 2 * time.Minute - agentTokenNegativeCacheTTL = 10 * time.Minute - agentTokenNegativeCacheCap = 10000 -) - -type cachedAgentNode struct { - node *model.Node - expiresAt time.Time -} - -type cachedMissingAccessToken struct { - expiresAt time.Time -} - -type agentTokenAuthCache struct { - positive *ristretto.Cache[string, cachedAgentNode] - negative *ristretto.Cache[string, cachedMissingAccessToken] - now func() time.Time - loadNodeByToken func(string) (*model.Node, error) -} - -var nodeAccessTokenCache = newAccessTokenAuthCache() - -func newAccessTokenAuthCache() *agentTokenAuthCache { - return &agentTokenAuthCache{ - positive: mustNewAccessTokenPositiveCache(), - negative: mustNewAccessTokenNegativeCache(), - now: time.Now, - loadNodeByToken: func(token string) (*model.Node, error) { - return model.GetNodeByAccessToken(token) - }, - } -} - -func mustNewAccessTokenPositiveCache() *ristretto.Cache[string, cachedAgentNode] { - cache, err := ristretto.NewCache(&ristretto.Config[string, cachedAgentNode]{ - NumCounters: 1e5, - MaxCost: 2e4, - BufferItems: 64, - }) - if err != nil { - panic(err) - } - return cache -} - -func mustNewAccessTokenNegativeCache() *ristretto.Cache[string, cachedMissingAccessToken] { - cache, err := ristretto.NewCache(&ristretto.Config[string, cachedMissingAccessToken]{ - NumCounters: 1e5, - MaxCost: agentTokenNegativeCacheCap, - BufferItems: 64, - }) - if err != nil { - panic(err) - } - return cache -} - -func (c *agentTokenAuthCache) authenticate(token string) (*model.Node, error) { - now := c.now() - if node, ok := c.getNode(token, now); ok { - return node, nil - } - if c.isMissing(token, now) { - return nil, gorm.ErrRecordNotFound - } - - node, err := c.loadNodeByToken(token) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.storeMissing(token, now.Add(agentTokenNegativeCacheTTL)) - } - return nil, err - } - - c.storeNode(token, node, now.Add(agentTokenPositiveCacheTTL)) - return cloneCachedNode(node), nil -} - -func (c *agentTokenAuthCache) getNode(token string, now time.Time) (*model.Node, bool) { - entry, ok := c.positive.Get(token) - if !ok { - return nil, false - } - if now.After(entry.expiresAt) { - c.positive.Del(token) - return nil, false - } - return cloneCachedNode(entry.node), true -} - -func (c *agentTokenAuthCache) isMissing(token string, now time.Time) bool { - entry, ok := c.negative.Get(token) - if !ok { - return false - } - if now.After(entry.expiresAt) { - c.negative.Del(token) - return false - } - return true -} - -func (c *agentTokenAuthCache) storeNode(token string, node *model.Node, expiresAt time.Time) { - if token == "" || node == nil { - return - } - c.negative.Del(token) - c.positive.Set(token, cachedAgentNode{ - node: cloneCachedNode(node), - expiresAt: expiresAt, - }, 1) - c.positive.Wait() -} - -func (c *agentTokenAuthCache) storeMissing(token string, expiresAt time.Time) { - if token == "" { - return - } - c.positive.Del(token) - c.negative.Set(token, cachedMissingAccessToken{ - expiresAt: expiresAt, - }, 1) - c.negative.Wait() -} - -func (c *agentTokenAuthCache) invalidate(token string) { - if token == "" { - return - } - c.positive.Del(token) - c.negative.Del(token) -} - -func (c *agentTokenAuthCache) reset() { - c.positive.Clear() - c.negative.Clear() -} - -func cloneCachedNode(node *model.Node) *model.Node { - if node == nil { - return nil - } - cloned := *node - return &cloned -} - -func authenticateAccessTokenWithCache(token string) (*model.Node, error) { - return nodeAccessTokenCache.authenticate(token) -} - -func refreshAccessTokenCache(node *model.Node) { - if node == nil { - return - } - nodeAccessTokenCache.storeNode( - node.AccessToken, - node, - nodeAccessTokenCache.now().Add(agentTokenPositiveCacheTTL), - ) -} - -func invalidateAccessTokenCache(token string) { - nodeAccessTokenCache.invalidate(token) -} diff --git a/openflare-server/internal/service/node_agent_token_cache_test.go b/openflare-server/internal/service/node_agent_token_cache_test.go deleted file mode 100644 index abf92aa5..00000000 --- a/openflare-server/internal/service/node_agent_token_cache_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -func TestAccessTokenAuthCacheUsesPositiveCacheUntilLogicalExpiry(t *testing.T) { - cache := newAccessTokenAuthCache() - cache.reset() - baseTime := time.Date(2026, 3, 14, 16, 0, 0, 0, time.UTC) - currentTime := baseTime - cache.now = func() time.Time { - return currentTime - } - - loadCount := 0 - cache.loadNodeByToken = func(token string) (*model.Node, error) { - loadCount++ - return &model.Node{ - NodeID: fmt.Sprintf("node-%d", loadCount), - Name: "edge", - AccessToken: token, - }, nil - } - - first, err := cache.authenticate("token-a") - if err != nil { - t.Fatalf("expected first auth to succeed: %v", err) - } - if loadCount != 1 { - t.Fatalf("expected one db load, got %d", loadCount) - } - - second, err := cache.authenticate("token-a") - if err != nil { - t.Fatalf("expected cached auth to succeed: %v", err) - } - if loadCount != 1 { - t.Fatalf("expected cache hit without db load, got %d", loadCount) - } - if first.NodeID != second.NodeID { - t.Fatalf("expected cached node to match original, got %s and %s", first.NodeID, second.NodeID) - } - - currentTime = baseTime.Add(agentTokenPositiveCacheTTL + time.Second) - third, err := cache.authenticate("token-a") - if err != nil { - t.Fatalf("expected auth after expiry to succeed: %v", err) - } - if loadCount != 2 { - t.Fatalf("expected reload after logical expiry, got %d loads", loadCount) - } - if third.NodeID == second.NodeID { - t.Fatalf("expected refreshed cache entry after expiry, got unchanged node id %s", third.NodeID) - } -} - -func TestAccessTokenAuthCacheRefreshesAfterMissingEntryExpires(t *testing.T) { - cache := newAccessTokenAuthCache() - cache.reset() - baseTime := time.Date(2026, 3, 14, 16, 30, 0, 0, time.UTC) - currentTime := baseTime - cache.now = func() time.Time { - return currentTime - } - - loadCount := 0 - cache.loadNodeByToken = func(token string) (*model.Node, error) { - loadCount++ - if loadCount == 1 { - return nil, gorm.ErrRecordNotFound - } - return &model.Node{ - NodeID: "node-recovered", - Name: "edge", - AccessToken: token, - }, nil - } - - _, err := cache.authenticate("token-missing") - if !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("expected first lookup to miss, got %v", err) - } - if loadCount != 1 { - t.Fatalf("expected one db load for first miss, got %d", loadCount) - } - - _, err = cache.authenticate("token-missing") - if !errors.Is(err, gorm.ErrRecordNotFound) { - t.Fatalf("expected cached missing lookup to miss, got %v", err) - } - if loadCount != 1 { - t.Fatalf("expected missing cache hit without db load, got %d", loadCount) - } - - currentTime = baseTime.Add(agentTokenNegativeCacheTTL + time.Second) - node, err := cache.authenticate("token-missing") - if err != nil { - t.Fatalf("expected lookup after missing expiry to reload successfully: %v", err) - } - if loadCount != 2 { - t.Fatalf("expected db reload after missing cache expiry, got %d", loadCount) - } - if node.NodeID != "node-recovered" { - t.Fatalf("unexpected recovered node: %+v", node) - } -} diff --git a/openflare-server/internal/service/node_observability.go b/openflare-server/internal/service/node_observability.go deleted file mode 100644 index 219e411a..00000000 --- a/openflare-server/internal/service/node_observability.go +++ /dev/null @@ -1,247 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -const ( - defaultObservabilityWindow = 24 * time.Hour - defaultObservabilityLimit = 120 - maxObservabilityLimit = 500 -) - -type NodeObservabilityQuery struct { - Hours int `json:"hours"` - Limit int `json:"limit"` -} - -type NodeObservabilityView struct { - NodeID string `json:"node_id"` - Profile *model.NodeSystemProfile `json:"profile"` - MetricSnapshots []*model.NodeMetricSnapshot `json:"metric_snapshots"` - TrafficReports []*model.NodeRequestReport `json:"traffic_reports"` - HealthEvents []*model.NodeHealthEvent `json:"health_events"` - Analytics NodeObservabilityAnalytics `json:"analytics"` - Trends NodeObservabilityTrends `json:"trends"` - RelayDashboard *RelayDashboardSnapshot `json:"relay_dashboard,omitempty"` -} - -type NodeObservabilityAnalytics struct { - Traffic TrafficWindowSummary `json:"traffic"` - Distributions TrafficDistributions `json:"distributions"` - Health ObservabilityHealthSummary `json:"health"` -} - -type NodeObservabilityTrends struct { - Traffic24h []TrafficTrendPoint `json:"traffic_24h"` - Capacity24h []CapacityTrendPoint `json:"capacity_24h"` - Network24h []NetworkTrendPoint `json:"network_24h"` - DiskIO24h []DiskIOTrendPoint `json:"disk_io_24h"` -} - -type NodeHealthEventCleanupResult struct { - NodeID string `json:"node_id"` - DeletedCount int64 `json:"deleted_count"` -} - -type RelayDashboardSnapshot struct { - TotalProxies int `json:"total_proxies"` - OnlineProxies int `json:"online_proxies"` - OfflineProxies int `json:"offline_proxies"` - Proxies []RelayProxyStat `json:"proxies"` - TotalConnections int `json:"total_connections"` - ClientCounts int `json:"client_counts"` -} - -type RelayProxyStat struct { - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - ClientVersion string `json:"client_version"` - LastStartTime string `json:"last_start_time"` - LastCloseTime string `json:"last_close_time"` - ClientAddr string `json:"client_addr"` -} - -func GetNodeObservability(id uint, query NodeObservabilityQuery) (*NodeObservabilityView, error) { - now := time.Now() - node, err := model.GetNodeByID(id) - if err != nil { - return nil, err - } - - limit := normalizeObservabilityLimit(query.Limit) - since := now.Add(-normalizeObservabilityWindow(query.Hours)) - - profile, err := model.GetNodeSystemProfile(node.NodeID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - if errors.Is(err, gorm.ErrRecordNotFound) { - profile = nil - } - - snapshots, err := model.ListNodeMetricSnapshots(node.NodeID, since, limit) - if err != nil { - return nil, err - } - reports, err := model.ListNodeRequestReports(node.NodeID, since, limit) - if err != nil { - return nil, err - } - accessLogRegions, err := model.ListNodeAccessLogRegionCounts(node.NodeID, since, 8) - if err != nil { - return nil, err - } - trendSnapshots, err := model.ListNodeMetricSnapshots(node.NodeID, now.Add(-24*time.Hour), 0) - if err != nil { - return nil, err - } - trendOpenresty, _ := model.ListNodeObservationOpenresty(node.NodeID, now.Add(-24*time.Hour), 0) - trendReports, err := model.ListNodeRequestReports(node.NodeID, now.Add(-24*time.Hour), 0) - if err != nil { - return nil, err - } - events, err := model.ListNodeHealthEvents(node.NodeID, false, limit) - if err != nil { - return nil, err - } - - view := &NodeObservabilityView{ - NodeID: node.NodeID, - Profile: profile, - MetricSnapshots: snapshots, - TrafficReports: reports, - HealthEvents: events, - Analytics: NodeObservabilityAnalytics{ - Traffic: buildTrafficWindowSummary(latestTrafficReport(reports)), - Distributions: buildTrafficDistributions(reports, accessLogRegions, 8), - Health: buildObservabilityHealthSummary(latestMetricSnapshot(snapshots), latestTrafficReport(reports), events), - }, - Trends: NodeObservabilityTrends{ - Traffic24h: buildTrafficTrendPoints(now, trendReports), - Capacity24h: buildCapacityTrendPoints(now, trendSnapshots), - Network24h: buildNetworkTrendPoints(now, trendSnapshots, trendOpenresty), - DiskIO24h: buildDiskIOTrendPoints(now, trendSnapshots), - }, - } - if node.NodeType == "tunnel_relay" { - frpsObs, _ := model.ListNodeObservationFrps(node.NodeID, time.Time{}, 1) - var latestFrps *model.NodeObservationFrps - if len(frpsObs) > 0 { - latestFrps = frpsObs[0] - } - view.RelayDashboard = buildRelayDashboardSnapshot(node, latestFrps) - } - return view, nil -} - -func buildRelayDashboardSnapshot(node *model.Node, obs *model.NodeObservationFrps) *RelayDashboardSnapshot { - if node == nil { - return nil - } - totalProxies := 0 - totalConnections := 0 - clientCounts := 0 - proxies := []RelayProxyStat{} - - if obs != nil { - totalProxies = obs.FrpsProxyCount - totalConnections = obs.FrpsConnections - clientCounts = obs.FrpsClientCount - if obs.FrpsProxies != "" { - var decoded []RelayProxyStat - if err := json.Unmarshal([]byte(obs.FrpsProxies), &decoded); err == nil { - proxies = decoded - } - } - } - if totalProxies < 0 { - totalProxies = 0 - } - onlineProxies := 0 - for _, p := range proxies { - if p.Status == "online" { - onlineProxies++ - } - } - // Fallback for backward compatibility - if len(proxies) == 0 { - onlineProxies = totalProxies - if node.RelayStatus != "healthy" { - onlineProxies = 0 - } - } - - return &RelayDashboardSnapshot{ - TotalProxies: totalProxies, - OnlineProxies: onlineProxies, - OfflineProxies: totalProxies - onlineProxies, - Proxies: proxies, - TotalConnections: maxInt(totalConnections, 0), - ClientCounts: maxInt(clientCounts, 0), - } -} - -func maxInt(a int, b int) int { - if a > b { - return a - } - return b -} - -func CleanupNodeHealthEvents(id uint) (*NodeHealthEventCleanupResult, error) { - node, err := model.GetNodeByID(id) - if err != nil { - return nil, err - } - deletedCount, err := model.DeleteNodeHealthEvents(node.NodeID) - if err != nil { - return nil, err - } - return &NodeHealthEventCleanupResult{ - NodeID: node.NodeID, - DeletedCount: deletedCount, - }, nil -} - -func latestMetricSnapshot(snapshots []*model.NodeMetricSnapshot) *model.NodeMetricSnapshot { - for _, snapshot := range snapshots { - if snapshot != nil { - return snapshot - } - } - return nil -} - -func latestTrafficReport(reports []*model.NodeRequestReport) *model.NodeRequestReport { - for _, report := range reports { - if report != nil { - return report - } - } - return nil -} - -func normalizeObservabilityLimit(limit int) int { - if limit <= 0 { - return defaultObservabilityLimit - } - if limit > maxObservabilityLimit { - return maxObservabilityLimit - } - return limit -} - -func normalizeObservabilityWindow(hours int) time.Duration { - if hours <= 0 { - return defaultObservabilityWindow - } - return time.Duration(hours) * time.Hour -} diff --git a/openflare-server/internal/service/node_update_test.go b/openflare-server/internal/service/node_update_test.go deleted file mode 100644 index 98d64f20..00000000 --- a/openflare-server/internal/service/node_update_test.go +++ /dev/null @@ -1,1806 +0,0 @@ -package service - -import ( - "encoding/json" - "io" - "net" - "net/http" - "sort" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/pkg/geoip" -) - -type roundTripFunc func(req *http.Request) (*http.Response, error) - -type fakeGeoIPProvider struct { - info *geoip.GeoInfo -} - -type fakeAccessLogGeoProvider struct { - info *geoip.GeoInfo -} - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func (f *fakeGeoIPProvider) Name() string { - return "fake-geoip" -} - -func (f *fakeGeoIPProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) { - return f.info, nil -} - -func (f *fakeGeoIPProvider) UpdateDatabase() error { - return nil -} - -func (f *fakeGeoIPProvider) Close() error { - return nil -} - -func (f *fakeAccessLogGeoProvider) Name() string { - return "fake-access-log-geoip" -} - -func (f *fakeAccessLogGeoProvider) GetGeoInfo(ip net.IP) (*geoip.GeoInfo, error) { - return f.info, nil -} - -func (f *fakeAccessLogGeoProvider) UpdateDatabase() error { - return nil -} - -func (f *fakeAccessLogGeoProvider) Close() error { - return nil -} - -func withFakeGeoIPProvider(t *testing.T, info *geoip.GeoInfo) { - t.Helper() - previous := geoip.CurrentProvider - geoip.CurrentProvider = &fakeGeoIPProvider{info: info} - t.Cleanup(func() { - geoip.CurrentProvider = previous - }) -} - -func withFakeAccessLogGeoProvider(t *testing.T, info *geoip.GeoInfo) { - t.Helper() - previous := accessLogGeoProviderFactory - accessLogGeoProviderFactory = func() (geoip.GeoIPService, error) { - return &fakeAccessLogGeoProvider{info: info}, nil - } - t.Cleanup(func() { - accessLogGeoProviderFactory = previous - }) -} - -func geoipFloat(value float64) *float64 { - return &value -} - -func boolValue(value bool) *bool { - return &value -} - -func TestRequestNodeAgentPreviewUpdate(t *testing.T) { - setupServiceTestDB(t) - - latitude := 31.2304 - longitude := 121.4737 - node, err := CreateNode(NodeInput{ - Name: "preview-edge-1", - GeoManualOverride: true, - GeoName: "Shanghai", - GeoLatitude: &latitude, - GeoLongitude: &longitude, - }) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - if node.GeoName != "Shanghai" || node.GeoLatitude == nil || node.GeoLongitude == nil { - t.Fatalf("expected geo metadata to be returned, got %+v", node) - } - - originalClient := UpdateHTTPClientForTest() - SetUpdateHTTPClientForTest(&http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "https://api.github.com/repos/"+common.AgentUpdateRepo+"/releases/tags/v0.5.0-rc.1" { - t.Fatalf("unexpected request url: %s", req.URL.String()) - } - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.5.0-rc.1","prerelease":true}`)), - }, nil - }), - }) - t.Cleanup(func() { - SetUpdateHTTPClientForTest(originalClient) - }) - - wsClient := RegisterAgentWSClient(node.NodeID) - defer UnregisterAgentWSClient(wsClient) - - updated, err := RequestNodeAgentUpdate(node.ID, NodeAgentUpdateInput{ - Channel: "preview", - TagName: "v0.5.0-rc.1", - }) - if err != nil { - t.Fatalf("expected preview update request to succeed: %v", err) - } - if !updated.UpdateRequested { - t.Fatal("expected update_requested to be true") - } - if updated.UpdateChannel != "preview" { - t.Fatalf("unexpected update channel: %s", updated.UpdateChannel) - } - if updated.UpdateTag != "v0.5.0-rc.1" { - t.Fatalf("unexpected update tag: %s", updated.UpdateTag) - } - select { - case message := <-wsClient.Messages(): - if message.Type != AgentWSMessageTypeSettings { - t.Fatalf("expected settings message, got %s", message.Type) - } - settings, ok := message.Payload.(*AgentSettings) - if !ok { - t.Fatalf("expected agent settings payload, got %T", message.Payload) - } - if !settings.UpdateNow || settings.UpdateChannel != "preview" || settings.UpdateTag != "v0.5.0-rc.1" { - t.Fatalf("unexpected pushed settings: %+v", settings) - } - case <-time.After(time.Second): - t.Fatal("expected websocket settings push for manual update") - } -} - -func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-preview-1", - Name: "preview-edge-1", - IP: "10.0.0.8", - AccessToken: "agent-token", - Version: "v0.4.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - UpdateRequested: true, - UpdateChannel: "preview", - UpdateTag: "v0.5.0-rc.1", - RestartOpenrestyRequested: true, - AutoUpdateEnabled: false, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - if err := model.DB.Create(&model.ConfigVersion{ - Version: "20260313-001", - SnapshotJSON: "{}", - MainConfig: "worker_processes auto;", - RenderedConfig: "server { listen 80; }", - Checksum: "checksum-active-1", - IsActive: true, - CreatedBy: "root", - }).Error; err != nil { - t.Fatalf("failed to seed active config version: %v", err) - } - - resp, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: node.IP, - Version: node.Version, - ExtVersion: node.ExtVersion, - OpenrestyStatus: OpenrestyStatusUnhealthy, - OpenrestyMessage: "port 80 already allocated", - }) - if err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - if resp.AgentSettings == nil { - t.Fatal("expected agent settings in heartbeat response") - } - if resp.ActiveConfig == nil { - t.Fatal("expected active config summary in heartbeat response") - } - if resp.ActiveConfig.Version == "" || resp.ActiveConfig.Checksum == "" { - t.Fatal("expected active config summary to include version and checksum") - } - if !resp.AgentSettings.UpdateNow { - t.Fatal("expected update_now to be true") - } - if resp.AgentSettings.UpdateChannel != "preview" { - t.Fatalf("unexpected update channel: %s", resp.AgentSettings.UpdateChannel) - } - if resp.AgentSettings.UpdateTag != "v0.5.0-rc.1" { - t.Fatalf("unexpected update tag: %s", resp.AgentSettings.UpdateTag) - } - if !resp.AgentSettings.RestartOpenrestyNow { - t.Fatal("expected restart_openresty_now to be true") - } - if resp.Node.OpenrestyStatus != OpenrestyStatusUnhealthy { - t.Fatalf("expected unhealthy openresty status, got %s", resp.Node.OpenrestyStatus) - } - if resp.Node.OpenrestyMessage != "port 80 already allocated" { - t.Fatalf("unexpected openresty message: %s", resp.Node.OpenrestyMessage) - } - - storedNode, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if storedNode.UpdateRequested { - t.Fatal("expected update_requested to be reset after heartbeat") - } - if storedNode.UpdateChannel != "stable" { - t.Fatalf("expected update channel to reset to stable, got %s", storedNode.UpdateChannel) - } - if storedNode.UpdateTag != "" { - t.Fatalf("expected update tag to be cleared, got %s", storedNode.UpdateTag) - } - if storedNode.RestartOpenrestyRequested { - t.Fatal("expected restart_openresty_requested to be reset after heartbeat") - } -} - -func TestUpdateNodeValidatesAndPersistsGeoMetadata(t *testing.T) { - setupServiceTestDB(t) - - node, err := CreateNode(NodeInput{Name: "geo-edge"}) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - - latitude := 37.7749 - longitude := -122.4194 - updated, err := UpdateNode(node.ID, NodeInput{ - Name: "geo-edge-updated", - AutoUpdateEnabled: true, - GeoManualOverride: true, - GeoName: "San Francisco", - GeoLatitude: &latitude, - GeoLongitude: &longitude, - }) - if err != nil { - t.Fatalf("expected node update to succeed: %v", err) - } - if updated.GeoName != "San Francisco" || updated.GeoLatitude == nil || updated.GeoLongitude == nil { - t.Fatalf("expected geo metadata in view, got %+v", updated) - } - - stored, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to load node: %v", err) - } - if stored.GeoName != "San Francisco" || stored.GeoLatitude == nil || stored.GeoLongitude == nil { - t.Fatalf("expected geo metadata persisted, got %+v", stored) - } -} - -func TestUpdateNodeRejectsPartialGeoMetadata(t *testing.T) { - setupServiceTestDB(t) - - node, err := CreateNode(NodeInput{Name: "geo-edge-invalid"}) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - - latitude := 37.7749 - if _, err = UpdateNode(node.ID, NodeInput{ - Name: "geo-edge-invalid", - GeoManualOverride: true, - GeoLatitude: &latitude, - }); err == nil { - t.Fatal("expected partial geo metadata to be rejected") - } -} - -func TestUpdateNodeRejectsInvalidIP(t *testing.T) { - setupServiceTestDB(t) - - node, err := CreateNode(NodeInput{Name: "geo-edge-invalid-ip"}) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - - if _, err = UpdateNode(node.ID, NodeInput{ - Name: "geo-edge-invalid-ip", - IP: "not-an-ip", - }); err == nil { - t.Fatal("expected invalid IP to be rejected") - } -} - -func TestUpdateNodeCanChangeIPAndAutoResolveGeo(t *testing.T) { - setupServiceTestDB(t) - withFakeGeoIPProvider(t, &geoip.GeoInfo{ - ISOCode: "US", - Name: "United States", - Latitude: geoipFloat(37.7749), - Longitude: geoipFloat(-122.4194), - }) - - node, err := CreateNode(NodeInput{Name: "geo-edge-auto-ip"}) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - - updated, err := UpdateNode(node.ID, NodeInput{ - Name: "geo-edge-auto-ip", - IP: "8.8.8.8", - }) - if err != nil { - t.Fatalf("expected node update to succeed: %v", err) - } - if updated.IP != "8.8.8.8" { - t.Fatalf("expected updated IP to be persisted, got %+v", updated.IP) - } - if updated.GeoName != "United States" { - t.Fatalf("expected geo name to be auto resolved, got %+v", updated) - } - if updated.GeoLatitude == nil || updated.GeoLongitude == nil { - t.Fatalf("expected geo coordinates to be auto resolved, got %+v", updated) - } -} - -func TestResolveReportedNodeIPPrefersPublicRemoteAddr(t *testing.T) { - resolved := ResolveReportedNodeIP("10.0.0.8", "198.51.100.20:9000") - if resolved != "198.51.100.20" { - t.Fatalf("expected public remote ip to override private reported ip, got %q", resolved) - } -} - -func TestResolveReportedNodeIPKeepsPublicReportedAddr(t *testing.T) { - resolved := ResolveReportedNodeIP("8.8.8.8", "198.51.100.20:9000") - if resolved != "8.8.8.8" { - t.Fatalf("expected reported public ip to be preserved, got %q", resolved) - } -} - -func TestResolveReportedNodeIPKeepsPrivateReportedAddrWhenRemoteIsPrivate(t *testing.T) { - resolved := ResolveReportedNodeIP("10.0.0.8", "172.16.1.10:9000") - if resolved != "10.0.0.8" { - t.Fatalf("expected private reported ip to be preserved when remote is also private, got %q", resolved) - } -} - -func TestHeartbeatNodeResolvesGeoMetadataFromIPWhenNotManuallyOverridden(t *testing.T) { - setupServiceTestDB(t) - withFakeGeoIPProvider(t, &geoip.GeoInfo{ - ISOCode: "US", - Name: "United States", - Latitude: geoipFloat(37.7749), - Longitude: geoipFloat(-122.4194), - }) - - node := &model.Node{ - NodeID: "node-geo-auto", - Name: "geo-auto", - IP: "10.0.0.8", - AccessToken: "agent-token", - Version: "v0.4.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - resp, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: "8.8.8.8", - Version: node.Version, - ExtVersion: node.ExtVersion, - }) - if err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - if resp.Node.GeoName != "United States" { - t.Fatalf("expected auto geo name, got %+v", resp.Node) - } - if resp.Node.GeoLatitude == nil || resp.Node.GeoLongitude == nil { - t.Fatalf("expected auto geo coordinates, got %+v", resp.Node) - } -} - -func TestHeartbeatNodePreservesManualGeoOverride(t *testing.T) { - setupServiceTestDB(t) - withFakeGeoIPProvider(t, &geoip.GeoInfo{ - ISOCode: "US", - Name: "United States", - Latitude: geoipFloat(37.7749), - Longitude: geoipFloat(-122.4194), - }) - - latitude := 31.2304 - longitude := 121.4737 - node := &model.Node{ - NodeID: "node-geo-manual", - Name: "geo-manual", - IP: "10.0.0.8", - GeoName: "Shanghai", - GeoLatitude: &latitude, - GeoLongitude: &longitude, - GeoManualOverride: true, - AccessToken: "agent-token", - Version: "v0.4.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - resp, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: "8.8.8.8", - Version: node.Version, - ExtVersion: node.ExtVersion, - }) - if err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - if resp.Node.GeoName != "Shanghai" { - t.Fatalf("expected manual geo name to be preserved, got %+v", resp.Node) - } - if resp.Node.GeoLatitude == nil || *resp.Node.GeoLatitude != latitude { - t.Fatalf("expected manual latitude to be preserved, got %+v", resp.Node.GeoLatitude) - } -} - -func TestHeartbeatNodePreservesManualIPOverride(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-ip-manual", - Name: "ip-manual", - IP: "203.0.113.10", - IPManualOverride: true, - AccessToken: "agent-token", - Version: "v0.4.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - resp, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: "10.0.0.8", - Version: "v0.5.0", - ExtVersion: "1.27.1.3", - OpenrestyStatus: OpenrestyStatusHealthy, - }) - if err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - if resp.Node.IP != "203.0.113.10" { - t.Fatalf("expected manual ip to be preserved, got %s", resp.Node.IP) - } - if resp.Node.Version != "v0.5.0" || resp.Node.ExtVersion != "1.27.1.3" { - t.Fatalf("expected runtime metadata to update despite locked ip, got %+v", resp.Node) - } - - stored, err := model.GetNodeByNodeID(node.NodeID) - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if stored.IP != "203.0.113.10" { - t.Fatalf("expected stored manual ip to be preserved, got %s", stored.IP) - } -} - -func TestHeartbeatNodeUpdatesIPWhenManualOverrideDisabled(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-ip-auto", - Name: "ip-auto", - IP: "10.0.0.8", - AccessToken: "agent-token", - Version: "v0.4.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - resp, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: "8.8.8.8", - Version: node.Version, - ExtVersion: node.ExtVersion, - }) - if err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - if resp.Node.IP != "8.8.8.8" { - t.Fatalf("expected heartbeat ip to update unlocked node, got %s", resp.Node.IP) - } -} - -func TestUpdateNodeCanLockAndUnlockManualIP(t *testing.T) { - setupServiceTestDB(t) - - node, err := CreateNode(NodeInput{Name: "manual-ip-edge"}) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - - locked, err := UpdateNode(node.ID, NodeInput{ - Name: "manual-ip-edge", - IP: "203.0.113.10", - IPManualOverride: boolValue(true), - }) - if err != nil { - t.Fatalf("expected lock update to succeed: %v", err) - } - if !locked.IPManualOverride || locked.IP != "203.0.113.10" { - t.Fatalf("expected node ip to be locked, got %+v", locked) - } - - stored, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if _, err = HeartbeatNode(stored, AgentNodePayload{ - NodeID: stored.NodeID, - Name: stored.Name, - IP: "8.8.8.8", - Version: "v0.5.0", - ExtVersion: "1.27.1.3", - }); err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - - lockedStored, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to reload locked node: %v", err) - } - if lockedStored.IP != "203.0.113.10" { - t.Fatalf("expected heartbeat to preserve locked ip, got %s", lockedStored.IP) - } - - unlocked, err := UpdateNode(node.ID, NodeInput{ - Name: "manual-ip-edge", - IP: "203.0.113.10", - IPManualOverride: boolValue(false), - }) - if err != nil { - t.Fatalf("expected unlock update to succeed: %v", err) - } - if unlocked.IPManualOverride { - t.Fatalf("expected node ip lock to be disabled, got %+v", unlocked) - } - - unlockedStored, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to reload unlocked node: %v", err) - } - if _, err = HeartbeatNode(unlockedStored, AgentNodePayload{ - NodeID: unlockedStored.NodeID, - Name: unlockedStored.Name, - IP: "8.8.4.4", - Version: "v0.5.1", - ExtVersion: "1.27.1.4", - }); err != nil { - t.Fatalf("expected heartbeat to succeed after unlock: %v", err) - } - - reloaded, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to reload updated node: %v", err) - } - if reloaded.IP != "8.8.4.4" { - t.Fatalf("expected heartbeat to update unlocked ip, got %s", reloaded.IP) - } -} - -func TestNodeManualIPOverrideDefaultsForManualInput(t *testing.T) { - setupServiceTestDB(t) - - created, err := CreateNode(NodeInput{ - Name: "precreated-edge", - IP: "203.0.113.20", - }) - if err != nil { - t.Fatalf("expected create to succeed: %v", err) - } - if !created.IPManualOverride { - t.Fatalf("expected precreated node with explicit ip to default to manual override, got %+v", created) - } - - empty, err := CreateNode(NodeInput{Name: "auto-edge"}) - if err != nil { - t.Fatalf("expected empty-ip create to succeed: %v", err) - } - if empty.IPManualOverride { - t.Fatalf("expected empty-ip node to stay unlocked, got %+v", empty) - } - - updated, err := UpdateNode(empty.ID, NodeInput{ - Name: "auto-edge", - IP: "203.0.113.21", - }) - if err != nil { - t.Fatalf("expected update to succeed: %v", err) - } - if !updated.IPManualOverride { - t.Fatalf("expected manual ip edit to default to locked, got %+v", updated) - } -} - -func TestRequestNodeOpenrestyRestart(t *testing.T) { - setupServiceTestDB(t) - - node, err := CreateNode(NodeInput{Name: "restart-edge-1"}) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - - updated, err := RequestNodeOpenrestyRestart(node.ID) - if err != nil { - t.Fatalf("expected openresty restart request to succeed: %v", err) - } - if !updated.RestartOpenrestyRequested { - t.Fatal("expected restart_openresty_requested to be true") - } -} - -func TestListNodeViewsIncludesLatestApplyLogsForMultipleNodes(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now() - nodes := []*model.Node{ - { - NodeID: "node-a", - Name: "edge-a", - IP: "10.0.0.11", - GeoName: "Shanghai", - AccessToken: "token-a", - Version: "v0.5.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - LastSeenAt: now, - }, - { - NodeID: "node-b", - Name: "edge-b", - IP: "10.0.0.12", - AccessToken: "token-b", - Version: "v0.5.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - LastSeenAt: now, - }, - } - for _, node := range nodes { - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node %s: %v", node.NodeID, err) - } - } - - logs := []*model.ApplyLog{ - {NodeID: "node-a", Version: "20260313-001", Result: ApplyResultOK, Message: "first success", CreatedAt: now.Add(-2 * time.Minute)}, - {NodeID: "node-a", Version: "20260313-002", Result: ApplyResultFailed, Message: "latest failure", CreatedAt: now.Add(-1 * time.Minute)}, - {NodeID: "node-b", Version: "20260313-003", Result: ApplyResultOK, Message: "latest success", CreatedAt: now}, - } - for _, log := range logs { - if err := model.DB.Create(log).Error; err != nil { - t.Fatalf("failed to insert apply log for %s: %v", log.NodeID, err) - } - } - - views, err := ListNodeViews() - if err != nil { - t.Fatalf("ListNodeViews failed: %v", err) - } - if len(views) != 2 { - t.Fatalf("expected 2 node views, got %d", len(views)) - } - - sort.Slice(views, func(i int, j int) bool { - return views[i].NodeID < views[j].NodeID - }) - - if views[0].NodeID != "node-a" || views[0].LatestApplyResult != ApplyResultFailed || views[0].LatestApplyMessage != "latest failure" { - t.Fatalf("unexpected latest apply log for node-a: %+v", views[0]) - } - if views[0].GeoName != "Shanghai" { - t.Fatalf("expected geo name to be exposed on node view, got %+v", views[0]) - } - if views[1].NodeID != "node-b" || views[1].LatestApplyResult != ApplyResultOK || views[1].LatestApplyMessage != "latest success" { - t.Fatalf("unexpected latest apply log for node-b: %+v", views[1]) - } -} - -func TestCollectNodeHeartbeatChangesOnlyReturnsChangedFields(t *testing.T) { - now := time.Now() - before := &model.Node{ - Name: "edge-1", - IP: "10.0.0.8", - Version: "v0.5.0", - ExtVersion: "1.27.1.2", - OpenrestyStatus: OpenrestyStatusHealthy, - OpenrestyMessage: "", - Status: NodeStatusOnline, - CurrentVersion: "20260313-001", - LastSeenAt: now.Add(-time.Minute), - LastError: "", - UpdateRequested: true, - UpdateChannel: "preview", - UpdateTag: "v0.5.0-rc.1", - RestartOpenrestyRequested: true, - } - after := &model.Node{ - Name: "edge-1", - IP: "10.0.0.8", - Version: "v0.5.0", - ExtVersion: "1.27.1.2", - OpenrestyStatus: OpenrestyStatusHealthy, - OpenrestyMessage: "", - Status: NodeStatusOnline, - CurrentVersion: "20260313-001", - LastSeenAt: now, - LastError: "", - UpdateRequested: false, - UpdateChannel: "stable", - UpdateTag: "", - RestartOpenrestyRequested: false, - } - - changes := collectNodeHeartbeatChanges(before, after) - if len(changes) != 5 { - t.Fatalf("expected 5 changed fields, got %d: %#v", len(changes), changes) - } - if _, ok := changes["last_seen_at"]; !ok { - t.Fatal("expected last_seen_at change to be included") - } - if value, ok := changes["update_requested"]; !ok || value != false { - t.Fatalf("expected update_requested reset, got %#v", value) - } - if value, ok := changes["update_channel"]; !ok || value != "stable" { - t.Fatalf("expected update_channel reset, got %#v", value) - } - if value, ok := changes["update_tag"]; !ok || value != "" { - t.Fatalf("expected update_tag reset, got %#v", value) - } - if value, ok := changes["restart_openresty_requested"]; !ok || value != false { - t.Fatalf("expected restart_openresty_requested reset, got %#v", value) - } - if _, ok := changes["ip"]; ok { - t.Fatal("did not expect unchanged ip to be included") - } -} - -func TestListNodeViewsDoesNotPersistComputedStatus(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-offline-view", - Name: "edge-offline", - IP: "10.0.0.21", - AccessToken: "token-offline", - Version: "v0.5.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - LastSeenAt: time.Now().Add(-common.NodeOfflineThreshold - time.Minute), - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - - views, err := ListNodeViews() - if err != nil { - t.Fatalf("ListNodeViews failed: %v", err) - } - if len(views) != 1 { - t.Fatalf("expected 1 node view, got %d", len(views)) - } - if views[0].Status != NodeStatusOffline { - t.Fatalf("expected computed offline status in view, got %s", views[0].Status) - } - - storedNode, err := model.GetNodeByID(node.ID) - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if storedNode.Status != NodeStatusOnline { - t.Fatalf("expected list query to avoid persisting computed status, got %s", storedNode.Status) - } -} - -func TestAgentWSConnectionMarksNodeViewOnline(t *testing.T) { - node := &model.Node{ - ID: 99, - NodeID: "node-ws-view", - Name: "edge-ws", - IP: "10.0.0.9", - Status: NodeStatusOffline, - LastSeenAt: time.Now().Add(-common.NodeOfflineThreshold - time.Minute), - } - client := RegisterAgentWSClient(node.NodeID) - defer UnregisterAgentWSClient(client) - - view := buildNodeView(node) - if view.Status != NodeStatusOnline { - t.Fatalf("expected websocket-connected node to be online, got %s", view.Status) - } - if view.LastSeenAt != AgentWSConnectedLastSeenValue { - t.Fatalf("expected websocket special last_seen_at, got %#v", view.LastSeenAt) - } - - result := BroadcastAgentWSActiveConfig(&ActiveConfigMeta{ - Version: "20260529-001", - Checksum: "checksum-ws", - }) - if result.ClientCount != 1 || result.SuccessCount != 1 || len(result.FailedNodes) != 0 { - t.Fatalf("unexpected broadcast result: %+v", result) - } - select { - case message := <-client.Messages(): - if message.Type != AgentWSMessageTypeActiveConfig { - t.Fatalf("unexpected websocket message type: %s", message.Type) - } - case <-time.After(time.Second): - t.Fatal("expected websocket broadcast message") - } -} - -func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) { - setupServiceTestDB(t) - withFakeAccessLogGeoProvider(t, &geoip.GeoInfo{ - ISOCode: "US", - Name: "United States", - }) - - node := &model.Node{ - NodeID: "node-observe-1", - Name: "observe-edge-1", - IP: "10.0.0.31", - AccessToken: "token-observe", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - _, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: node.IP, - Version: node.Version, - ExtVersion: node.ExtVersion, - Profile: &AgentNodeSystemProfile{ - Hostname: "observe-edge-1", - OSName: "Ubuntu", - OSVersion: "24.04", - KernelVersion: "6.8.0", - Architecture: "amd64", - CPUModel: "Intel Xeon", - CPUCores: 8, - TotalMemoryBytes: 16 * 1024 * 1024 * 1024, - TotalDiskBytes: 200 * 1024 * 1024 * 1024, - UptimeSeconds: 3600, - ReportedAtUnix: time.Now().Add(-time.Minute).Unix(), - }, - Snapshot: &AgentNodeMetricSnapshot{ - CapturedAtUnix: time.Now().Add(-30 * time.Second).Unix(), - CPUUsagePercent: 42.5, - MemoryUsedBytes: 8 * 1024 * 1024 * 1024, - MemoryTotalBytes: 16 * 1024 * 1024 * 1024, - StorageUsedBytes: 70 * 1024 * 1024 * 1024, - StorageTotalBytes: 200 * 1024 * 1024 * 1024, - DiskReadBytes: 1024, - DiskWriteBytes: 2048, - NetworkRxBytes: 4096, - NetworkTxBytes: 8192, - }, - TrafficReport: &AgentNodeTrafficReport{ - WindowStartedAtUnix: time.Now().Add(-time.Minute).Unix(), - WindowEndedAtUnix: time.Now().Unix(), - RequestCount: 1200, - ErrorCount: 12, - UniqueVisitorCount: 320, - StatusCodes: map[string]int64{"200": 1100, "502": 12}, - TopDomains: map[string]int64{"example.com": 900}, - SourceCountries: map[string]int64{"CN": 700, "US": 200}, - }, - AccessLogs: []AgentNodeAccessLog{ - { - LoggedAtUnix: time.Now().Add(-45 * time.Second).Unix(), - RemoteAddr: "203.0.113.10", - Host: "example.com", - Path: "/login", - StatusCode: 200, - }, - { - LoggedAtUnix: time.Now().Add(-40 * time.Second).Unix(), - RemoteAddr: "198.51.100.20", - Host: "api.example.com", - Path: "/v1/ping", - StatusCode: 502, - }, - }, - HealthEvents: []AgentNodeHealthEvent{ - { - EventType: "openresty_unhealthy", - Severity: NodeHealthSeverityCritical, - Message: "reload failed", - TriggeredAtUnix: time.Now().Add(-2 * time.Minute).Unix(), - Metadata: map[string]string{ - "source": "runtime", - }, - }, - }, - }) - if err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - - profile, err := model.GetNodeSystemProfile(node.NodeID) - if err != nil { - t.Fatalf("expected node profile to persist: %v", err) - } - if profile.OSName != "Ubuntu" || profile.CPUCores != 8 { - t.Fatalf("unexpected system profile: %+v", profile) - } - - snapshots, err := model.ListNodeMetricSnapshots(node.NodeID, time.Time{}, 10) - if err != nil { - t.Fatalf("expected node snapshots query to succeed: %v", err) - } - if len(snapshots) != 1 { - t.Fatalf("unexpected metric snapshots: %+v", snapshots) - } - - reports, err := model.ListNodeRequestReports(node.NodeID, time.Time{}, 10) - if err != nil { - t.Fatalf("expected node request reports query to succeed: %v", err) - } - if len(reports) != 1 || reports[0].RequestCount != 1200 { - t.Fatalf("unexpected request reports: %+v", reports) - } - - accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{ - NodeID: node.NodeID, - Since: time.Time{}, - Page: 0, - PageSize: 10, - }) - if err != nil { - t.Fatalf("expected node access logs query to succeed: %v", err) - } - if len(accessLogs) != 2 || accessLogs[0].Path == "" { - t.Fatalf("unexpected access logs: %+v", accessLogs) - } - if accessLogs[0].Region == "" || accessLogs[1].Region == "" { - t.Fatalf("expected access log region to persist: %+v", accessLogs) - } - - events, err := model.ListNodeHealthEvents(node.NodeID, true, 10) - if err != nil { - t.Fatalf("expected node health events query to succeed: %v", err) - } - if len(events) != 1 || events[0].EventType != "openresty_unhealthy" { - t.Fatalf("unexpected active health events: %+v", events) - } - if events[0].MetadataJSON == "" { - t.Fatal("expected metadata_json to persist") - } - var metadata map[string]string - if err := json.Unmarshal([]byte(events[0].MetadataJSON), &metadata); err != nil { - t.Fatalf("expected metadata_json to be valid json: %v", err) - } - if metadata["source"] != "runtime" { - t.Fatalf("unexpected metadata json: %+v", metadata) - } -} - -func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) { - setupServiceTestDB(t) - withFakeAccessLogGeoProvider(t, &geoip.GeoInfo{ - ISOCode: "CN", - Name: "China", - }) - - node := &model.Node{ - NodeID: "node-observe-buffered", - Name: "observe-buffered-edge", - IP: "10.0.0.32", - AccessToken: "token-observe-buffered", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - now := time.Now().UTC() - _, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: node.IP, - Version: node.Version, - ExtVersion: node.ExtVersion, - Snapshot: &AgentNodeMetricSnapshot{ - CapturedAtUnix: now.Unix(), - CPUUsagePercent: 25, - MemoryUsedBytes: 2 * 1024 * 1024 * 1024, - MemoryTotalBytes: 8 * 1024 * 1024 * 1024, - }, - TrafficReport: &AgentNodeTrafficReport{ - WindowStartedAtUnix: now.Add(-time.Minute).Unix(), - WindowEndedAtUnix: now.Unix(), - RequestCount: 20, - ErrorCount: 1, - UniqueVisitorCount: 10, - StatusCodes: map[string]int64{"200": 19, "500": 1}, - TopDomains: map[string]int64{"edge.example.com": 20}, - SourceCountries: map[string]int64{"CN": 12}, - }, - BufferedObservability: []AgentBufferedObservabilityRecord{ - { - WindowStartedAtUnix: now.Add(-2 * time.Minute).Unix(), - Snapshot: &AgentNodeMetricSnapshot{ - CapturedAtUnix: now.Add(-2 * time.Minute).Unix(), - CPUUsagePercent: 30, - MemoryUsedBytes: 3 * 1024 * 1024 * 1024, - MemoryTotalBytes: 8 * 1024 * 1024 * 1024, - }, - TrafficReport: &AgentNodeTrafficReport{ - WindowStartedAtUnix: now.Add(-2 * time.Minute).Unix(), - WindowEndedAtUnix: now.Add(-time.Minute).Unix(), - RequestCount: 40, - ErrorCount: 2, - UniqueVisitorCount: 18, - StatusCodes: map[string]int64{"200": 38, "500": 2}, - TopDomains: map[string]int64{"edge.example.com": 40}, - SourceCountries: map[string]int64{"CN": 20}, - }, - AccessLogs: []AgentNodeAccessLog{ - { - LoggedAtUnix: now.Add(-110 * time.Second).Unix(), - RemoteAddr: "203.0.113.21", - Host: "edge.example.com", - Path: "/buffered", - StatusCode: 200, - }, - }, - }, - }, - }) - if err != nil { - t.Fatalf("expected heartbeat to succeed: %v", err) - } - - snapshots, err := model.ListNodeMetricSnapshots(node.NodeID, time.Time{}, 10) - if err != nil { - t.Fatalf("expected node snapshots query to succeed: %v", err) - } - if len(snapshots) != 2 { - t.Fatalf("expected current and buffered snapshots, got %+v", snapshots) - } - - reports, err := model.ListNodeRequestReports(node.NodeID, time.Time{}, 10) - if err != nil { - t.Fatalf("expected node request reports query to succeed: %v", err) - } - if len(reports) != 2 { - t.Fatalf("expected current and buffered reports, got %+v", reports) - } - - accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{ - NodeID: node.NodeID, - Since: time.Time{}, - Page: 0, - PageSize: 10, - }) - if err != nil { - t.Fatalf("expected node access logs query to succeed: %v", err) - } - if len(accessLogs) != 1 || accessLogs[0].Path != "/buffered" { - t.Fatalf("expected buffered access logs to persist, got %+v", accessLogs) - } - if accessLogs[0].Region != "China" { - t.Fatalf("expected buffered access log region to persist, got %+v", accessLogs[0]) - } - - _, err = HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: node.IP, - Version: node.Version, - ExtVersion: node.ExtVersion, - BufferedObservability: []AgentBufferedObservabilityRecord{ - { - WindowStartedAtUnix: now.Add(-2 * time.Minute).Unix(), - Snapshot: &AgentNodeMetricSnapshot{ - CapturedAtUnix: now.Add(-2 * time.Minute).Unix(), - CPUUsagePercent: 30, - MemoryUsedBytes: 3 * 1024 * 1024 * 1024, - MemoryTotalBytes: 8 * 1024 * 1024 * 1024, - }, - TrafficReport: &AgentNodeTrafficReport{ - WindowStartedAtUnix: now.Add(-2 * time.Minute).Unix(), - WindowEndedAtUnix: now.Add(-time.Minute).Unix(), - RequestCount: 40, - ErrorCount: 2, - UniqueVisitorCount: 18, - StatusCodes: map[string]int64{"200": 38, "500": 2}, - TopDomains: map[string]int64{"edge.example.com": 40}, - SourceCountries: map[string]int64{"CN": 20}, - }, - AccessLogs: []AgentNodeAccessLog{ - { - LoggedAtUnix: now.Add(-110 * time.Second).Unix(), - RemoteAddr: "203.0.113.21", - Host: "edge.example.com", - Path: "/buffered", - StatusCode: 200, - }, - }, - }, - }, - }) - if err != nil { - t.Fatalf("expected heartbeat replay dedupe to succeed: %v", err) - } - - snapshots, err = model.ListNodeMetricSnapshots(node.NodeID, time.Time{}, 10) - if err != nil { - t.Fatalf("expected node snapshots query to succeed after replay: %v", err) - } - if len(snapshots) != 2 { - t.Fatalf("expected replay dedupe to keep snapshot count stable, got %+v", snapshots) - } - reports, err = model.ListNodeRequestReports(node.NodeID, time.Time{}, 10) - if err != nil { - t.Fatalf("expected node request reports query to succeed after replay: %v", err) - } - if len(reports) != 2 { - t.Fatalf("expected replay dedupe to keep report count stable, got %+v", reports) - } - accessLogs, err = model.ListNodeAccessLogs(model.NodeAccessLogQuery{ - NodeID: node.NodeID, - Since: time.Time{}, - Page: 0, - PageSize: 10, - }) - if err != nil { - t.Fatalf("expected node access logs query to succeed after replay: %v", err) - } - if len(accessLogs) != 1 { - t.Fatalf("expected replay dedupe to keep access log count stable, got %+v", accessLogs) - } -} - -func TestListAccessLogsUsesPagination(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-access-log-page", - Name: "access-log-edge", - IP: "10.0.0.40", - AccessToken: "token-access-log-page", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - now := time.Now().UTC() - for _, item := range []*model.NodeAccessLog{ - { - NodeID: node.NodeID, - LoggedAt: now.Add(-10 * time.Second), - RemoteAddr: "203.0.113.1", - Region: "United States", - Host: "example.com", - Path: "/one", - StatusCode: 200, - }, - { - NodeID: node.NodeID, - LoggedAt: now.Add(-9 * time.Second), - RemoteAddr: "203.0.113.2", - Region: "China", - Host: "example.com", - Path: "/two", - StatusCode: 200, - }, - { - NodeID: node.NodeID, - LoggedAt: now.Add(-8 * time.Second), - RemoteAddr: "203.0.113.3", - Region: "Japan", - Host: "example.com", - Path: "/three", - StatusCode: 502, - }, - } { - if err := model.DB.Create(item).Error; err != nil { - t.Fatalf("failed to seed access logs: %v", err) - } - } - - pageOne, err := ListAccessLogs(AccessLogQuery{ - NodeID: node.NodeID, - Page: 0, - PageSize: 2, - }) - if err != nil { - t.Fatalf("ListAccessLogs page 1 failed: %v", err) - } - if len(pageOne.Items) != 2 || !pageOne.HasMore { - t.Fatalf("unexpected first page: %+v", pageOne) - } - if pageOne.Items[0].Path != "/three" || pageOne.Items[1].Path != "/two" { - t.Fatalf("unexpected first page ordering: %+v", pageOne.Items) - } - if pageOne.Items[0].Region != "Japan" { - t.Fatalf("expected paged access log region to be returned, got %+v", pageOne.Items[0]) - } - - pageTwo, err := ListAccessLogs(AccessLogQuery{ - NodeID: node.NodeID, - Page: 1, - PageSize: 2, - }) - if err != nil { - t.Fatalf("ListAccessLogs page 2 failed: %v", err) - } - if len(pageTwo.Items) != 1 || pageTwo.HasMore { - t.Fatalf("unexpected second page: %+v", pageTwo) - } - if pageTwo.Items[0].Path != "/one" { - t.Fatalf("unexpected second page ordering: %+v", pageTwo.Items) - } -} - -func TestHeartbeatNodeResolvesMissingHealthEvents(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-event-1", - Name: "event-edge-1", - IP: "10.0.0.41", - AccessToken: "token-event", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed node: %v", err) - } - - _, err := HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: node.IP, - Version: node.Version, - ExtVersion: node.ExtVersion, - HealthEvents: []AgentNodeHealthEvent{ - { - EventType: "sync_error", - Severity: NodeHealthSeverityWarning, - Message: "checksum mismatch", - TriggeredAtUnix: time.Now().Add(-time.Minute).Unix(), - }, - }, - }) - if err != nil { - t.Fatalf("expected first heartbeat to succeed: %v", err) - } - - _, err = HeartbeatNode(node, AgentNodePayload{ - NodeID: node.NodeID, - Name: node.Name, - IP: node.IP, - Version: node.Version, - ExtVersion: node.ExtVersion, - HealthEvents: []AgentNodeHealthEvent{}, - }) - if err != nil { - t.Fatalf("expected second heartbeat to succeed: %v", err) - } - - activeEvents, err := model.ListNodeHealthEvents(node.NodeID, true, 10) - if err != nil { - t.Fatalf("expected active node health events query to succeed: %v", err) - } - if len(activeEvents) != 0 { - t.Fatalf("expected no active health events, got %+v", activeEvents) - } - - allEvents, err := model.ListNodeHealthEvents(node.NodeID, false, 10) - if err != nil { - t.Fatalf("expected all node health events query to succeed: %v", err) - } - if len(allEvents) != 1 || allEvents[0].Status != NodeHealthEventStatusResolved || allEvents[0].ResolvedAt == nil { - t.Fatalf("expected resolved health event record, got %+v", allEvents) - } -} - -func TestGetNodeObservability(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-observability-query", - Name: "query-edge", - IP: "10.0.0.61", - AccessToken: "token-query", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - if err := model.UpsertNodeSystemProfile(&model.NodeSystemProfile{ - NodeID: node.NodeID, - Hostname: "query-edge", - OSName: "Ubuntu", - Architecture: "amd64", - ReportedAt: time.Now(), - }); err != nil { - t.Fatalf("failed to insert node system profile: %v", err) - } - if err := (&model.NodeMetricSnapshot{ - NodeID: node.NodeID, - CapturedAt: time.Now().Add(-time.Hour), - CPUUsagePercent: 60, - MemoryUsedBytes: 14 * 1024 * 1024 * 1024, - MemoryTotalBytes: 16 * 1024 * 1024 * 1024, - StorageUsedBytes: 90 * 1024 * 1024 * 1024, - StorageTotalBytes: 100 * 1024 * 1024 * 1024, - DiskReadBytes: 0, - DiskWriteBytes: 0, - NetworkRxBytes: 2048, - NetworkTxBytes: 4096, - }).Insert(); err != nil { - t.Fatalf("failed to insert node metric baseline snapshot: %v", err) - } - if err := (&model.NodeMetricSnapshot{ - NodeID: node.NodeID, - CapturedAt: time.Now(), - CPUUsagePercent: 81, - MemoryUsedBytes: 15 * 1024 * 1024 * 1024, - MemoryTotalBytes: 16 * 1024 * 1024 * 1024, - StorageUsedBytes: 92 * 1024 * 1024 * 1024, - StorageTotalBytes: 100 * 1024 * 1024 * 1024, - DiskReadBytes: 1024, - DiskWriteBytes: 2048, - NetworkRxBytes: 4096, - NetworkTxBytes: 8192, - }).Insert(); err != nil { - t.Fatalf("failed to insert node metric snapshot: %v", err) - } - if err := (&model.NodeRequestReport{ - NodeID: node.NodeID, - WindowStartedAt: time.Now().Add(-time.Minute), - WindowEndedAt: time.Now(), - RequestCount: 123, - ErrorCount: 9, - UniqueVisitorCount: 87, - StatusCodesJSON: `{"200":114,"502":9}`, - TopDomainsJSON: `{"example.com":80,"api.example.com":43}`, - SourceCountriesJSON: `{"CN":90,"US":33}`, - }).Insert(); err != nil { - t.Fatalf("failed to insert node request report: %v", err) - } - if err := model.DB.Create(&model.NodeHealthEvent{ - NodeID: node.NodeID, - EventType: "sync_error", - Severity: NodeHealthSeverityWarning, - Status: NodeHealthEventStatusActive, - Message: "checksum mismatch", - FirstTriggeredAt: time.Now().Add(-time.Minute), - LastTriggeredAt: time.Now(), - ReportedAt: time.Now(), - }).Error; err != nil { - t.Fatalf("failed to insert node health event: %v", err) - } - - view, err := GetNodeObservability(node.ID, NodeObservabilityQuery{Hours: 24, Limit: 10}) - if err != nil { - t.Fatalf("GetNodeObservability failed: %v", err) - } - if view.NodeID != node.NodeID { - t.Fatalf("unexpected node id: %s", view.NodeID) - } - if view.Profile == nil || view.Profile.OSName != "Ubuntu" { - t.Fatalf("unexpected profile: %+v", view.Profile) - } - if len(view.MetricSnapshots) != 2 { - t.Fatalf("expected 2 metric snapshots, got %d", len(view.MetricSnapshots)) - } - if view.MetricSnapshots[0].DiskWriteBytes != 2048 { - t.Fatalf("expected latest metric snapshot to stay intact, got %+v", view.MetricSnapshots[0]) - } - if len(view.TrafficReports) != 1 || view.TrafficReports[0].RequestCount != 123 { - t.Fatalf("unexpected traffic reports: %+v", view.TrafficReports) - } - if len(view.HealthEvents) != 1 || view.HealthEvents[0].EventType != "sync_error" { - t.Fatalf("unexpected health events: %+v", view.HealthEvents) - } - if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 || len(view.Trends.Network24h) != 24 || len(view.Trends.DiskIO24h) != 24 { - t.Fatalf("expected 24-point trends, got %+v", view.Trends) - } - if view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1].RequestCount != 123 { - t.Fatalf("unexpected traffic trend tail: %+v", view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1]) - } - if view.Trends.Network24h[len(view.Trends.Network24h)-1].NetworkTxBytes != 8192 { - t.Fatalf("unexpected network trend tail: %+v", view.Trends.Network24h[len(view.Trends.Network24h)-1]) - } - if view.Trends.DiskIO24h[len(view.Trends.DiskIO24h)-1].DiskWriteBytes != 2048 { - t.Fatalf("unexpected disk io trend tail: %+v", view.Trends.DiskIO24h[len(view.Trends.DiskIO24h)-1]) - } - if view.Analytics.Traffic.RequestCount != 123 || view.Analytics.Traffic.ErrorRatePercent <= 7 { - t.Fatalf("unexpected traffic analytics: %+v", view.Analytics.Traffic) - } - if len(view.Analytics.Distributions.StatusCodes) != 2 || view.Analytics.Distributions.StatusCodes[0].Key != "200" { - t.Fatalf("unexpected traffic distributions: %+v", view.Analytics.Distributions) - } - if len(view.Analytics.Distributions.SourceCountries) != 2 || view.Analytics.Distributions.SourceCountries[0].Key != "CN" { - t.Fatalf("unexpected source countries: %+v", view.Analytics.Distributions.SourceCountries) - } - if !view.Analytics.Health.HasCapacityRisk || !view.Analytics.Health.HasTrafficRisk || !view.Analytics.Health.HasRuntimeRisk { - t.Fatalf("unexpected health analytics: %+v", view.Analytics.Health) - } -} - -func TestGetNodeObservabilityAllowsMissingProfile(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-observability-empty", - Name: "empty-edge", - IP: "10.0.0.62", - AccessToken: "token-empty", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - - view, err := GetNodeObservability(node.ID, NodeObservabilityQuery{}) - if err != nil { - t.Fatalf("GetNodeObservability failed: %v", err) - } - if view.Profile != nil { - t.Fatalf("expected nil profile when profile not reported, got %+v", view.Profile) - } - if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 || len(view.Trends.Network24h) != 24 || len(view.Trends.DiskIO24h) != 24 { - t.Fatalf("expected empty 24-point trends, got %+v", view.Trends) - } - if view.Analytics.Traffic.RequestCount != 0 || len(view.Analytics.Distributions.StatusCodes) != 0 { - t.Fatalf("expected empty analytics, got %+v", view.Analytics) - } -} - -func TestCleanupNodeHealthEvents(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-health-cleanup", - Name: "health-cleanup-edge", - IP: "10.0.0.72", - AccessToken: "token-health-cleanup", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - Status: NodeStatusOnline, - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node: %v", err) - } - - resolvedAt := time.Now().Add(-4 * time.Minute) - if err := model.DB.Create(&model.NodeHealthEvent{ - NodeID: node.NodeID, - EventType: "sync_error", - Severity: NodeHealthSeverityWarning, - Status: NodeHealthEventStatusActive, - Message: "checksum mismatch", - FirstTriggeredAt: time.Now().Add(-2 * time.Minute), - LastTriggeredAt: time.Now().Add(-time.Minute), - ReportedAt: time.Now().Add(-time.Minute), - }).Error; err != nil { - t.Fatalf("failed to insert first node health event: %v", err) - } - if err := model.DB.Create(&model.NodeHealthEvent{ - NodeID: node.NodeID, - EventType: "openresty_down", - Severity: NodeHealthSeverityCritical, - Status: NodeHealthEventStatusResolved, - Message: "openresty exited unexpectedly", - FirstTriggeredAt: time.Now().Add(-10 * time.Minute), - LastTriggeredAt: time.Now().Add(-5 * time.Minute), - ReportedAt: time.Now().Add(-5 * time.Minute), - ResolvedAt: &resolvedAt, - }).Error; err != nil { - t.Fatalf("failed to insert second node health event: %v", err) - } - - result, err := CleanupNodeHealthEvents(node.ID) - if err != nil { - t.Fatalf("CleanupNodeHealthEvents failed: %v", err) - } - if result.NodeID != node.NodeID || result.DeletedCount != 2 { - t.Fatalf("unexpected cleanup result: %+v", result) - } - - events, err := model.ListNodeHealthEvents(node.NodeID, false, 10) - if err != nil { - t.Fatalf("failed to list node health events after cleanup: %v", err) - } - if len(events) != 0 { - t.Fatalf("expected node health events to be removed, got %+v", events) - } -} - -func TestGetDashboardOverview(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now() - if err := model.DB.Create(&model.ConfigVersion{ - Version: "20260314-001", - SnapshotJSON: "{}", - MainConfig: "worker_processes auto;", - RenderedConfig: "server { listen 80; }", - Checksum: "checksum-dashboard", - IsActive: true, - CreatedBy: "root", - }).Error; err != nil { - t.Fatalf("failed to seed active config version: %v", err) - } - - nodes := []*model.Node{ - { - NodeID: "node-dashboard-a", - Name: "edge-a", - IP: "10.0.0.71", - GeoName: "Shanghai", - AccessToken: "token-a", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - OpenrestyStatus: OpenrestyStatusHealthy, - Status: NodeStatusOnline, - CurrentVersion: "20260314-001", - LastSeenAt: now, - }, - { - NodeID: "node-dashboard-b", - Name: "edge-b", - IP: "10.0.0.72", - GeoName: "San Francisco", - AccessToken: "token-b", - Version: "v0.6.0", - ExtVersion: "1.27.1.2", - OpenrestyStatus: OpenrestyStatusUnhealthy, - Status: NodeStatusOnline, - CurrentVersion: "20260313-001", - LastSeenAt: now, - }, - } - for _, node := range nodes { - if err := node.Insert(); err != nil { - t.Fatalf("failed to insert node %s: %v", node.NodeID, err) - } - } - - if err := (&model.NodeMetricSnapshot{ - NodeID: "node-dashboard-a", - CapturedAt: now.Add(-time.Hour), - CPUUsagePercent: 40, - MemoryUsedBytes: 4 * 1024 * 1024 * 1024, - MemoryTotalBytes: 8 * 1024 * 1024 * 1024, - StorageUsedBytes: 48 * 1024 * 1024 * 1024, - StorageTotalBytes: 100 * 1024 * 1024 * 1024, - DiskReadBytes: 0, - DiskWriteBytes: 0, - NetworkRxBytes: 100, - NetworkTxBytes: 150, - }).Insert(); err != nil { - t.Fatalf("failed to insert node a baseline metric snapshot: %v", err) - } - if err := (&model.NodeMetricSnapshot{ - NodeID: "node-dashboard-a", - CapturedAt: now, - CPUUsagePercent: 45, - MemoryUsedBytes: 4 * 1024 * 1024 * 1024, - MemoryTotalBytes: 8 * 1024 * 1024 * 1024, - StorageUsedBytes: 50 * 1024 * 1024 * 1024, - StorageTotalBytes: 100 * 1024 * 1024 * 1024, - DiskReadBytes: 100, - DiskWriteBytes: 150, - NetworkRxBytes: 300, - NetworkTxBytes: 500, - }).Insert(); err != nil { - t.Fatalf("failed to insert node a metric snapshot: %v", err) - } - if err := (&model.NodeMetricSnapshot{ - NodeID: "node-dashboard-b", - CapturedAt: now.Add(-time.Hour), - CPUUsagePercent: 88, - MemoryUsedBytes: 14 * 1024 * 1024 * 1024, - MemoryTotalBytes: 16 * 1024 * 1024 * 1024, - StorageUsedBytes: 93 * 1024 * 1024 * 1024, - StorageTotalBytes: 100 * 1024 * 1024 * 1024, - DiskReadBytes: 0, - DiskWriteBytes: 0, - NetworkRxBytes: 200, - NetworkTxBytes: 400, - }).Insert(); err != nil { - t.Fatalf("failed to insert node b baseline metric snapshot: %v", err) - } - if err := (&model.NodeMetricSnapshot{ - NodeID: "node-dashboard-b", - CapturedAt: now, - CPUUsagePercent: 92, - MemoryUsedBytes: 15 * 1024 * 1024 * 1024, - MemoryTotalBytes: 16 * 1024 * 1024 * 1024, - StorageUsedBytes: 95 * 1024 * 1024 * 1024, - StorageTotalBytes: 100 * 1024 * 1024 * 1024, - DiskReadBytes: 200, - DiskWriteBytes: 400, - NetworkRxBytes: 600, - NetworkTxBytes: 800, - }).Insert(); err != nil { - t.Fatalf("failed to insert node b metric snapshot: %v", err) - } - - if err := (&model.NodeRequestReport{ - NodeID: "node-dashboard-a", - WindowStartedAt: now.Add(-time.Minute), - WindowEndedAt: now, - RequestCount: 600, - ErrorCount: 6, - UniqueVisitorCount: 120, - StatusCodesJSON: `{"200":570,"502":6,"304":24}`, - TopDomainsJSON: `{"app.example.com":420,"api.example.com":180}`, - }).Insert(); err != nil { - t.Fatalf("failed to insert node a traffic report: %v", err) - } - if err := (&model.NodeRequestReport{ - NodeID: "node-dashboard-b", - WindowStartedAt: now.Add(-time.Minute), - WindowEndedAt: now, - RequestCount: 300, - ErrorCount: 30, - UniqueVisitorCount: 80, - StatusCodesJSON: `{"200":240,"500":18,"502":12,"404":30}`, - TopDomainsJSON: `{"app.example.com":140,"edge.example.com":160}`, - }).Insert(); err != nil { - t.Fatalf("failed to insert node b traffic report: %v", err) - } - for _, item := range []*model.NodeAccessLog{ - { - NodeID: "node-dashboard-a", - LoggedAt: now.Add(-30 * time.Minute), - RemoteAddr: "203.0.113.11", - Region: "China", - Host: "app.example.com", - Path: "/", - StatusCode: 200, - }, - { - NodeID: "node-dashboard-a", - LoggedAt: now.Add(-20 * time.Minute), - RemoteAddr: "203.0.113.12", - Region: "China", - Host: "app.example.com", - Path: "/login", - StatusCode: 200, - }, - { - NodeID: "node-dashboard-b", - LoggedAt: now.Add(-10 * time.Minute), - RemoteAddr: "198.51.100.8", - Region: "United States", - Host: "edge.example.com", - Path: "/edge", - StatusCode: 502, - }, - } { - if err := model.DB.Create(item).Error; err != nil { - t.Fatalf("failed to seed dashboard access logs: %v", err) - } - } - - if err := model.DB.Create(&model.NodeHealthEvent{ - NodeID: "node-dashboard-b", - EventType: "openresty_unhealthy", - Severity: NodeHealthSeverityCritical, - Status: NodeHealthEventStatusActive, - Message: "reload failed", - FirstTriggeredAt: now.Add(-time.Minute), - LastTriggeredAt: now, - ReportedAt: now, - }).Error; err != nil { - t.Fatalf("failed to insert dashboard health event: %v", err) - } - - view, err := GetDashboardOverview() - if err != nil { - t.Fatalf("GetDashboardOverview failed: %v", err) - } - if view.Summary.TotalNodes != 2 || view.Summary.OnlineNodes != 2 { - t.Fatalf("unexpected dashboard summary: %+v", view.Summary) - } - if view.Summary.UnhealthyNodes != 1 { - t.Fatalf("unexpected unhealthy summary: %+v", view.Summary) - } - if view.Traffic.RequestCount != 900 || view.Traffic.ErrorCount != 36 { - t.Fatalf("unexpected dashboard traffic: %+v", view.Traffic) - } - if view.Capacity.HighCPUNodes != 1 || view.Capacity.HighMemoryNodes != 1 { - t.Fatalf("unexpected dashboard capacity summary: %+v", view.Capacity) - } - if len(view.Nodes) != 2 { - t.Fatalf("unexpected dashboard nodes: %+v", view.Nodes) - } - if view.Nodes[0].GeoName == "" && view.Nodes[1].GeoName == "" { - t.Fatalf("expected dashboard nodes to expose geo metadata: %+v", view.Nodes) - } - if view.Nodes[0].ActiveEventCount != 1 { - t.Fatalf("expected dashboard nodes to preserve active event counts: %+v", view.Nodes) - } - if len(view.Trends.Traffic24h) != 24 || len(view.Trends.Capacity24h) != 24 || len(view.Trends.Network24h) != 24 || len(view.Trends.DiskIO24h) != 24 { - t.Fatalf("expected 24-point dashboard trends, got %+v", view.Trends) - } - if view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1].RequestCount != 900 { - t.Fatalf("unexpected dashboard traffic trend tail: %+v", view.Trends.Traffic24h[len(view.Trends.Traffic24h)-1]) - } - if view.Trends.Network24h[len(view.Trends.Network24h)-1].NetworkRxBytes != 900 { - t.Fatalf("unexpected dashboard network trend tail: %+v", view.Trends.Network24h[len(view.Trends.Network24h)-1]) - } - if view.Trends.DiskIO24h[len(view.Trends.DiskIO24h)-1].DiskWriteBytes != 550 { - t.Fatalf("unexpected dashboard disk io trend tail: %+v", view.Trends.DiskIO24h[len(view.Trends.DiskIO24h)-1]) - } - if len(view.Distributions.StatusCodes) == 0 || view.Distributions.StatusCodes[0].Key != "200" { - t.Fatalf("unexpected dashboard status distributions: %+v", view.Distributions.StatusCodes) - } - if len(view.Distributions.SourceCountries) == 0 || view.Distributions.SourceCountries[0].Key != "China" { - t.Fatalf("unexpected dashboard source distributions: %+v", view.Distributions.SourceCountries) - } - if len(view.Distributions.TopDomains) == 0 || view.Distributions.TopDomains[0].Key != "app.example.com" { - t.Fatalf("unexpected dashboard domain distributions: %+v", view.Distributions.TopDomains) - } -} - -func TestGetDashboardOverviewReturnsEmptyNodeSlice(t *testing.T) { - setupServiceTestDB(t) - - view, err := GetDashboardOverview() - if err != nil { - t.Fatalf("GetDashboardOverview failed: %v", err) - } - if view.Nodes == nil { - t.Fatalf("expected nodes to be an empty slice, got nil") - } - if len(view.Nodes) != 0 { - t.Fatalf("expected empty nodes, got %+v", view.Nodes) - } -} diff --git a/openflare-server/internal/service/observability.go b/openflare-server/internal/service/observability.go deleted file mode 100644 index a012382c..00000000 --- a/openflare-server/internal/service/observability.go +++ /dev/null @@ -1,410 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "log/slog" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -const ( - NodeHealthEventStatusActive = "active" - NodeHealthEventStatusResolved = "resolved" - NodeHealthSeverityInfo = "info" - NodeHealthSeverityWarning = "warning" - NodeHealthSeverityCritical = "critical" - nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour - nodeAccessLogPathMaxLength = 100 -) - -type AgentNodeSystemProfile struct { - Hostname string `json:"hostname"` - OSName string `json:"os_name"` - OSVersion string `json:"os_version"` - KernelVersion string `json:"kernel_version"` - Architecture string `json:"architecture"` - CPUModel string `json:"cpu_model"` - CPUCores int `json:"cpu_cores"` - TotalMemoryBytes int64 `json:"total_memory_bytes"` - TotalDiskBytes int64 `json:"total_disk_bytes"` - UptimeSeconds int64 `json:"uptime_seconds"` - ReportedAtUnix int64 `json:"reported_at_unix"` -} - -type AgentNodeMetricSnapshot struct { - CapturedAtUnix int64 `json:"captured_at_unix"` - CPUUsagePercent float64 `json:"cpu_usage_percent"` - MemoryUsedBytes int64 `json:"memory_used_bytes"` - MemoryTotalBytes int64 `json:"memory_total_bytes"` - StorageUsedBytes int64 `json:"storage_used_bytes"` - StorageTotalBytes int64 `json:"storage_total_bytes"` - DiskReadBytes int64 `json:"disk_read_bytes"` - DiskWriteBytes int64 `json:"disk_write_bytes"` - NetworkRxBytes int64 `json:"network_rx_bytes"` - NetworkTxBytes int64 `json:"network_tx_bytes"` -} - -type AgentNodeOpenrestyObservation struct { - CapturedAtUnix int64 `json:"captured_at_unix"` - OpenrestyRxBytes int64 `json:"openresty_rx_bytes"` - OpenrestyTxBytes int64 `json:"openresty_tx_bytes"` - OpenrestyConnections int64 `json:"openresty_connections"` -} - -type AgentNodeTrafficReport struct { - WindowStartedAtUnix int64 `json:"window_started_at_unix"` - WindowEndedAtUnix int64 `json:"window_ended_at_unix"` - RequestCount int64 `json:"request_count"` - ErrorCount int64 `json:"error_count"` - UniqueVisitorCount int64 `json:"unique_visitor_count"` - StatusCodes map[string]int64 `json:"status_codes"` - TopDomains map[string]int64 `json:"top_domains"` - SourceCountries map[string]int64 `json:"source_countries"` -} - -type AgentNodeAccessLog struct { - LoggedAtUnix int64 `json:"logged_at_unix"` - RemoteAddr string `json:"remote_addr"` - Host string `json:"host"` - Path string `json:"path"` - StatusCode int `json:"status_code"` -} - -type AgentBufferedObservabilityRecord struct { - WindowStartedAtUnix int64 `json:"window_started_at_unix"` - Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"` - OpenrestyObservation *AgentNodeOpenrestyObservation `json:"openresty_observation,omitempty"` - TrafficReport *AgentNodeTrafficReport `json:"traffic_report,omitempty"` - AccessLogs []AgentNodeAccessLog `json:"access_logs,omitempty"` -} - -type AgentNodeHealthEvent struct { - EventType string `json:"event_type"` - Severity string `json:"severity"` - Message string `json:"message"` - TriggeredAtUnix int64 `json:"triggered_at_unix"` - Metadata map[string]string `json:"metadata"` -} - -func persistHeartbeatObservability(nodeID string, payload AgentNodePayload, reportedAt time.Time) { - if strings.TrimSpace(nodeID) == "" { - return - } - if payload.Profile == nil && payload.Snapshot == nil && payload.TrafficReport == nil && len(payload.AccessLogs) == 0 && len(payload.BufferedObservability) == 0 && payload.HealthEvents == nil { - return - } - - if err := model.DB.Transaction(func(tx *gorm.DB) error { - if err := persistNodeSystemProfile(tx, nodeID, payload.Profile, reportedAt); err != nil { - return err - } - if err := persistBufferedObservability(tx, nodeID, payload.BufferedObservability, reportedAt); err != nil { - return err - } - if err := persistNodeMetricSnapshot(tx, nodeID, payload.Snapshot, reportedAt); err != nil { - return err - } - if err := persistNodeOpenrestyObservation(tx, nodeID, payload.OpenrestyObservation, reportedAt); err != nil { - return err - } - if err := persistNodeTrafficReport(tx, nodeID, payload.TrafficReport, reportedAt); err != nil { - return err - } - if err := persistNodeAccessLogs(tx, nodeID, payload.AccessLogs, reportedAt); err != nil { - return err - } - if payload.HealthEvents != nil { - if err := reconcileNodeHealthEvents(tx, nodeID, payload.HealthEvents, reportedAt); err != nil { - return err - } - } - return nil - }); err != nil { - slog.Error("persist heartbeat observability failed", "node_id", nodeID, "error", err) - } -} - -func persistBufferedObservability(tx *gorm.DB, nodeID string, records []AgentBufferedObservabilityRecord, reportedAt time.Time) error { - for _, record := range records { - if err := persistNodeMetricSnapshot(tx, nodeID, record.Snapshot, reportedAt); err != nil { - return err - } - if err := persistNodeOpenrestyObservation(tx, nodeID, record.OpenrestyObservation, reportedAt); err != nil { - return err - } - if err := persistNodeTrafficReport(tx, nodeID, record.TrafficReport, reportedAt); err != nil { - return err - } - if err := persistNodeAccessLogs(tx, nodeID, record.AccessLogs, reportedAt); err != nil { - return err - } - } - return nil -} - -func persistNodeSystemProfile(tx *gorm.DB, nodeID string, profile *AgentNodeSystemProfile, reportedAt time.Time) error { - if profile == nil { - return nil - } - record := &model.NodeSystemProfile{ - NodeID: nodeID, - Hostname: strings.TrimSpace(profile.Hostname), - OSName: strings.TrimSpace(profile.OSName), - OSVersion: strings.TrimSpace(profile.OSVersion), - KernelVersion: strings.TrimSpace(profile.KernelVersion), - Architecture: strings.TrimSpace(profile.Architecture), - CPUModel: strings.TrimSpace(profile.CPUModel), - CPUCores: profile.CPUCores, - TotalMemoryBytes: profile.TotalMemoryBytes, - TotalDiskBytes: profile.TotalDiskBytes, - UptimeSeconds: profile.UptimeSeconds, - ReportedAt: timeFromUnix(profile.ReportedAtUnix, reportedAt), - } - return tx.Model(&model.NodeSystemProfile{}).Where("node_id = ?", nodeID).Assign(record).FirstOrCreate(record).Error -} - -func persistNodeMetricSnapshot(tx *gorm.DB, nodeID string, snapshot *AgentNodeMetricSnapshot, reportedAt time.Time) error { - if snapshot == nil { - return nil - } - record := &model.NodeMetricSnapshot{ - NodeID: nodeID, - CapturedAt: timeFromUnix(snapshot.CapturedAtUnix, reportedAt), - CPUUsagePercent: snapshot.CPUUsagePercent, - MemoryUsedBytes: snapshot.MemoryUsedBytes, - MemoryTotalBytes: snapshot.MemoryTotalBytes, - StorageUsedBytes: snapshot.StorageUsedBytes, - StorageTotalBytes: snapshot.StorageTotalBytes, - DiskReadBytes: snapshot.DiskReadBytes, - DiskWriteBytes: snapshot.DiskWriteBytes, - NetworkRxBytes: snapshot.NetworkRxBytes, - NetworkTxBytes: snapshot.NetworkTxBytes, - } - exists, err := model.NodeMetricSnapshotExists(tx, nodeID, record.CapturedAt) - if err != nil { - return err - } - if exists { - return nil - } - return tx.Create(record).Error -} - -func persistNodeOpenrestyObservation(tx *gorm.DB, nodeID string, obs *AgentNodeOpenrestyObservation, reportedAt time.Time) error { - if obs == nil { - return nil - } - record := &model.NodeObservationOpenresty{ - NodeID: nodeID, - CapturedAt: timeFromUnix(obs.CapturedAtUnix, reportedAt), - OpenrestyRxBytes: obs.OpenrestyRxBytes, - OpenrestyTxBytes: obs.OpenrestyTxBytes, - OpenrestyConnections: obs.OpenrestyConnections, - } - return tx.Create(record).Error -} - -func persistNodeTrafficReport(tx *gorm.DB, nodeID string, report *AgentNodeTrafficReport, reportedAt time.Time) error { - if report == nil { - return nil - } - if report.WindowEndedAtUnix > 0 && report.WindowStartedAtUnix > report.WindowEndedAtUnix { - return errors.New("traffic report window_started_at_unix 不能大于 window_ended_at_unix") - } - record := &model.NodeRequestReport{ - NodeID: nodeID, - WindowStartedAt: timeFromUnix(report.WindowStartedAtUnix, reportedAt), - WindowEndedAt: timeFromUnix(report.WindowEndedAtUnix, reportedAt), - RequestCount: report.RequestCount, - ErrorCount: report.ErrorCount, - UniqueVisitorCount: report.UniqueVisitorCount, - StatusCodesJSON: marshalJSON(report.StatusCodes), - TopDomainsJSON: marshalJSON(report.TopDomains), - SourceCountriesJSON: marshalJSON(report.SourceCountries), - } - exists, err := model.NodeRequestReportExists(tx, nodeID, record.WindowStartedAt, record.WindowEndedAt) - if err != nil { - return err - } - if exists { - return nil - } - return tx.Create(record).Error -} - -func persistNodeAccessLogs(tx *gorm.DB, nodeID string, logs []AgentNodeAccessLog, reportedAt time.Time) error { - if len(logs) == 0 { - return nil - } - resolver, err := newAccessLogRegionResolver() - if err != nil { - slog.Warn("initialize access log geo resolver failed", "node_id", nodeID, "error", err) - } - if resolver != nil { - defer resolver.Close() - } - for _, item := range logs { - record := &model.NodeAccessLog{ - NodeID: nodeID, - LoggedAt: timeFromUnix(item.LoggedAtUnix, reportedAt), - RemoteAddr: strings.TrimSpace(item.RemoteAddr), - Region: "", - Host: strings.TrimSpace(item.Host), - Path: truncateForDatabase(strings.TrimSpace(item.Path), nodeAccessLogPathMaxLength), - StatusCode: item.StatusCode, - } - if resolver != nil { - record.Region = resolver.Resolve(record.RemoteAddr) - } - exists, err := model.NodeAccessLogExists(tx, record) - if err != nil { - return err - } - if exists { - continue - } - if err := tx.Create(record).Error; err != nil { - return err - } - } - _, err = model.DeleteNodeAccessLogsByNodeBefore(tx, nodeID, reportedAt.Add(-nodeAccessLogRetentionWindow)) - return err -} - -func reconcileNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHealthEvent, reportedAt time.Time) error { - return reconcileScopedNodeHealthEvents(tx, nodeID, events, reportedAt, nil) -} - -func reconcileScopedNodeHealthEvents(tx *gorm.DB, nodeID string, events []AgentNodeHealthEvent, reportedAt time.Time, managedEventTypes map[string]struct{}) error { - activeTypes := make(map[string]AgentNodeHealthEvent, len(events)) - for _, event := range events { - eventType := normalizeHealthEventType(event.EventType) - if eventType == "" { - continue - } - if len(managedEventTypes) > 0 { - if _, ok := managedEventTypes[eventType]; !ok { - continue - } - } - event.EventType = eventType - event.Severity = normalizeHealthSeverity(event.Severity) - if event.TriggeredAtUnix <= 0 { - event.TriggeredAtUnix = reportedAt.Unix() - } - activeTypes[eventType] = event - } - - var activeEvents []*model.NodeHealthEvent - query := tx.Where("node_id = ? AND status = ?", nodeID, NodeHealthEventStatusActive) - if len(managedEventTypes) > 0 { - scopedTypes := make([]string, 0, len(managedEventTypes)) - for eventType := range managedEventTypes { - eventType = normalizeHealthEventType(eventType) - if eventType != "" { - scopedTypes = append(scopedTypes, eventType) - } - } - if len(scopedTypes) == 0 { - return nil - } - query = query.Where("event_type IN ?", scopedTypes) - } - if err := query.Find(&activeEvents).Error; err != nil { - return err - } - - activeByType := make(map[string]*model.NodeHealthEvent, len(activeEvents)) - for _, event := range activeEvents { - activeByType[event.EventType] = event - } - - for eventType, event := range activeTypes { - triggeredAt := timeFromUnix(event.TriggeredAtUnix, reportedAt) - if existing, ok := activeByType[eventType]; ok { - existing.Severity = event.Severity - existing.Message = normalizeHealthEventMessage(event.Message) - existing.LastTriggeredAt = triggeredAt - existing.ReportedAt = reportedAt - existing.MetadataJSON = marshalJSON(event.Metadata) - existing.ResolvedAt = nil - if err := tx.Save(existing).Error; err != nil { - return err - } - continue - } - record := &model.NodeHealthEvent{ - NodeID: nodeID, - EventType: eventType, - Severity: event.Severity, - Status: NodeHealthEventStatusActive, - Message: normalizeHealthEventMessage(event.Message), - FirstTriggeredAt: triggeredAt, - LastTriggeredAt: triggeredAt, - ReportedAt: reportedAt, - MetadataJSON: marshalJSON(event.Metadata), - } - if err := tx.Create(record).Error; err != nil { - return err - } - } - - for _, existing := range activeEvents { - if _, ok := activeTypes[existing.EventType]; ok { - continue - } - resolvedAt := reportedAt - existing.Status = NodeHealthEventStatusResolved - existing.ReportedAt = reportedAt - existing.ResolvedAt = &resolvedAt - if err := tx.Save(existing).Error; err != nil { - return err - } - } - - return nil -} - -func normalizeHealthEventType(eventType string) string { - eventType = strings.TrimSpace(strings.ToLower(eventType)) - eventType = strings.ReplaceAll(eventType, " ", "_") - return eventType -} - -func normalizeHealthSeverity(severity string) string { - switch strings.ToLower(strings.TrimSpace(severity)) { - case NodeHealthSeverityCritical: - return NodeHealthSeverityCritical - case NodeHealthSeverityInfo: - return NodeHealthSeverityInfo - default: - return NodeHealthSeverityWarning - } -} - -func normalizeHealthEventMessage(message string) string { - return truncateForDatabase(message, 4096) -} - -func timeFromUnix(unixSeconds int64, fallback time.Time) time.Time { - if unixSeconds <= 0 { - return fallback - } - return time.Unix(unixSeconds, 0).UTC() -} - -func marshalJSON(value any) string { - if value == nil { - return "" - } - raw, err := json.Marshal(value) - if err != nil { - return "" - } - return string(raw) -} diff --git a/openflare-server/internal/service/observability_analytics.go b/openflare-server/internal/service/observability_analytics.go deleted file mode 100644 index 4d9aefde..00000000 --- a/openflare-server/internal/service/observability_analytics.go +++ /dev/null @@ -1,173 +0,0 @@ -package service - -import ( - "encoding/json" - "sort" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -type DistributionItem struct { - Key string `json:"key"` - Value int64 `json:"value"` -} - -type TrafficDistributions struct { - StatusCodes []DistributionItem `json:"status_codes"` - TopDomains []DistributionItem `json:"top_domains"` - SourceCountries []DistributionItem `json:"source_countries"` -} - -type TrafficWindowSummary struct { - WindowStartedAt time.Time `json:"window_started_at"` - WindowEndedAt time.Time `json:"window_ended_at"` - RequestCount int64 `json:"request_count"` - UniqueVisitorCount int64 `json:"unique_visitor_count"` - ErrorCount int64 `json:"error_count"` - EstimatedQPS float64 `json:"estimated_qps"` - ErrorRatePercent float64 `json:"error_rate_percent"` -} - -type ObservabilityHealthSummary struct { - ActiveAlerts int `json:"active_alerts"` - CriticalAlerts int `json:"critical_alerts"` - WarningAlerts int `json:"warning_alerts"` - InfoAlerts int `json:"info_alerts"` - ResolvedAlerts int `json:"resolved_alerts"` - HasCapacityRisk bool `json:"has_capacity_risk"` - HasTrafficRisk bool `json:"has_traffic_risk"` - HasRuntimeRisk bool `json:"has_runtime_risk"` -} - -type distributionAccumulator map[string]int64 - -func buildTrafficWindowSummary(report *model.NodeRequestReport) TrafficWindowSummary { - if report == nil { - return TrafficWindowSummary{} - } - summary := TrafficWindowSummary{ - WindowStartedAt: report.WindowStartedAt, - WindowEndedAt: report.WindowEndedAt, - RequestCount: report.RequestCount, - UniqueVisitorCount: report.UniqueVisitorCount, - ErrorCount: report.ErrorCount, - } - if duration := report.WindowEndedAt.Sub(report.WindowStartedAt).Seconds(); duration > 0 { - summary.EstimatedQPS = float64(report.RequestCount) / duration - } - if report.RequestCount > 0 { - summary.ErrorRatePercent = (float64(report.ErrorCount) / float64(report.RequestCount)) * 100 - } - return summary -} - -func buildTrafficDistributions( - reports []*model.NodeRequestReport, - accessLogRegions []*model.NodeAccessLogRegionCount, - limit int, -) TrafficDistributions { - statusCodes := make(distributionAccumulator) - topDomains := make(distributionAccumulator) - reportSourceCountries := make(distributionAccumulator) - for _, report := range reports { - mergeJSONCounts(statusCodes, report.StatusCodesJSON) - mergeJSONCounts(topDomains, report.TopDomainsJSON) - mergeJSONCounts(reportSourceCountries, report.SourceCountriesJSON) - } - sourceCountries := reportSourceCountries - if len(accessLogRegions) > 0 { - sourceCountries = make(distributionAccumulator, len(accessLogRegions)) - for _, item := range accessLogRegions { - if item == nil || strings.TrimSpace(item.Region) == "" || item.Count <= 0 { - continue - } - sourceCountries[item.Region] = item.Count - } - } - return TrafficDistributions{ - StatusCodes: toDistributionItems(statusCodes, limit), - TopDomains: toDistributionItems(topDomains, limit), - SourceCountries: toDistributionItems(sourceCountries, limit), - } -} - -func buildObservabilityHealthSummary(snapshot *model.NodeMetricSnapshot, report *model.NodeRequestReport, events []*model.NodeHealthEvent) ObservabilityHealthSummary { - summary := ObservabilityHealthSummary{} - for _, event := range events { - if event == nil { - continue - } - if event.Status == NodeHealthEventStatusResolved { - summary.ResolvedAlerts++ - continue - } - summary.ActiveAlerts++ - switch event.Severity { - case NodeHealthSeverityCritical: - summary.CriticalAlerts++ - case NodeHealthSeverityWarning: - summary.WarningAlerts++ - default: - summary.InfoAlerts++ - } - } - if snapshot != nil { - memoryUsage := percentage(snapshot.MemoryUsedBytes, snapshot.MemoryTotalBytes) - storageUsage := percentage(snapshot.StorageUsedBytes, snapshot.StorageTotalBytes) - summary.HasCapacityRisk = snapshot.CPUUsagePercent >= 80 || memoryUsage >= 85 || storageUsage >= 85 - } - if report != nil && report.RequestCount >= 100 { - summary.HasTrafficRisk = (float64(report.ErrorCount) / float64(report.RequestCount)) >= 0.05 - } - summary.HasRuntimeRisk = summary.ActiveAlerts > 0 || summary.HasCapacityRisk || summary.HasTrafficRisk - return summary -} - -func mergeJSONCounts(target distributionAccumulator, raw string) { - if len(target) == 0 && strings.TrimSpace(raw) == "" { - return - } - values := parseJSONCounts(raw) - for key, value := range values { - if strings.TrimSpace(key) == "" || value <= 0 { - continue - } - target[key] += value - } -} - -func parseJSONCounts(raw string) map[string]int64 { - if strings.TrimSpace(raw) == "" { - return nil - } - values := make(map[string]int64) - if err := json.Unmarshal([]byte(raw), &values); err != nil { - return nil - } - return values -} - -func toDistributionItems(values distributionAccumulator, limit int) []DistributionItem { - if len(values) == 0 { - return []DistributionItem{} - } - items := make([]DistributionItem, 0, len(values)) - for key, value := range values { - if strings.TrimSpace(key) == "" || value <= 0 { - continue - } - items = append(items, DistributionItem{Key: key, Value: value}) - } - sort.Slice(items, func(i int, j int) bool { - if items[i].Value == items[j].Value { - return items[i].Key < items[j].Key - } - return items[i].Value > items[j].Value - }) - if limit > 0 && len(items) > limit { - items = items[:limit] - } - return items -} diff --git a/openflare-server/internal/service/observability_trends.go b/openflare-server/internal/service/observability_trends.go deleted file mode 100644 index a21cabfe..00000000 --- a/openflare-server/internal/service/observability_trends.go +++ /dev/null @@ -1,236 +0,0 @@ -package service - -import ( - "sort" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -const observabilityTrendBuckets = 24 - -type TrafficTrendPoint struct { - BucketStartedAt time.Time `json:"bucket_started_at"` - RequestCount int64 `json:"request_count"` - ErrorCount int64 `json:"error_count"` - UniqueVisitorCount int64 `json:"unique_visitor_count"` -} - -type CapacityTrendPoint struct { - BucketStartedAt time.Time `json:"bucket_started_at"` - AverageCPUUsagePercent float64 `json:"average_cpu_usage_percent"` - AverageMemoryUsagePercent float64 `json:"average_memory_usage_percent"` - ReportedNodes int `json:"reported_nodes"` -} - -type NetworkTrendPoint struct { - BucketStartedAt time.Time `json:"bucket_started_at"` - NetworkRxBytes int64 `json:"network_rx_bytes"` - NetworkTxBytes int64 `json:"network_tx_bytes"` - OpenrestyRxBytes int64 `json:"openresty_rx_bytes"` - OpenrestyTxBytes int64 `json:"openresty_tx_bytes"` - ReportedNodes int `json:"reported_nodes"` -} - -type DiskIOTrendPoint struct { - BucketStartedAt time.Time `json:"bucket_started_at"` - DiskReadBytes int64 `json:"disk_read_bytes"` - DiskWriteBytes int64 `json:"disk_write_bytes"` - ReportedNodes int `json:"reported_nodes"` -} - -type capacityTrendAccumulator struct { - cpuSum float64 - cpuCount int - memSum float64 - memCount int - nodes map[string]struct{} -} - -type snapshotTrendAccumulator struct { - nodes map[string]struct{} -} - -func buildTrafficTrendPoints(now time.Time, reports []*model.NodeRequestReport) []TrafficTrendPoint { - start := trendWindowStart(now) - points := make([]TrafficTrendPoint, observabilityTrendBuckets) - for index := range points { - points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour) - } - - for _, report := range reports { - index, ok := trendBucketIndex(report.WindowEndedAt, start) - if !ok { - continue - } - points[index].RequestCount += report.RequestCount - points[index].ErrorCount += report.ErrorCount - points[index].UniqueVisitorCount += report.UniqueVisitorCount - } - - return points -} - -func buildCapacityTrendPoints(now time.Time, snapshots []*model.NodeMetricSnapshot) []CapacityTrendPoint { - start := trendWindowStart(now) - points := make([]CapacityTrendPoint, observabilityTrendBuckets) - accumulators := make([]capacityTrendAccumulator, observabilityTrendBuckets) - for index := range points { - points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour) - accumulators[index].nodes = make(map[string]struct{}) - } - - for _, snapshot := range snapshots { - index, ok := trendBucketIndex(snapshot.CapturedAt, start) - if !ok { - continue - } - if snapshot.CPUUsagePercent > 0 { - accumulators[index].cpuSum += snapshot.CPUUsagePercent - accumulators[index].cpuCount++ - } - if memoryUsage := percentage(snapshot.MemoryUsedBytes, snapshot.MemoryTotalBytes); memoryUsage > 0 { - accumulators[index].memSum += memoryUsage - accumulators[index].memCount++ - } - if snapshot.NodeID != "" { - accumulators[index].nodes[snapshot.NodeID] = struct{}{} - } - } - - for index := range points { - if accumulators[index].cpuCount > 0 { - points[index].AverageCPUUsagePercent = accumulators[index].cpuSum / float64(accumulators[index].cpuCount) - } - if accumulators[index].memCount > 0 { - points[index].AverageMemoryUsagePercent = accumulators[index].memSum / float64(accumulators[index].memCount) - } - points[index].ReportedNodes = len(accumulators[index].nodes) - } - - return points -} - -func buildNetworkTrendPoints(now time.Time, snapshots []*model.NodeMetricSnapshot, openrestyObs []*model.NodeObservationOpenresty) []NetworkTrendPoint { - start := trendWindowStart(now) - points := make([]NetworkTrendPoint, observabilityTrendBuckets) - accumulators := make([]snapshotTrendAccumulator, observabilityTrendBuckets) - for index := range points { - points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour) - accumulators[index].nodes = make(map[string]struct{}) - } - - for _, snapshot := range snapshots { - index, ok := trendBucketIndex(snapshot.CapturedAt, start) - if !ok { - continue - } - points[index].NetworkRxBytes += snapshot.NetworkRxBytes - points[index].NetworkTxBytes += snapshot.NetworkTxBytes - if snapshot.NodeID != "" { - accumulators[index].nodes[snapshot.NodeID] = struct{}{} - } - } - - for _, obs := range openrestyObs { - index, ok := trendBucketIndex(obs.CapturedAt, start) - if !ok { - continue - } - points[index].OpenrestyRxBytes += obs.OpenrestyRxBytes - points[index].OpenrestyTxBytes += obs.OpenrestyTxBytes - if obs.NodeID != "" { - accumulators[index].nodes[obs.NodeID] = struct{}{} - } - } - - for index := range points { - points[index].ReportedNodes = len(accumulators[index].nodes) - } - - return points -} - -func buildDiskIOTrendPoints(now time.Time, snapshots []*model.NodeMetricSnapshot) []DiskIOTrendPoint { - start := trendWindowStart(now) - points := make([]DiskIOTrendPoint, observabilityTrendBuckets) - accumulators := make([]snapshotTrendAccumulator, observabilityTrendBuckets) - for index := range points { - points[index].BucketStartedAt = start.Add(time.Duration(index) * time.Hour) - accumulators[index].nodes = make(map[string]struct{}) - } - - sort.Slice(snapshots, func(i int, j int) bool { - if snapshots[i].CapturedAt.Equal(snapshots[j].CapturedAt) { - return snapshots[i].NodeID < snapshots[j].NodeID - } - return snapshots[i].CapturedAt.Before(snapshots[j].CapturedAt) - }) - - type diskCounterState struct { - read int64 - write int64 - seen bool - } - - previousByNode := make(map[string]diskCounterState, len(snapshots)) - - for _, snapshot := range snapshots { - nodeKey := snapshot.NodeID - if nodeKey == "" { - nodeKey = "__unknown__" - } - - previous := previousByNode[nodeKey] - previousByNode[nodeKey] = diskCounterState{ - read: snapshot.DiskReadBytes, - write: snapshot.DiskWriteBytes, - seen: true, - } - if !previous.seen { - continue - } - - index, ok := trendBucketIndex(snapshot.CapturedAt, start) - if !ok { - continue - } - - readDelta := snapshot.DiskReadBytes - previous.read - writeDelta := snapshot.DiskWriteBytes - previous.write - if readDelta < 0 { - readDelta = 0 - } - if writeDelta < 0 { - writeDelta = 0 - } - - points[index].DiskReadBytes += readDelta - points[index].DiskWriteBytes += writeDelta - if snapshot.NodeID != "" { - accumulators[index].nodes[snapshot.NodeID] = struct{}{} - } - } - - for index := range points { - points[index].ReportedNodes = len(accumulators[index].nodes) - } - - return points -} - -func trendWindowStart(now time.Time) time.Time { - return now.Truncate(time.Hour).Add(-(observabilityTrendBuckets - 1) * time.Hour) -} - -func trendBucketIndex(timestamp time.Time, start time.Time) (int, bool) { - if timestamp.Before(start) { - return 0, false - } - delta := timestamp.Sub(start) - index := int(delta / time.Hour) - if index < 0 || index >= observabilityTrendBuckets { - return 0, false - } - return index, true -} diff --git a/openflare-server/internal/service/observability_trends_test.go b/openflare-server/internal/service/observability_trends_test.go deleted file mode 100644 index a3115f29..00000000 --- a/openflare-server/internal/service/observability_trends_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package service - -import ( - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestBuildDiskIOTrendPointsUsesCounterDelta(t *testing.T) { - now := time.Date(2026, 3, 14, 18, 30, 0, 0, time.UTC) - start := trendWindowStart(now) - - points := buildDiskIOTrendPoints(now, []*model.NodeMetricSnapshot{ - { - NodeID: "node-a", - CapturedAt: start.Add(22 * time.Hour), - DiskReadBytes: 100, - DiskWriteBytes: 200, - }, - { - NodeID: "node-a", - CapturedAt: start.Add(23 * time.Hour), - DiskReadBytes: 250, - DiskWriteBytes: 260, - }, - }) - - last := points[len(points)-1] - if last.DiskReadBytes != 150 || last.DiskWriteBytes != 60 { - t.Fatalf("expected disk io trend to use counter delta, got %+v", last) - } -} diff --git a/openflare-server/internal/service/origin.go b/openflare-server/internal/service/origin.go deleted file mode 100644 index f20ed373..00000000 --- a/openflare-server/internal/service/origin.go +++ /dev/null @@ -1,242 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -type OriginInput struct { - Name string `json:"name"` - Address string `json:"address"` - Remark string `json:"remark"` -} - -type OriginRouteSummary struct { - ID uint `json:"id"` - Domain string `json:"domain"` - OriginURL string `json:"origin_url"` - Enabled bool `json:"enabled"` - UpdatedAt string `json:"updated_at"` -} - -type OriginView struct { - ID uint `json:"id"` - Name string `json:"name"` - Address string `json:"address"` - Remark string `json:"remark"` - RouteCount int64 `json:"route_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type OriginDetailView struct { - OriginView - Routes []OriginRouteSummary `json:"routes"` -} - -func ListOrigins() ([]OriginView, error) { - origins, err := model.ListOrigins() - if err != nil { - return nil, err - } - return buildOriginViews(origins) -} - -func GetOriginDetail(id uint) (*OriginDetailView, error) { - origin, err := model.GetOriginByID(id) - if err != nil { - return nil, err - } - views, err := buildOriginViews([]*model.Origin{origin}) - if err != nil { - return nil, err - } - routes, err := model.ListProxyRoutesByOriginID(id) - if err != nil { - return nil, err - } - items := make([]OriginRouteSummary, 0, len(routes)) - for _, route := range routes { - items = append(items, OriginRouteSummary{ - ID: route.ID, - Domain: route.Domain, - OriginURL: route.OriginURL, - Enabled: route.Enabled, - UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), - }) - } - sort.Slice(items, func(i int, j int) bool { - return items[i].Domain < items[j].Domain - }) - detail := &OriginDetailView{ - OriginView: views[0], - Routes: items, - } - return detail, nil -} - -func CreateOrigin(input OriginInput) (*model.Origin, error) { - origin, err := buildOrigin(nil, input) - if err != nil { - return nil, err - } - if err = origin.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("源站地址已存在") - } - return nil, err - } - return origin, nil -} - -func UpdateOrigin(id uint, input OriginInput) (*model.Origin, error) { - origin, err := model.GetOriginByID(id) - if err != nil { - return nil, err - } - previousAddress := origin.Address - nextOrigin, err := buildOrigin(origin, input) - if err != nil { - return nil, err - } - err = model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Save(nextOrigin).Error; err != nil { - if model.IsUniqueConstraintError(err) { - return errors.New("源站地址已存在") - } - return err - } - if previousAddress == nextOrigin.Address { - return nil - } - return updateRoutesForOriginAddress(tx, nextOrigin.ID, nextOrigin.Address) - }) - if err != nil { - return nil, err - } - return nextOrigin, nil -} - -func DeleteOrigin(id uint) error { - routes, err := model.ListProxyRoutesByOriginID(id) - if err != nil { - return err - } - if len(routes) > 0 { - return errors.New("该源站仍被规则引用,无法删除") - } - origin, err := model.GetOriginByID(id) - if err != nil { - return err - } - return origin.Delete() -} - -func buildOrigin(existing *model.Origin, input OriginInput) (*model.Origin, error) { - address := normalizeOriginAddress(input.Address) - if err := validateOriginAddress(address); err != nil { - return nil, err - } - if existing == nil { - existing = &model.Origin{} - } - existing.Address = address - existing.Name = normalizeOriginName(input.Name, address) - existing.Remark = strings.TrimSpace(input.Remark) - return existing, nil -} - -func getOrCreateOriginByAddress(address string) (*model.Origin, error) { - normalizedAddress := normalizeOriginAddress(address) - if err := validateOriginAddress(normalizedAddress); err != nil { - return nil, err - } - existing, err := model.GetOriginByAddress(normalizedAddress) - if err == nil { - return existing, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - origin := &model.Origin{ - Name: normalizedAddress, - Address: normalizedAddress, - Remark: "", - } - if err := origin.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return model.GetOriginByAddress(normalizedAddress) - } - return nil, err - } - return origin, nil -} - -func updateRoutesForOriginAddress(tx *gorm.DB, originID uint, address string) error { - var routes []*model.ProxyRoute - if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil { - return fmt.Errorf("query routes for origin update failed: %w", err) - } - for _, route := range routes { - rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address) - if err != nil { - return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err) - } - upstreams := make([]string, 0) - if strings.TrimSpace(route.Upstreams) != "" { - if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil { - return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err) - } - } - if len(upstreams) == 0 { - upstreams = append(upstreams, rewrittenOriginURL) - } else { - upstreams[0] = rewrittenOriginURL - } - upstreamsJSON, err := json.Marshal(upstreams) - if err != nil { - return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err) - } - if err := tx.Model(&model.ProxyRoute{}). - Where("id = ?", route.ID). - Updates(map[string]any{ - "origin_url": rewrittenOriginURL, - "upstreams": string(upstreamsJSON), - }).Error; err != nil { - return fmt.Errorf("update route %d origin address failed: %w", route.ID, err) - } - } - return nil -} - -func buildOriginViews(origins []*model.Origin) ([]OriginView, error) { - countRows, err := model.ListOriginRouteCounts() - if err != nil { - return nil, err - } - countMap := make(map[uint]int64, len(countRows)) - for _, row := range countRows { - countMap[row.OriginID] = row.RouteCount - } - views := make([]OriginView, 0, len(origins)) - for _, origin := range origins { - views = append(views, OriginView{ - ID: origin.ID, - Name: origin.Name, - Address: origin.Address, - Remark: origin.Remark, - RouteCount: countMap[origin.ID], - CreatedAt: origin.CreatedAt, - UpdatedAt: origin.UpdatedAt, - }) - } - return views, nil -} diff --git a/openflare-server/internal/service/origin_helpers.go b/openflare-server/internal/service/origin_helpers.go deleted file mode 100644 index 44f6c7b6..00000000 --- a/openflare-server/internal/service/origin_helpers.go +++ /dev/null @@ -1,170 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "net" - "net/url" - "strconv" - "strings" - "unicode" -) - -func normalizeOriginAddress(raw string) string { - return strings.ToLower(strings.TrimSpace(raw)) -} - -func validateOriginAddress(address string) error { - if address == "" { - return errors.New("源站地址不能为空") - } - if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") { - return errors.New("源站地址格式不合法") - } - if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") { - return errors.New("源站地址无需包含 IPv6 方括号") - } - if ip := net.ParseIP(address); ip != nil { - return nil - } - if len(address) > 253 { - return errors.New("源站地址格式不合法") - } - labels := strings.Split(address, ".") - for _, label := range labels { - if len(label) == 0 || len(label) > 63 { - return errors.New("源站地址格式不合法") - } - if label[0] == '-' || label[len(label)-1] == '-' { - return errors.New("源站地址格式不合法") - } - for _, r := range label { - if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { - continue - } - return errors.New("源站地址格式不合法") - } - } - return nil -} - -func normalizeOriginName(name string, address string) string { - normalized := strings.TrimSpace(name) - if normalized != "" { - return normalized - } - return address -} - -func normalizeOriginPort(raw string) (string, error) { - port := strings.TrimSpace(raw) - if port == "" { - return "", errors.New("端口不能为空") - } - value, err := strconv.Atoi(port) - if err != nil || value < 1 || value > 65535 { - return "", errors.New("端口格式不合法") - } - return strconv.Itoa(value), nil -} - -func normalizeOriginScheme(raw string) (string, error) { - scheme := strings.ToLower(strings.TrimSpace(raw)) - switch scheme { - case "http", "https": - return scheme, nil - default: - return "", errors.New("源站协议仅支持 http 或 https") - } -} - -func normalizeOriginURI(raw string) (string, error) { - uri := strings.TrimSpace(raw) - if uri == "" { - return "", nil - } - if strings.Contains(uri, "://") { - return "", errors.New("源站路径不能包含协议") - } - if !strings.HasPrefix(uri, "/") && !strings.HasPrefix(uri, "?") { - return "", errors.New("源站路径需以 / 或 ? 开头") - } - return uri, nil -} - -func formatOriginHost(address string, port string) string { - if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") { - return net.JoinHostPort(address, port) - } - return net.JoinHostPort(address, port) -} - -func buildOriginURLFromParts( - scheme string, - address string, - port string, - uri string, -) (string, error) { - normalizedScheme, err := normalizeOriginScheme(scheme) - if err != nil { - return "", err - } - normalizedAddress := normalizeOriginAddress(address) - if err := validateOriginAddress(normalizedAddress); err != nil { - return "", err - } - normalizedPort, err := normalizeOriginPort(port) - if err != nil { - return "", err - } - normalizedURI, err := normalizeOriginURI(uri) - if err != nil { - return "", err - } - - parsed := &url.URL{ - Scheme: normalizedScheme, - Host: formatOriginHost(normalizedAddress, normalizedPort), - } - if normalizedURI != "" { - if strings.HasPrefix(normalizedURI, "?") { - parsed.RawQuery = strings.TrimPrefix(normalizedURI, "?") - } else { - pathQuery := strings.SplitN(normalizedURI, "?", 2) - parsed.Path = pathQuery[0] - if len(pathQuery) > 1 { - parsed.RawQuery = pathQuery[1] - } - } - } - return parsed.String(), nil -} - -func extractOriginAddress(rawURL string) (string, error) { - parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) - if err != nil { - return "", fmt.Errorf("源站地址格式不合法: %w", err) - } - address := normalizeOriginAddress(parsed.Hostname()) - if err := validateOriginAddress(address); err != nil { - return "", err - } - return address, nil -} - -func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) { - parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL)) - if err != nil { - return "", fmt.Errorf("源站地址格式不合法: %w", err) - } - address := normalizeOriginAddress(newAddress) - if err := validateOriginAddress(address); err != nil { - return "", err - } - port := parsed.Port() - if port == "" { - return "", errors.New("源站地址缺少端口") - } - parsed.Host = formatOriginHost(address, port) - return parsed.String(), nil -} diff --git a/openflare-server/internal/service/origin_test.go b/openflare-server/internal/service/origin_test.go deleted file mode 100644 index 2ff21eb4..00000000 --- a/openflare-server/internal/service/origin_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package service - -import ( - "testing" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestCreateProxyRouteStructuredOriginAutoCreatesOrigin(t *testing.T) { - setupServiceTestDB(t) - - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "app.example.com", - OriginScheme: "https", - OriginAddress: "origin.internal", - OriginPort: "8443", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if route.OriginID == nil || *route.OriginID == 0 { - t.Fatal("expected route to be linked with an auto-created origin") - } - if route.OriginURL != "https://origin.internal:8443" { - t.Fatalf("unexpected route origin url: %s", route.OriginURL) - } - - origin, err := model.GetOriginByID(*route.OriginID) - if err != nil { - t.Fatalf("GetOriginByID failed: %v", err) - } - if origin.Address != "origin.internal" { - t.Fatalf("unexpected origin address: %s", origin.Address) - } -} - -func TestUpdateOriginRewritesLinkedRouteOriginURL(t *testing.T) { - setupServiceTestDB(t) - - origin, err := CreateOrigin(OriginInput{ - Name: "primary-origin", - Address: "origin-a.internal", - }) - if err != nil { - t.Fatalf("CreateOrigin failed: %v", err) - } - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "app.example.com", - OriginID: &origin.ID, - OriginScheme: "https", - OriginPort: "8443", - OriginURI: "/api", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - updatedOrigin, err := UpdateOrigin(origin.ID, OriginInput{ - Name: origin.Name, - Address: "origin-c.internal", - }) - if err != nil { - t.Fatalf("UpdateOrigin failed: %v", err) - } - if updatedOrigin.Address != "origin-c.internal" { - t.Fatalf("unexpected updated origin address: %s", updatedOrigin.Address) - } - - reloadedRoute, err := model.GetProxyRouteByID(route.ID) - if err != nil { - t.Fatalf("GetProxyRouteByID failed: %v", err) - } - if reloadedRoute.OriginURL != "https://origin-c.internal:8443/api" { - t.Fatalf("expected route origin url to be rewritten, got %s", reloadedRoute.OriginURL) - } - if reloadedRoute.Upstreams == "" || reloadedRoute.Upstreams == "[]" { - t.Fatalf("expected route upstreams to be preserved, got %s", reloadedRoute.Upstreams) - } -} - -func TestDeleteOriginRejectsReferencedOrigin(t *testing.T) { - setupServiceTestDB(t) - - origin, err := CreateOrigin(OriginInput{ - Address: "origin-a.internal", - }) - if err != nil { - t.Fatalf("CreateOrigin failed: %v", err) - } - if _, err = CreateProxyRoute(ProxyRouteInput{ - Domain: "app.example.com", - OriginID: &origin.ID, - OriginScheme: "https", - OriginPort: "443", - Enabled: true, - }); err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - - if err = DeleteOrigin(origin.ID); err == nil { - t.Fatal("expected referenced origin deletion to fail") - } -} diff --git a/openflare-server/internal/service/pages.go b/openflare-server/internal/service/pages.go deleted file mode 100644 index 12b50025..00000000 --- a/openflare-server/internal/service/pages.go +++ /dev/null @@ -1,834 +0,0 @@ -package service - -import ( - "archive/zip" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "mime/multipart" - "net/url" - "os" - "path" - "path/filepath" - "regexp" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -const ( - pagesMaxDeploymentFiles = 1000 - pagesMaxDeploymentBytes = 100 * 1024 * 1024 - defaultPagesEntryFile = "index.html" - defaultPagesFallbackPath = "/index.html" -) - -var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`) - -type PagesProjectInput struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - SPAFallbackEnabled bool `json:"spa_fallback_enabled"` - SPAFallbackPath string `json:"spa_fallback_path"` - APIProxyEnabled bool `json:"api_proxy_enabled"` - APIProxyPath string `json:"api_proxy_path"` - APIProxyPass string `json:"api_proxy_pass"` - APIProxyRewrite string `json:"api_proxy_rewrite"` - RootDir string `json:"root_dir"` - EntryFile string `json:"entry_file"` -} - -type PagesProjectView struct { - ID uint `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - SPAFallbackEnabled bool `json:"spa_fallback_enabled"` - SPAFallbackPath string `json:"spa_fallback_path"` - APIProxyEnabled bool `json:"api_proxy_enabled"` - APIProxyPath string `json:"api_proxy_path"` - APIProxyPass string `json:"api_proxy_pass"` - APIProxyRewrite string `json:"api_proxy_rewrite"` - RootDir string `json:"root_dir"` - EntryFile string `json:"entry_file"` - ActiveDeploymentID *uint `json:"active_deployment_id"` - ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"` - DeploymentCount int64 `json:"deployment_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type PagesDeploymentView struct { - ID uint `json:"id"` - ProjectID uint `json:"project_id"` - DeploymentNumber int `json:"deployment_number"` - Checksum string `json:"checksum"` - Status string `json:"status"` - FileCount int `json:"file_count"` - TotalSize int64 `json:"total_size"` - CreatedBy string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - ActivatedAt *time.Time `json:"activated_at"` -} - -type PagesDeploymentFileView struct { - ID uint `json:"id"` - DeploymentID uint `json:"deployment_id"` - Path string `json:"path"` - Size int64 `json:"size"` - Checksum string `json:"checksum"` - CreatedAt time.Time `json:"created_at"` -} - -type pagesDeploymentManifest struct { - Files []model.PagesDeploymentFile - FileCount int - TotalSize int64 - EntryFile string -} - -func ListPagesProjects() ([]*PagesProjectView, error) { - projects, err := model.ListPagesProjects() - if err != nil { - return nil, err - } - views := make([]*PagesProjectView, 0, len(projects)) - for _, project := range projects { - view, err := buildPagesProjectView(project) - if err != nil { - return nil, err - } - views = append(views, view) - } - return views, nil -} - -func GetPagesProject(id uint) (*PagesProjectView, error) { - project, err := model.GetPagesProjectByID(id) - if err != nil { - return nil, err - } - return buildPagesProjectView(project) -} - -func CreatePagesProject(input PagesProjectInput) (*PagesProjectView, error) { - project, err := buildPagesProject(nil, input) - if err != nil { - return nil, err - } - if err = model.DB.Create(project).Error; err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("Pages 项目标识已存在") - } - return nil, err - } - return buildPagesProjectView(project) -} - -func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, error) { - project, err := model.GetPagesProjectByID(id) - if err != nil { - return nil, err - } - project, err = buildPagesProject(project, input) - if err != nil { - return nil, err - } - if err = model.DB.Model(project).Updates(map[string]any{ - "name": project.Name, - "slug": project.Slug, - "description": project.Description, - "enabled": project.Enabled, - "spa_fallback_enabled": project.SPAFallbackEnabled, - "spa_fallback_path": project.SPAFallbackPath, - "api_proxy_enabled": project.APIProxyEnabled, - "api_proxy_path": project.APIProxyPath, - "api_proxy_pass": project.APIProxyPass, - "api_proxy_rewrite": project.APIProxyRewrite, - "root_dir": project.RootDir, - "entry_file": project.EntryFile, - }).Error; err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("Pages 项目标识已存在") - } - return nil, err - } - return buildPagesProjectView(project) -} - -func DeletePagesProject(id uint) error { - project, err := model.GetPagesProjectByID(id) - if err != nil { - return err - } - var routeCount int64 - if err = model.DB.Model(&model.ProxyRoute{}).Where("pages_project_id = ?", project.ID).Count(&routeCount).Error; err != nil { - return err - } - if routeCount > 0 { - return errors.New("Pages 项目已被规则引用,不能删除") - } - deployments, err := model.ListPagesDeployments(project.ID) - if err != nil { - return err - } - return model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("deployment_id IN (?)", tx.Model(&model.PagesDeployment{}).Select("id").Where("project_id = ?", project.ID)).Delete(&model.PagesDeploymentFile{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", project.ID).Delete(&model.PagesDeployment{}).Error; err != nil { - return err - } - if err := tx.Delete(project).Error; err != nil { - return err - } - for _, deployment := range deployments { - _ = os.Remove(deployment.ArtifactPath) - } - return nil - }) -} - -func ListPagesProjectDeployments(projectID uint) ([]*PagesDeploymentView, error) { - if _, err := model.GetPagesProjectByID(projectID); err != nil { - return nil, err - } - deployments, err := model.ListPagesDeployments(projectID) - if err != nil { - return nil, err - } - views := make([]*PagesDeploymentView, 0, len(deployments)) - for _, deployment := range deployments { - views = append(views, buildPagesDeploymentView(deployment)) - } - return views, nil -} - -func ListPagesDeploymentFiles(deploymentID uint) ([]*PagesDeploymentFileView, error) { - if _, err := model.GetPagesDeploymentByID(deploymentID); err != nil { - return nil, err - } - files, err := model.ListPagesDeploymentFiles(deploymentID) - if err != nil { - return nil, err - } - views := make([]*PagesDeploymentFileView, 0, len(files)) - for _, file := range files { - views = append(views, &PagesDeploymentFileView{ - ID: file.ID, - DeploymentID: file.DeploymentID, - Path: file.Path, - Size: file.Size, - Checksum: file.Checksum, - CreatedAt: file.CreatedAt, - }) - } - return views, nil -} - -func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, rootDir string, entryFile string, createdBy string) (*PagesDeploymentView, error) { - project, err := model.GetPagesProjectByID(projectID) - if err != nil { - return nil, err - } - if fileHeader == nil { - return nil, errors.New("缺少 Pages 部署包") - } - if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") { - return nil, errors.New("Pages 部署包必须是 .zip 文件") - } - rootDir, err = validateAndNormalizePagesRootDir(project.RootDir) - if err != nil { - return nil, err - } - entryFile = normalizePagesEntryFile(project.EntryFile) - tempPath, checksum, err := persistPagesUploadTemp(fileHeader) - if err != nil { - return nil, err - } - defer os.Remove(tempPath) - manifest, err := inspectPagesZip(tempPath, rootDir, entryFile) - if err != nil { - return nil, err - } - artifactPath, err := pagesArtifactPath(project.Slug, checksum) - if err != nil { - return nil, err - } - if err = os.MkdirAll(filepath.Dir(artifactPath), 0o755); err != nil { - return nil, fmt.Errorf("创建 Pages 存储目录失败: %w", err) - } - if err = copyFile(tempPath, artifactPath); err != nil { - return nil, err - } - deployment := &model.PagesDeployment{} - err = model.DB.Transaction(func(tx *gorm.DB) error { - var maxNumber int - if err := tx.Model(&model.PagesDeployment{}). - Where("project_id = ?", project.ID). - Select("COALESCE(MAX(deployment_number), 0)"). - Scan(&maxNumber).Error; err != nil { - return err - } - deployment = &model.PagesDeployment{ - ProjectID: project.ID, - DeploymentNumber: maxNumber + 1, - Checksum: checksum, - Status: model.PagesDeploymentStatusUploaded, - ArtifactPath: artifactPath, - FileCount: manifest.FileCount, - TotalSize: manifest.TotalSize, - CreatedBy: strings.TrimSpace(createdBy), - } - if err := tx.Create(deployment).Error; err != nil { - return err - } - for index := range manifest.Files { - manifest.Files[index].DeploymentID = deployment.ID - } - if len(manifest.Files) > 0 { - if err := tx.Create(&manifest.Files).Error; err != nil { - return err - } - } - return nil - }) - if err != nil { - _ = os.Remove(artifactPath) - return nil, err - } - return buildPagesDeploymentView(deployment), nil -} - -func validateAndNormalizePagesRootDir(raw string) (string, error) { - value := strings.TrimSpace(raw) - if value == "" { - return "", nil - } - if len(value) > 512 { - return "", errors.New("Pages 根目录长度不能超过 512") - } - if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") { - return "", errors.New("Pages 根目录包含不支持的字符") - } - for _, r := range value { - if r <= 0x20 || r == 0x7f { - return "", errors.New("Pages 根目录不能包含空白或控制字符") - } - } - cleaned := path.Clean(filepath.ToSlash(value)) - if cleaned == "." || cleaned == "/" { - return "", nil - } - for _, segment := range strings.Split(cleaned, "/") { - if segment == "." || segment == ".." { - return "", errors.New("Pages 根目录不能包含 . 或 .. 路径段") - } - } - return strings.TrimPrefix(cleaned, "/"), nil -} - -func ActivatePagesDeployment(projectID uint, deploymentID uint) (*PagesProjectView, error) { - project, err := model.GetPagesProjectByID(projectID) - if err != nil { - return nil, err - } - deployment, err := model.GetPagesDeploymentByID(deploymentID) - if err != nil { - return nil, err - } - if deployment.ProjectID != project.ID { - return nil, errors.New("Pages 部署不属于该项目") - } - now := time.Now() - if err = model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.PagesDeployment{}). - Where("project_id = ?", project.ID). - Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil { - return err - } - if err := tx.Model(deployment).Updates(map[string]any{ - "status": model.PagesDeploymentStatusActive, - "activated_at": &now, - }).Error; err != nil { - return err - } - return tx.Model(project).Updates(map[string]any{ - "active_deployment_id": deployment.ID, - }).Error - }); err != nil { - return nil, err - } - return GetPagesProject(project.ID) -} - -func DeletePagesDeployment(projectID uint, deploymentID uint) error { - project, err := model.GetPagesProjectByID(projectID) - if err != nil { - return err - } - deployment, err := model.GetPagesDeploymentByID(deploymentID) - if err != nil { - return err - } - if deployment.ProjectID != project.ID { - return errors.New("Pages 部署不属于该项目") - } - if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID == deployment.ID { - return errors.New("不能删除当前激活的 Pages 部署") - } - return model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("deployment_id = ?", deployment.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil { - return err - } - if err := tx.Delete(deployment).Error; err != nil { - return err - } - _ = os.Remove(deployment.ArtifactPath) - return nil - }) -} - -func GetPagesDeploymentPackagePath(deploymentID uint) (string, string, error) { - deployment, err := model.GetPagesDeploymentByID(deploymentID) - if err != nil { - return "", "", err - } - if err = ensurePagesDeploymentInActiveSnapshot(deployment.ID); err != nil { - return "", "", err - } - if strings.TrimSpace(deployment.ArtifactPath) == "" { - return "", "", errors.New("Pages 部署包路径为空") - } - if _, err = os.Stat(deployment.ArtifactPath); err != nil { - return "", "", fmt.Errorf("Pages 部署包不存在: %w", err) - } - return deployment.ArtifactPath, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), nil -} - -func ensurePagesDeploymentInActiveSnapshot(deploymentID uint) error { - version, err := model.GetActiveConfigVersion() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("Pages 部署尚未进入激活配置") - } - return err - } - snapshot, err := parseSnapshotDocument(version.SnapshotJSON) - if err != nil { - return err - } - for _, route := range snapshot.Routes { - if route.UpstreamType != "pages" || route.PagesDeployment == nil { - continue - } - if route.PagesDeployment.DeploymentID == deploymentID { - return nil - } - } - return errors.New("Pages 部署尚未进入激活配置") -} - -func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*model.PagesProject, error) { - name := strings.TrimSpace(input.Name) - if name == "" { - return nil, errors.New("Pages 项目名称不能为空") - } - slug := normalizePagesSlug(input.Slug) - if slug == "" { - slug = normalizePagesSlug(name) - } - if !pagesSlugPattern.MatchString(slug) { - return nil, errors.New("Pages 项目标识只能包含小写字母、数字和连字符") - } - if project == nil { - project = &model.PagesProject{} - } - project.Name = name - project.Slug = slug - project.Description = strings.TrimSpace(input.Description) - project.Enabled = input.Enabled - project.SPAFallbackEnabled = input.SPAFallbackEnabled - fallbackPath, err := normalizePagesFallbackPath(input.SPAFallbackPath) - if err != nil { - return nil, err - } - project.SPAFallbackPath = fallbackPath - - project.APIProxyEnabled = input.APIProxyEnabled - apiProxyPath := strings.TrimSpace(input.APIProxyPath) - apiProxyPass := strings.TrimSpace(input.APIProxyPass) - apiProxyRewrite := strings.TrimSpace(input.APIProxyRewrite) - - if project.APIProxyEnabled { - if apiProxyPath == "" { - return nil, errors.New("启用 API 反代时,匹配路径不能为空") - } - if !strings.HasPrefix(apiProxyPath, "/") { - return nil, errors.New("API 反代匹配路径必须以 '/' 开头") - } - if apiProxyPass == "" { - return nil, errors.New("启用 API 反代时,后端服务地址不能为空") - } - parsedURL, err := url.Parse(apiProxyPass) - if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" { - return nil, errors.New("API 反代后端服务地址必须是有效的 HTTP/HTTPS URL") - } - } - project.APIProxyPath = apiProxyPath - project.APIProxyPass = apiProxyPass - project.APIProxyRewrite = apiProxyRewrite - - rootDir, err := validateAndNormalizePagesRootDir(input.RootDir) - if err != nil { - return nil, err - } - project.RootDir = rootDir - project.EntryFile = normalizePagesEntryFile(input.EntryFile) - - return project, nil -} - -func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, error) { - if project == nil { - return nil, errors.New("Pages 项目为空") - } - view := &PagesProjectView{ - ID: project.ID, - Name: project.Name, - Slug: project.Slug, - Description: project.Description, - Enabled: project.Enabled, - SPAFallbackEnabled: project.SPAFallbackEnabled, - SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath), - APIProxyEnabled: project.APIProxyEnabled, - APIProxyPath: project.APIProxyPath, - APIProxyPass: project.APIProxyPass, - APIProxyRewrite: project.APIProxyRewrite, - RootDir: project.RootDir, - EntryFile: project.EntryFile, - ActiveDeploymentID: project.ActiveDeploymentID, - CreatedAt: project.CreatedAt, - UpdatedAt: project.UpdatedAt, - } - if err := model.DB.Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&view.DeploymentCount).Error; err != nil { - return nil, err - } - if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID != 0 { - deployment, err := model.GetPagesDeploymentByID(*project.ActiveDeploymentID) - if err == nil { - view.ActiveDeployment = buildPagesDeploymentView(deployment) - } - } - return view, nil -} - -func buildPagesDeploymentView(deployment *model.PagesDeployment) *PagesDeploymentView { - if deployment == nil { - return nil - } - return &PagesDeploymentView{ - ID: deployment.ID, - ProjectID: deployment.ProjectID, - DeploymentNumber: deployment.DeploymentNumber, - Checksum: deployment.Checksum, - Status: deployment.Status, - FileCount: deployment.FileCount, - TotalSize: deployment.TotalSize, - CreatedBy: deployment.CreatedBy, - CreatedAt: deployment.CreatedAt, - ActivatedAt: deployment.ActivatedAt, - } -} - -func normalizePagesSlug(raw string) string { - value := strings.ToLower(strings.TrimSpace(raw)) - var builder strings.Builder - lastDash := false - for _, r := range value { - valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') - if valid { - builder.WriteRune(r) - lastDash = false - continue - } - if !lastDash { - builder.WriteByte('-') - lastDash = true - } - } - return strings.Trim(builder.String(), "-") -} - -func normalizePagesFallbackPath(raw string) (string, error) { - value := strings.TrimSpace(raw) - if value == "" { - value = defaultPagesFallbackPath - } - if len(value) > 512 { - return "", errors.New("SPA fallback 回退路径长度不能超过 512") - } - if !strings.HasPrefix(value, "/") { - return "", errors.New("SPA fallback 回退路径必须以 / 开头") - } - if value == "/" || strings.HasSuffix(value, "/") { - return "", errors.New("SPA fallback 回退路径必须指向具体文件") - } - if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") { - return "", errors.New("SPA fallback 回退路径包含不支持的字符") - } - for _, r := range value { - if r <= 0x20 || r == 0x7f { - return "", errors.New("SPA fallback 回退路径不能包含空白或控制字符") - } - } - for _, segment := range strings.Split(value, "/") { - if segment == "." || segment == ".." { - return "", errors.New("SPA fallback 回退路径不能包含 . 或 .. 路径段") - } - } - cleaned := path.Clean(value) - if cleaned == "." || !strings.HasPrefix(cleaned, "/") { - return "", errors.New("SPA fallback 回退路径不合法") - } - if cleaned == "/" || strings.HasSuffix(cleaned, "/") { - return "", errors.New("SPA fallback 回退路径必须指向具体文件") - } - return cleaned, nil -} - -func normalizeStoredPagesFallbackPath(value string) string { - normalized, err := normalizePagesFallbackPath(value) - if err != nil { - return defaultPagesFallbackPath - } - return normalized -} - -func normalizePagesEntryFile(raw string) string { - value := path.Clean(strings.TrimSpace(filepath.ToSlash(raw))) - if value == "." || value == "/" { - return defaultPagesEntryFile - } - return strings.TrimPrefix(value, "/") -} - -func persistPagesUploadTemp(fileHeader *multipart.FileHeader) (string, string, error) { - file, err := fileHeader.Open() - if err != nil { - return "", "", err - } - defer file.Close() - temp, err := os.CreateTemp("", "openflare-pages-*.zip") - if err != nil { - return "", "", err - } - defer temp.Close() - hash := sha256.New() - limited := io.LimitReader(file, pagesMaxDeploymentBytes+1) - written, err := io.Copy(io.MultiWriter(temp, hash), limited) - if err != nil { - _ = os.Remove(temp.Name()) - return "", "", err - } - if written > pagesMaxDeploymentBytes { - _ = os.Remove(temp.Name()) - return "", "", fmt.Errorf("Pages 部署包不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024) - } - return temp.Name(), hex.EncodeToString(hash.Sum(nil)), nil -} - -func findCommonRootPrefix(files []*zip.File) (string, error) { - var firstFilePath string - hasMultipleFiles := false - for _, item := range files { - normalizedPath, skip, err := normalizePagesZipPath(item.Name) - if err != nil { - return "", err - } - if skip { - continue - } - if firstFilePath == "" { - firstFilePath = normalizedPath - } else { - hasMultipleFiles = true - } - } - if firstFilePath == "" { - return "", nil - } - parts := strings.Split(firstFilePath, "/") - if len(parts) <= 1 { - return "", nil - } - commonPrefix := parts[0] + "/" - if hasMultipleFiles { - for _, item := range files { - normalizedPath, skip, err := normalizePagesZipPath(item.Name) - if err != nil { - return "", err - } - if skip { - continue - } - if !strings.HasPrefix(normalizedPath, commonPrefix) { - return "", nil - } - } - } - return commonPrefix, nil -} - -func inspectPagesZip(zipPath string, rootDir string, entryFile string) (*pagesDeploymentManifest, error) { - reader, err := zip.OpenReader(zipPath) - if err != nil { - return nil, errors.New("Pages 部署包不是有效 zip 文件") - } - defer reader.Close() - - commonPrefix, err := findCommonRootPrefix(reader.File) - if err != nil { - return nil, err - } - - manifest := &pagesDeploymentManifest{ - Files: []model.PagesDeploymentFile{}, - EntryFile: entryFile, - } - targetEntryPath := entryFile - if rootDir != "" { - targetEntryPath = path.Join(rootDir, entryFile) - } - entrySeen := false - for _, item := range reader.File { - normalizedPath, skip, err := normalizePagesZipPath(item.Name) - if err != nil { - return nil, err - } - if skip { - continue - } - - if commonPrefix != "" { - normalizedPath = strings.TrimPrefix(normalizedPath, commonPrefix) - } - - if item.FileInfo().Mode()&os.ModeSymlink != 0 { - return nil, fmt.Errorf("Pages 部署包不支持符号链接: %s", normalizedPath) - } - if item.UncompressedSize64 > pagesMaxDeploymentBytes { - return nil, fmt.Errorf("Pages 文件过大: %s", normalizedPath) - } - manifest.FileCount++ - if manifest.FileCount > pagesMaxDeploymentFiles { - return nil, fmt.Errorf("Pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles) - } - manifest.TotalSize += int64(item.UncompressedSize64) - if manifest.TotalSize > pagesMaxDeploymentBytes { - return nil, fmt.Errorf("Pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024) - } - checksum, err := checksumZipFile(item) - if err != nil { - return nil, err - } - if normalizedPath == targetEntryPath { - entrySeen = true - } - manifest.Files = append(manifest.Files, model.PagesDeploymentFile{ - Path: normalizedPath, - Size: int64(item.UncompressedSize64), - Checksum: checksum, - }) - } - if manifest.FileCount == 0 { - return nil, errors.New("Pages 部署包不能为空") - } - if !entrySeen { - return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", targetEntryPath) - } - return manifest, nil -} - -func normalizePagesZipPath(raw string) (string, bool, error) { - name := strings.TrimSpace(filepath.ToSlash(raw)) - if name == "" { - return "", true, nil - } - if strings.HasSuffix(name, "/") { - return "", true, nil - } - if strings.HasPrefix(name, "/") || path.IsAbs(name) { - return "", false, fmt.Errorf("Pages 部署包不能包含绝对路径: %s", raw) - } - cleaned := path.Clean(name) - if cleaned == "." { - return "", true, nil - } - if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") { - return "", false, fmt.Errorf("Pages 部署包路径不能逃逸目录: %s", raw) - } - return cleaned, false, nil -} - -func checksumZipFile(item *zip.File) (string, error) { - file, err := item.Open() - if err != nil { - return "", err - } - defer file.Close() - hash := sha256.New() - if _, err = io.Copy(hash, file); err != nil { - return "", err - } - return hex.EncodeToString(hash.Sum(nil)), nil -} - -func pagesArtifactPath(projectSlug string, checksum string) (string, error) { - root, err := pagesStorageRoot() - if err != nil { - return "", err - } - return filepath.Join(root, "artifacts", projectSlug, checksum+".zip"), nil -} - -func pagesStorageRoot() (string, error) { - if common.SQLDSN != "" { - return filepath.Abs(filepath.Join("data", "pages")) - } - dbPath := strings.TrimSpace(common.SQLitePath) - if dbPath == "" { - return filepath.Abs(filepath.Join("data", "pages")) - } - dir := filepath.Dir(dbPath) - if dir == "." || dir == "" { - dir = "data" - } - return filepath.Abs(filepath.Join(dir, "pages")) -} - -func copyFile(src string, dst string) error { - input, err := os.Open(src) - if err != nil { - return err - } - defer input.Close() - output, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) - if err != nil { - return err - } - defer output.Close() - if _, err = io.Copy(output, input); err != nil { - return err - } - return output.Sync() -} diff --git a/openflare-server/internal/service/pages_test.go b/openflare-server/internal/service/pages_test.go deleted file mode 100644 index c6da0f9b..00000000 --- a/openflare-server/internal/service/pages_test.go +++ /dev/null @@ -1,420 +0,0 @@ -package service - -import ( - "archive/zip" - "bytes" - "fmt" - "mime/multipart" - "net/http/httptest" - "strings" - "testing" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) { - setupServiceTestDB(t) - - project, err := CreatePagesProject(PagesProjectInput{ - Name: "Marketing Site", - Slug: "marketing-site", - Enabled: true, - SPAFallbackEnabled: true, - SPAFallbackPath: "/app.html", - }) - if err != nil { - t.Fatalf("CreatePagesProject failed: %v", err) - } - uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{ - "index.html": "

Hello Pages

", - "assets/app.js": "console.log('pages')", - "assets/style.css": "body{color:#111}", - })) - deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "", "index.html", "root") - if err != nil { - t.Fatalf("UploadPagesDeployment failed: %v", err) - } - if deployment.FileCount != 3 || deployment.TotalSize == 0 { - t.Fatalf("unexpected deployment manifest: %+v", deployment) - } - project, err = ActivatePagesDeployment(project.ID, deployment.ID) - if err != nil { - t.Fatalf("ActivatePagesDeployment failed: %v", err) - } - if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID != deployment.ID { - t.Fatalf("expected active deployment %d, got %+v", deployment.ID, project.ActiveDeploymentID) - } - - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "pages.example.com", - Enabled: true, - UpstreamType: "pages", - PagesProjectID: &project.ID, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if route.UpstreamType != "pages" || route.PagesProjectID == nil || *route.PagesProjectID != project.ID { - t.Fatalf("expected route to bind Pages project, got %+v", route) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.SnapshotJSON, `"upstream_type":"pages"`) { - t.Fatalf("expected snapshot to include pages route, got %s", result.Version.SnapshotJSON) - } - if !strings.Contains(result.Version.SnapshotJSON, `"deployment_id":`) { - t.Fatalf("expected snapshot to include pages deployment, got %s", result.Version.SnapshotJSON) - } - if !strings.Contains(result.Version.RenderedConfig, "root \"__OPENFLARE_PAGES_DIR__/deployments/") { - t.Fatalf("expected rendered config to use pages dir placeholder, got:\n%s", result.Version.RenderedConfig) - } - if !strings.Contains(result.Version.SnapshotJSON, `"spa_fallback_path":"/app.html"`) { - t.Fatalf("expected snapshot to include custom SPA fallback path, got %s", result.Version.SnapshotJSON) - } - if !strings.Contains(result.Version.RenderedConfig, "try_files $uri $uri/ /app.html;") { - t.Fatalf("expected SPA fallback try_files, got:\n%s", result.Version.RenderedConfig) - } - if strings.Contains(result.Version.RenderedConfig, "proxy_pass") { - t.Fatalf("Pages route must not render proxy_pass, got:\n%s", result.Version.RenderedConfig) - } -} - -func TestPagesProjectRejectsUnsafeFallbackPath(t *testing.T) { - setupServiceTestDB(t) - - _, err := CreatePagesProject(PagesProjectInput{ - Name: "Unsafe Fallback", - Slug: "unsafe-fallback", - Enabled: true, - SPAFallbackEnabled: true, - SPAFallbackPath: "/index.html; proxy_pass http://evil", - }) - if err == nil || !strings.Contains(err.Error(), "回退路径") { - t.Fatalf("expected unsafe SPA fallback path rejection, got %v", err) - } -} - -func TestUploadPagesDeploymentRejectsZipSlip(t *testing.T) { - setupServiceTestDB(t) - - project, err := CreatePagesProject(PagesProjectInput{ - Name: "Unsafe Site", - Slug: "unsafe-site", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreatePagesProject failed: %v", err) - } - _, err = UploadPagesDeployment(project.ID, multipartFileHeader(t, "bad.zip", testPagesZip(t, map[string]string{ - "../escape.html": "bad", - "index.html": "ok", - })), "", "index.html", "root") - if err == nil || !strings.Contains(err.Error(), "逃逸目录") { - t.Fatalf("expected zip-slip rejection, got %v", err) - } -} - -func TestPagesRouteRequiresActiveDeployment(t *testing.T) { - setupServiceTestDB(t) - - project, err := CreatePagesProject(PagesProjectInput{ - Name: "Draft Site", - Slug: "draft-site", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreatePagesProject failed: %v", err) - } - if _, err = CreateProxyRoute(ProxyRouteInput{ - Domain: "draft.example.com", - Enabled: true, - UpstreamType: "pages", - PagesProjectID: &project.ID, - }); err == nil || !strings.Contains(err.Error(), "没有激活部署") { - t.Fatalf("expected active deployment validation, got %v", err) - } -} - -func TestPagesDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) { - setupServiceTestDB(t) - - project, err := CreatePagesProject(PagesProjectInput{Name: "Published Site", Slug: "published-site", Enabled: true}) - if err != nil { - t.Fatalf("CreatePagesProject failed: %v", err) - } - deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{ - "index.html": "ok", - })), "", "index.html", "root") - if err != nil { - t.Fatalf("UploadPagesDeployment failed: %v", err) - } - if _, err = ActivatePagesDeployment(project.ID, deployment.ID); err != nil { - t.Fatalf("ActivatePagesDeployment failed: %v", err) - } - if _, _, err = GetPagesDeploymentPackagePath(deployment.ID); err == nil || !strings.Contains(err.Error(), "激活配置") { - t.Fatalf("expected package download to require active config, got %v", err) - } - if _, err = CreateProxyRoute(ProxyRouteInput{ - Domain: "published.example.com", - Enabled: true, - UpstreamType: "pages", - PagesProjectID: &project.ID, - }); err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if _, err = PublishConfigVersion("root", false); err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - filePath, fileName, err := GetPagesDeploymentPackagePath(deployment.ID) - if err != nil { - t.Fatalf("GetPagesDeploymentPackagePath failed after publish: %v", err) - } - if filePath == "" || fileName == "" { - t.Fatalf("expected package path and file name, got path=%q name=%q", filePath, fileName) - } -} - -func testPagesZip(t *testing.T, files map[string]string) []byte { - t.Helper() - var buffer bytes.Buffer - writer := zip.NewWriter(&buffer) - for name, content := range files { - file, err := writer.Create(name) - if err != nil { - t.Fatalf("create zip entry failed: %v", err) - } - if _, err := file.Write([]byte(content)); err != nil { - t.Fatalf("write zip entry failed: %v", err) - } - } - if err := writer.Close(); err != nil { - t.Fatalf("close zip failed: %v", err) - } - return buffer.Bytes() -} - -func multipartFileHeader(t *testing.T, fileName string, content []byte) *multipart.FileHeader { - t.Helper() - var body bytes.Buffer - writer := multipart.NewWriter(&body) - part, err := writer.CreateFormFile("package", fileName) - if err != nil { - t.Fatalf("CreateFormFile failed: %v", err) - } - if _, err = part.Write(content); err != nil { - t.Fatalf("write multipart file failed: %v", err) - } - if err = writer.Close(); err != nil { - t.Fatalf("close multipart writer failed: %v", err) - } - req := httptest.NewRequest("POST", "/", &body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - if err = req.ParseMultipartForm(int64(len(content)) + 1024); err != nil { - t.Fatalf("ParseMultipartForm failed: %v", err) - } - file, header, err := req.FormFile("package") - if err != nil { - t.Fatalf("FormFile failed: %v", err) - } - file.Close() - return header -} - -func TestDeletePagesDeploymentRejectsActiveDeployment(t *testing.T) { - setupServiceTestDB(t) - - project, err := CreatePagesProject(PagesProjectInput{Name: "Active", Slug: "active", Enabled: true}) - if err != nil { - t.Fatalf("CreatePagesProject failed: %v", err) - } - deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{"index.html": "ok"})), "", "index.html", "root") - if err != nil { - t.Fatalf("UploadPagesDeployment failed: %v", err) - } - if _, err = ActivatePagesDeployment(project.ID, deployment.ID); err != nil { - t.Fatalf("ActivatePagesDeployment failed: %v", err) - } - if err = DeletePagesDeployment(project.ID, deployment.ID); err == nil { - t.Fatal("expected active deployment deletion to fail") - } - var stored model.PagesDeployment - if err = model.DB.First(&stored, deployment.ID).Error; err != nil { - t.Fatalf("expected active deployment to remain: %v", err) - } -} - -func TestUploadPagesDeploymentWithTopLevelFolder(t *testing.T) { - setupServiceTestDB(t) - - project, err := CreatePagesProject(PagesProjectInput{ - Name: "Folder Site", - Slug: "folder-site", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreatePagesProject failed: %v", err) - } - // Upload a zip with all files inside a top-level directory "Speed-Test-source/" - uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{ - "Speed-Test-source/index.html": "

Hello Pages

", - "Speed-Test-source/assets/app.js": "console.log('pages')", - })) - deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "", "index.html", "root") - if err != nil { - t.Fatalf("UploadPagesDeployment with folder failed: %v", err) - } - if deployment.FileCount != 2 { - t.Fatalf("expected 2 files, got %d", deployment.FileCount) - } - if project.EntryFile != "index.html" { - t.Fatalf("expected EntryFile to be index.html, got %q", project.EntryFile) - } -} - -func TestPagesProjectAPIProxyValidation(t *testing.T) { - setupServiceTestDB(t) - - // 1. Invalid configuration: enabled but empty fields - _, err := CreatePagesProject(PagesProjectInput{ - Name: "API Proxy 1", - Enabled: true, - APIProxyEnabled: true, - }) - if err == nil || !strings.Contains(err.Error(), "匹配路径不能为空") { - t.Fatalf("expected error for empty match path, got: %v", err) - } - - // 2. Invalid path: must start with '/' - _, err = CreatePagesProject(PagesProjectInput{ - Name: "API Proxy 2", - Enabled: true, - APIProxyEnabled: true, - APIProxyPath: "api", - APIProxyPass: "http://127.0.0.1:8080", - }) - if err == nil || !strings.Contains(err.Error(), "必须以 '/' 开头") { - t.Fatalf("expected error for path not starting with /, got: %v", err) - } - - // 3. Invalid target URL - _, err = CreatePagesProject(PagesProjectInput{ - Name: "API Proxy 3", - Enabled: true, - APIProxyEnabled: true, - APIProxyPath: "/api", - APIProxyPass: "127.0.0.1:8080", - }) - if err == nil || !strings.Contains(err.Error(), "有效的 HTTP/HTTPS URL") { - t.Fatalf("expected error for invalid pass URL, got: %v", err) - } - - // 4. Valid configuration - project, err := CreatePagesProject(PagesProjectInput{ - Name: "API Proxy Valid", - Enabled: true, - APIProxyEnabled: true, - APIProxyPath: "/api", - APIProxyPass: "http://127.0.0.1:8080", - APIProxyRewrite: "/", - }) - if err != nil { - t.Fatalf("unexpected error creating valid project: %v", err) - } - if !project.APIProxyEnabled || project.APIProxyPath != "/api" || project.APIProxyPass != "http://127.0.0.1:8080" || project.APIProxyRewrite != "/" { - t.Fatalf("unexpected project state: %+v", project) - } -} - -func TestUploadPagesDeploymentWithRootDir(t *testing.T) { - setupServiceTestDB(t) - - project, err := CreatePagesProject(PagesProjectInput{ - Name: "App Site", - Slug: "app-site", - Enabled: true, - RootDir: "build", - EntryFile: "index.html", - }) - if err != nil { - t.Fatalf("CreatePagesProject failed: %v", err) - } - - // 1. Upload a zip with files inside a subfolder. - uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{ - "build/index.html": "

App Root

", - "build/static/bundle.js": "console.log('app')", - "README.md": "README info", - })) - deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "build", "index.html", "root") - if err != nil { - t.Fatalf("UploadPagesDeployment with rootDir failed: %v", err) - } - if deployment.FileCount != 3 { - t.Fatalf("expected 3 files, got %d", deployment.FileCount) - } - if project.RootDir != "build" { - t.Fatalf("expected RootDir to be 'build', got %q", project.RootDir) - } - if project.EntryFile != "index.html" { - t.Fatalf("expected EntryFile to be 'index.html', got %q", project.EntryFile) - } - - // 2. Update project configuration to a wrong entry file relative to root directory, upload should fail - project, err = UpdatePagesProject(project.ID, PagesProjectInput{ - Name: "App Site", - Slug: "app-site", - Enabled: true, - RootDir: "build", - EntryFile: "missing.html", - }) - if err != nil { - t.Fatalf("UpdatePagesProject failed: %v", err) - } - _, err = UploadPagesDeployment(project.ID, uploadHeader, "build", "missing.html", "root") - if err == nil || !strings.Contains(err.Error(), "缺少入口文件") { - t.Fatalf("expected failure for missing entry file, got %v", err) - } - - // Revert to correct config for snapshot check - project, err = UpdatePagesProject(project.ID, PagesProjectInput{ - Name: "App Site", - Slug: "app-site", - Enabled: true, - RootDir: "build", - EntryFile: "index.html", - }) - if err != nil { - t.Fatalf("UpdatePagesProject failed: %v", err) - } - - // 3. Test config snapshot LocalRoot path rendering - project, err = ActivatePagesDeployment(project.ID, deployment.ID) - if err != nil { - t.Fatalf("ActivatePagesDeployment failed: %v", err) - } - _, err = CreateProxyRoute(ProxyRouteInput{ - Domain: "app.example.com", - Enabled: true, - UpstreamType: "pages", - PagesProjectID: &project.ID, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - // Verify LocalRoot contains the rootDir - expectedLocalRoot := fmt.Sprintf("deployments/%d/current/build", deployment.ID) - if !strings.Contains(result.Version.SnapshotJSON, expectedLocalRoot) { - t.Fatalf("expected snapshot JSON to include %q, got %s", expectedLocalRoot, result.Version.SnapshotJSON) - } - - if !strings.Contains(result.Version.RenderedConfig, "current/build") { - t.Fatalf("expected rendered config to point to current/build, got:\n%s", result.Version.RenderedConfig) - } -} diff --git a/openflare-server/internal/service/proxy_route.go b/openflare-server/internal/service/proxy_route.go deleted file mode 100644 index 0046c85e..00000000 --- a/openflare-server/internal/service/proxy_route.go +++ /dev/null @@ -1,1325 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "net" - "net/url" - "regexp" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) -var proxyRouteLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`) - -const ( - proxyRouteCachePolicyURL = "url" - proxyRouteCachePolicySuffix = "suffix" - proxyRouteCachePolicyPathPrefix = "path_prefix" - proxyRouteCachePolicyPathExact = "path_exact" -) - -type ProxyRouteCustomHeaderInput struct { - Key string `json:"key"` - Value string `json:"value"` -} - -type ProxyRouteInput struct { - SiteName string `json:"site_name"` - Domain string `json:"domain"` - Domains []string `json:"domains"` - OriginID *uint `json:"origin_id"` - OriginURL string `json:"origin_url"` - OriginScheme string `json:"origin_scheme"` - OriginAddress string `json:"origin_address"` - OriginPort string `json:"origin_port"` - OriginURI string `json:"origin_uri"` - OriginHost string `json:"origin_host"` - Upstreams []string `json:"upstreams"` - Enabled bool `json:"enabled"` - EnableHTTPS bool `json:"enable_https"` - CertID *uint `json:"cert_id"` - CertIDs []uint `json:"cert_ids"` - DomainCertIDs []uint `json:"domain_cert_ids"` - RedirectHTTP bool `json:"redirect_http"` - LimitConnPerServer int `json:"limit_conn_per_server"` - LimitConnPerIP int `json:"limit_conn_per_ip"` - LimitRate string `json:"limit_rate"` - CacheEnabled bool `json:"cache_enabled"` - CachePolicy string `json:"cache_policy"` - CacheRules []string `json:"cache_rules"` - CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"` - BasicAuthEnabled bool `json:"basic_auth_enabled"` - BasicAuthUsername string `json:"basic_auth_username"` - BasicAuthPassword string `json:"basic_auth_password"` - Remark string `json:"remark"` - UpstreamType string `json:"upstream_type"` - TunnelNodeID *uint `json:"tunnel_node_id"` - TunnelID *uint `json:"tunnel_id"` - TunnelTargetAddr string `json:"tunnel_target_addr"` - TunnelTargetProtocol string `json:"tunnel_target_protocol"` - PagesProjectID *uint `json:"pages_project_id"` -} - -type ProxyRouteView struct { - ID uint `json:"id"` - SiteName string `json:"site_name"` - Domain string `json:"domain"` - Domains []string `json:"domains"` - PrimaryDomain string `json:"primary_domain"` - DomainCount int `json:"domain_count"` - OriginID *uint `json:"origin_id"` - OriginURL string `json:"origin_url"` - OriginHost string `json:"origin_host"` - Upstreams string `json:"upstreams"` - UpstreamList []string `json:"upstream_list"` - Enabled bool `json:"enabled"` - EnableHTTPS bool `json:"enable_https"` - CertID *uint `json:"cert_id"` - CertIDs []uint `json:"cert_ids"` - DomainCertIDs []uint `json:"domain_cert_ids"` - RedirectHTTP bool `json:"redirect_http"` - LimitConnPerServer int `json:"limit_conn_per_server"` - LimitConnPerIP int `json:"limit_conn_per_ip"` - LimitRate string `json:"limit_rate"` - CacheEnabled bool `json:"cache_enabled"` - CachePolicy string `json:"cache_policy"` - CacheRules string `json:"cache_rules"` - CacheRuleList []string `json:"cache_rule_list"` - CustomHeaders string `json:"custom_headers"` - CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"` - BasicAuthEnabled bool `json:"basic_auth_enabled"` - BasicAuthUsername string `json:"basic_auth_username"` - BasicAuthPassword string `json:"basic_auth_password"` - Remark string `json:"remark"` - UpstreamType string `json:"upstream_type"` - TunnelNodeID *uint `json:"tunnel_node_id"` - TunnelID *uint `json:"tunnel_id"` - TunnelTargetAddr string `json:"tunnel_target_addr"` - TunnelTargetProtocol string `json:"tunnel_target_protocol"` - PagesProjectID *uint `json:"pages_project_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func ListProxyRoutes() ([]*ProxyRouteView, error) { - routes, err := model.ListProxyRoutes() - if err != nil { - return nil, err - } - return buildProxyRouteViews(routes) -} - -func GetProxyRoute(id uint) (*ProxyRouteView, error) { - route, err := model.GetProxyRouteByID(id) - if err != nil { - return nil, err - } - return buildProxyRouteView(route) -} - -func CreateProxyRoute(input ProxyRouteInput) (*ProxyRouteView, error) { - route, err := buildProxyRoute(nil, input) - if err != nil { - return nil, err - } - if err = route.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("proxy route identity already exists") - } - return nil, err - } - return buildProxyRouteView(route) -} - -func UpdateProxyRoute(id uint, input ProxyRouteInput) (*ProxyRouteView, error) { - route, err := model.GetProxyRouteByID(id) - if err != nil { - return nil, err - } - route, err = buildProxyRoute(route, input) - if err != nil { - return nil, err - } - if err = route.Update(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("proxy route identity already exists") - } - return nil, err - } - return buildProxyRouteView(route) -} - -func DeleteProxyRoute(id uint) error { - route, err := model.GetProxyRouteByID(id) - if err != nil { - return err - } - return route.Delete() -} - -func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) { - domains, err := normalizeProxyRouteDomainsInput(route, input.Domain, input.Domains) - if err != nil { - return nil, err - } - domain := domains[0] - siteName := normalizeProxyRouteSiteNameInput(route, input.SiteName, domain) - - upstreamType := normalizeUpstreamType(input.UpstreamType) - var originURL string - var originID *uint - var upstreams []string - - if upstreamType == "tunnel" { - // Tunnel type: origin URL is auto-filled during config rendering - originURL = "http://127.0.0.1" - upstreams = []string{originURL} - } else if upstreamType == "pages" { - if err := validatePagesRouteInput(input.PagesProjectID); err != nil { - return nil, err - } - // Keep persisted upstreams HTTP-compatible; Pages rendering uses pages_project_id. - originURL = "http://127.0.0.1" - upstreams = []string{originURL} - } else { - originURL, originID, err = resolveProxyRoutePrimaryOrigin(input) - if err != nil { - return nil, err - } - upstreams, err = normalizeUpstreams(originURL, input.Upstreams) - if err != nil { - return nil, err - } - } - originHost := strings.TrimSpace(input.OriginHost) - remark := strings.TrimSpace(input.Remark) - cachePolicy := strings.TrimSpace(input.CachePolicy) - cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules) - if err != nil { - return nil, err - } - customHeaders, err := normalizeCustomHeaders(input.CustomHeaders) - if err != nil { - return nil, err - } - limitConnPerServer, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerServer, "limit_conn_per_server") - if err != nil { - return nil, err - } - limitConnPerIP, err := normalizeProxyRouteLimitConnValue(input.LimitConnPerIP, "limit_conn_per_ip") - if err != nil { - return nil, err - } - limitRate, err := normalizeProxyRouteLimitRate(input.LimitRate) - if err != nil { - return nil, err - } - - cacheRulesJSON, err := json.Marshal(cacheRules) - if err != nil { - return nil, err - } - upstreamsJSON, err := json.Marshal(upstreams) - if err != nil { - return nil, err - } - customHeadersJSON, err := json.Marshal(customHeaders) - if err != nil { - return nil, err - } - - if !input.EnableHTTPS { - input.RedirectHTTP = false - input.CertID = nil - input.CertIDs = nil - input.DomainCertIDs = nil - } - domainCertIDs, certIDs, primaryCertID, err := normalizeProxyRouteDomainCertificateIDs( - domains, - input.EnableHTTPS, - input.DomainCertIDs, - input.CertID, - input.CertIDs, - ) - if err != nil { - return nil, err - } - if err := validateProxyRouteDomainCertificateCoverage(domains, domainCertIDs); err != nil { - return nil, err - } - certIDsJSON, err := json.Marshal(certIDs) - if err != nil { - return nil, err - } - domainCertIDsJSON, err := json.Marshal(domainCertIDs) - if err != nil { - return nil, err - } - domainsJSON, err := json.Marshal(domains) - if err != nil { - return nil, err - } - - if err := validateProxyRouteSiteName(siteName); err != nil { - return nil, err - } - if err := validateProxyRouteIdentityUniqueness(route, siteName, domains); err != nil { - return nil, err - } - if err := validateOriginHost(originHost); err != nil { - return nil, err - } - input.DomainCertIDs = domainCertIDs - input.CertIDs = certIDs - input.CertID = primaryCertID - if input.RedirectHTTP && !input.EnableHTTPS { - return nil, errors.New("redirect_http requires enable_https") - } - - if input.BasicAuthEnabled { - input.BasicAuthUsername = strings.TrimSpace(input.BasicAuthUsername) - input.BasicAuthPassword = strings.TrimSpace(input.BasicAuthPassword) - if input.BasicAuthUsername == "" || input.BasicAuthPassword == "" { - return nil, errors.New("basic_auth_username and basic_auth_password cannot be empty when basic auth is enabled") - } - } else { - input.BasicAuthUsername = "" - input.BasicAuthPassword = "" - } - - if route == nil { - route = &model.ProxyRoute{} - } - route.SiteName = siteName - route.Domain = domain - route.Domains = string(domainsJSON) - route.OriginID = originID - route.OriginURL = upstreams[0] - route.OriginHost = originHost - route.Upstreams = string(upstreamsJSON) - route.Enabled = input.Enabled - route.EnableHTTPS = input.EnableHTTPS - route.CertID = input.CertID - route.CertIDs = string(certIDsJSON) - route.DomainCertIDs = string(domainCertIDsJSON) - route.RedirectHTTP = input.RedirectHTTP - route.LimitConnPerServer = limitConnPerServer - route.LimitConnPerIP = limitConnPerIP - route.LimitRate = limitRate - route.CacheEnabled = input.CacheEnabled - route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy) - route.CacheRules = string(cacheRulesJSON) - route.CustomHeaders = string(customHeadersJSON) - route.BasicAuthEnabled = input.BasicAuthEnabled - route.BasicAuthUsername = input.BasicAuthUsername - route.BasicAuthPassword = input.BasicAuthPassword - route.Remark = remark - route.UpstreamType = upstreamType - if upstreamType == "tunnel" { - tunnelNodeID, err := normalizeTunnelNodeID(input.TunnelNodeID, input.TunnelID) - if err != nil { - return nil, err - } - if err := validateTunnelRouteInput(tunnelNodeID, input.TunnelTargetAddr, input.TunnelTargetProtocol); err != nil { - return nil, err - } - route.TunnelNodeID = tunnelNodeID - route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr) - route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol) - route.PagesProjectID = nil - } else if upstreamType == "pages" { - route.TunnelNodeID = nil - route.TunnelTargetAddr = "" - route.TunnelTargetProtocol = "" - route.PagesProjectID = input.PagesProjectID - } else { - route.TunnelNodeID = nil - route.TunnelTargetAddr = "" - route.TunnelTargetProtocol = "" - route.PagesProjectID = nil - } - return route, nil -} - -func buildProxyRouteViews(routes []*model.ProxyRoute) ([]*ProxyRouteView, error) { - views := make([]*ProxyRouteView, 0, len(routes)) - for _, route := range routes { - view, err := buildProxyRouteView(route) - if err != nil { - return nil, err - } - views = append(views, view) - } - return views, nil -} - -func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) { - if route == nil { - return nil, errors.New("proxy route is nil") - } - domains, err := decodeStoredDomains(route.Domains, route.Domain) - if err != nil { - return nil, err - } - upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL) - if err != nil { - return nil, err - } - cacheRules, err := decodeStoredCacheRules(route.CacheRules) - if err != nil { - return nil, err - } - customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders) - if err != nil { - return nil, err - } - certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID) - if err != nil { - return nil, err - } - domainCertIDs, err := resolveProxyRouteDomainCertIDs(route, domains, certIDs) - if err != nil { - return nil, err - } - var certID *uint - if len(certIDs) > 0 { - certID = &certIDs[0] - } - primaryDomain := domains[0] - return &ProxyRouteView{ - ID: route.ID, - SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, primaryDomain), - Domain: primaryDomain, - Domains: domains, - PrimaryDomain: primaryDomain, - DomainCount: len(domains), - OriginID: route.OriginID, - OriginURL: route.OriginURL, - OriginHost: route.OriginHost, - Upstreams: route.Upstreams, - UpstreamList: upstreams, - Enabled: route.Enabled, - EnableHTTPS: route.EnableHTTPS, - CertID: certID, - CertIDs: certIDs, - DomainCertIDs: domainCertIDs, - RedirectHTTP: route.RedirectHTTP, - LimitConnPerServer: route.LimitConnPerServer, - LimitConnPerIP: route.LimitConnPerIP, - LimitRate: route.LimitRate, - CacheEnabled: route.CacheEnabled, - CachePolicy: route.CachePolicy, - CacheRules: route.CacheRules, - CacheRuleList: cacheRules, - CustomHeaders: route.CustomHeaders, - CustomHeaderList: customHeaders, - BasicAuthEnabled: route.BasicAuthEnabled, - BasicAuthUsername: route.BasicAuthUsername, - BasicAuthPassword: route.BasicAuthPassword, - Remark: route.Remark, - UpstreamType: route.UpstreamType, - TunnelNodeID: route.TunnelNodeID, - TunnelID: route.TunnelNodeID, - TunnelTargetAddr: route.TunnelTargetAddr, - TunnelTargetProtocol: route.TunnelTargetProtocol, - PagesProjectID: route.PagesProjectID, - CreatedAt: route.CreatedAt, - UpdatedAt: route.UpdatedAt, - }, nil -} - -func normalizeTunnelNodeID(tunnelNodeID *uint, legacyTunnelID *uint) (*uint, error) { - if tunnelNodeID != nil && *tunnelNodeID != 0 { - return tunnelNodeID, nil - } - if legacyTunnelID != nil && *legacyTunnelID != 0 { - return legacyTunnelID, nil - } - return nil, errors.New("tunnel_node_id is required for tunnel upstream") -} - -func validateTunnelRouteInput(tunnelNodeID *uint, targetAddr string, targetProtocol string) error { - if tunnelNodeID == nil || *tunnelNodeID == 0 { - return errors.New("tunnel_node_id is required for tunnel upstream") - } - tunnelNode, err := model.GetNodeByID(*tunnelNodeID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("tunnel client node does not exist") - } - return err - } - if tunnelNode.NodeType != "tunnel_client" { - return errors.New("tunnel_node_id must reference a tunnel_client node") - } - if strings.TrimSpace(targetAddr) == "" { - return errors.New("tunnel_target_addr is required for tunnel upstream") - } - switch strings.ToLower(strings.TrimSpace(targetProtocol)) { - case "", "http", "https": - return nil - default: - return errors.New("tunnel_target_protocol must be http or https") - } -} - -func validatePagesRouteInput(projectID *uint) error { - if projectID == nil || *projectID == 0 { - return errors.New("pages_project_id is required for Pages upstream") - } - project, err := model.GetPagesProjectByID(*projectID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("Pages 项目不存在") - } - return err - } - if !project.Enabled { - return errors.New("Pages 项目未启用") - } - if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 { - return errors.New("Pages 项目没有激活部署") - } - return nil -} - -func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string { - siteName := strings.TrimSpace(raw) - if siteName != "" { - return siteName - } - if route != nil && strings.TrimSpace(route.SiteName) != "" { - return strings.TrimSpace(route.SiteName) - } - return primaryDomain -} - -func normalizeProxyRouteDomainValue(raw string) string { - return strings.ToLower(strings.TrimSpace(raw)) -} - -func normalizeProxyRouteDomainsInput(route *model.ProxyRoute, rawDomain string, rawDomains []string) ([]string, error) { - if len(rawDomains) > 0 { - domains, err := normalizeProxyRouteDomains(rawDomains) - if err != nil { - return nil, err - } - domain := normalizeProxyRouteDomainValue(rawDomain) - if domain != "" && domain != domains[0] { - return nil, errors.New("domain must match domains[0]") - } - return domains, nil - } - - if route != nil { - existingDomains, err := decodeStoredDomains(route.Domains, route.Domain) - if err == nil && len(existingDomains) > 0 { - domain := normalizeProxyRouteDomainValue(rawDomain) - if domain == "" || domain == existingDomains[0] { - return existingDomains, nil - } - } - } - - return normalizeProxyRouteDomains([]string{rawDomain}) -} - -func normalizeProxyRouteDomains(rawDomains []string) ([]string, error) { - normalized := make([]string, 0, len(rawDomains)) - for _, rawDomain := range rawDomains { - domain := normalizeProxyRouteDomainValue(rawDomain) - if domain == "" { - continue - } - if strings.Contains(domain, "://") || strings.Contains(domain, "/") { - return nil, errors.New("domain format is invalid") - } - normalized = append(normalized, domain) - } - normalized = utils.Unique(normalized) - if len(normalized) == 0 { - return nil, errors.New("at least one domain is required") - } - return normalized, nil -} - -func validateProxyRouteSiteName(siteName string) error { - if strings.TrimSpace(siteName) == "" { - return errors.New("site_name cannot be empty") - } - return nil -} - -func validateProxyRouteIdentityUniqueness(route *model.ProxyRoute, siteName string, domains []string) error { - routes, err := model.ListProxyRoutes() - if err != nil { - return err - } - - currentID := uint(0) - if route != nil { - currentID = route.ID - } - - for _, item := range routes { - if item == nil || item.ID == currentID { - continue - } - existingSiteName := normalizeProxyRouteSiteNameInput(item, item.SiteName, item.Domain) - if existingSiteName == siteName { - return errors.New("site_name already exists") - } - - existingDomains, err := decodeStoredDomains(item.Domains, item.Domain) - if err != nil { - return fmt.Errorf("existing route %d domains are invalid: %w", item.ID, err) - } - existingSet := make(map[string]struct{}, len(existingDomains)) - for _, existingDomain := range existingDomains { - existingSet[existingDomain] = struct{}{} - } - for _, domain := range domains { - if _, ok := existingSet[domain]; ok { - return fmt.Errorf("domain %s already exists", domain) - } - } - } - - return nil -} - -func normalizeProxyRouteLimitConnValue(value int, field string) (int, error) { - if value < 0 { - return 0, fmt.Errorf("%s must be greater than or equal to 0", field) - } - return value, nil -} - -func normalizeProxyRouteCertificateIDs(enableHTTPS bool, certID *uint, certIDs []uint) ([]uint, error) { - if !enableHTTPS { - return []uint{}, nil - } - - candidates := make([]uint, 0, len(certIDs)+1) - if certID != nil && *certID != 0 { - candidates = append(candidates, *certID) - } - candidates = append(candidates, certIDs...) - - normalized := make([]uint, 0, len(candidates)) - seen := make(map[uint]struct{}, len(candidates)) - for _, item := range candidates { - if item == 0 { - continue - } - if _, ok := seen[item]; ok { - continue - } - if _, err := model.GetTLSCertificateByID(item); err != nil { - return nil, errors.New("selected certificate does not exist") - } - seen[item] = struct{}{} - normalized = append(normalized, item) - } - if len(normalized) == 0 { - return nil, errors.New("must select a certificate when HTTPS is enabled") - } - return normalized, nil -} - -func normalizeProxyRouteDomainCertificateIDs( - domains []string, - enableHTTPS bool, - rawDomainCertIDs []uint, - certID *uint, - certIDs []uint, -) ([]uint, []uint, *uint, error) { - if !enableHTTPS { - return []uint{}, []uint{}, nil, nil - } - - if len(rawDomainCertIDs) > 0 { - if len(rawDomainCertIDs) != len(domains) { - return nil, nil, nil, errors.New("domain_cert_ids must match domains length") - } - - normalizedDomainCertIDs := make([]uint, len(rawDomainCertIDs)) - uniqueCertIDs := make([]uint, 0, len(rawDomainCertIDs)) - seen := make(map[uint]struct{}, len(rawDomainCertIDs)) - hasAssignedCertificate := false - for index, item := range rawDomainCertIDs { - if item == 0 { - continue - } - if _, err := model.GetTLSCertificateByID(item); err != nil { - return nil, nil, nil, errors.New("selected certificate does not exist") - } - normalizedDomainCertIDs[index] = item - hasAssignedCertificate = true - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - uniqueCertIDs = append(uniqueCertIDs, item) - } - if !hasAssignedCertificate { - return nil, nil, nil, errors.New("must select a certificate when HTTPS is enabled") - } - - primaryCertID := &uniqueCertIDs[0] - return normalizedDomainCertIDs, uniqueCertIDs, primaryCertID, nil - } - - normalizedCertIDs, err := normalizeProxyRouteCertificateIDs( - enableHTTPS, - certID, - certIDs, - ) - if err != nil { - return nil, nil, nil, err - } - - switch { - case len(normalizedCertIDs) == 0: - return nil, nil, nil, errors.New("must select a certificate when HTTPS is enabled") - case len(normalizedCertIDs) == 1: - domainCertIDs := make([]uint, len(domains)) - for index := range domainCertIDs { - domainCertIDs[index] = normalizedCertIDs[0] - } - primaryCertID := &normalizedCertIDs[0] - return domainCertIDs, normalizedCertIDs, primaryCertID, nil - case len(normalizedCertIDs) == len(domains): - domainCertIDs := make([]uint, len(normalizedCertIDs)) - copy(domainCertIDs, normalizedCertIDs) - primaryCertID := &normalizedCertIDs[0] - return domainCertIDs, normalizedCertIDs, primaryCertID, nil - default: - domainCertIDs, err := deriveDomainCertIDsFromCertificateSet( - domains, - normalizedCertIDs, - ) - if err != nil { - return nil, nil, nil, err - } - primaryCertID := &normalizedCertIDs[0] - return domainCertIDs, normalizedCertIDs, primaryCertID, nil - } -} - -func validateProxyRouteDomainCertificateCoverage( - domains []string, - domainCertIDs []uint, -) error { - if len(domainCertIDs) == 0 { - return nil - } - - domainsByCertID := make(map[uint][]string) - for index, certID := range domainCertIDs { - if certID == 0 { - continue - } - domainsByCertID[certID] = append(domainsByCertID[certID], domains[index]) - } - - for certID, assignedDomains := range domainsByCertID { - certificate, err := model.GetTLSCertificateByID(certID) - if err != nil { - return errors.New("selected certificate does not exist") - } - if err := validateCertificateCoverage(certificate, assignedDomains); err != nil { - return err - } - } - return nil -} - -func deriveDomainCertIDsFromCertificateSet( - domains []string, - certIDs []uint, -) ([]uint, error) { - certificates, err := loadTLSCertificates(certIDs) - if err != nil { - return nil, err - } - - result := make([]uint, len(domains)) - for domainIndex, domain := range domains { - if domainIndex < len(certificates) && - certificates[domainIndex] != nil && - validateCertificateCoverage(certificates[domainIndex], []string{domain}) == nil { - result[domainIndex] = certificates[domainIndex].ID - continue - } - - assigned := uint(0) - for _, certificate := range certificates { - if certificate != nil && - validateCertificateCoverage(certificate, []string{domain}) == nil { - assigned = certificate.ID - break - } - } - if assigned == 0 { - return nil, fmt.Errorf("certificate does not cover domain %s", domain) - } - result[domainIndex] = assigned - } - return result, nil -} - -func decodeStoredDomainCertIDs(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, errors.New("domain_cert_ids payload is invalid") - } - if len(domainCertIDs) == 0 { - return []uint{}, nil - } - if domainCount > 0 && len(domainCertIDs) != domainCount { - return nil, errors.New("domain_cert_ids length does not match domains") - } - - normalized := make([]uint, len(domainCertIDs)) - copy(normalized, domainCertIDs) - return normalized, nil -} - -func resolveProxyRouteDomainCertIDs( - route *model.ProxyRoute, - domains []string, - certIDs []uint, -) ([]uint, error) { - domainCertIDs, err := decodeStoredDomainCertIDs(route.DomainCertIDs, len(domains)) - if err != nil { - return nil, err - } - if len(domainCertIDs) > 0 || len(certIDs) == 0 { - return domainCertIDs, nil - } - return deriveDomainCertIDsFromCertificateSet(domains, certIDs) -} - -func normalizeProxyRouteLimitRate(raw string) (string, error) { - normalized := strings.ToLower(strings.TrimSpace(raw)) - if normalized == "" || normalized == "0" { - return "", nil - } - if !proxyRouteLimitRatePattern.MatchString(normalized) { - return "", errors.New("limit_rate must be a number or use the 512k / 1m format") - } - if strings.TrimRight(normalized, "km") == "" { - return "", nil - } - return normalized, nil -} - -func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error) { - if hasStructuredOriginInput(input) { - scheme, err := normalizeOriginScheme(input.OriginScheme) - if err != nil { - return "", nil, err - } - port, err := normalizeOriginPort(input.OriginPort) - if err != nil { - return "", nil, err - } - uri, err := normalizeOriginURI(input.OriginURI) - if err != nil { - return "", nil, err - } - if input.OriginID != nil && *input.OriginID != 0 { - origin, err := model.GetOriginByID(*input.OriginID) - if err != nil { - return "", nil, errors.New("selected origin does not exist") - } - originURL, err := buildOriginURLFromParts( - scheme, - origin.Address, - port, - uri, - ) - if err != nil { - return "", nil, err - } - return originURL, &origin.ID, nil - } - - address := normalizeOriginAddress(input.OriginAddress) - if err := validateOriginAddress(address); err != nil { - return "", nil, err - } - originURL, err := buildOriginURLFromParts(scheme, address, port, uri) - if err != nil { - return "", nil, err - } - origin, err := getOrCreateOriginByAddress(address) - if err != nil { - return "", nil, err - } - return originURL, &origin.ID, nil - } - - originURL := strings.TrimSpace(input.OriginURL) - if originURL == "" { - return "", nil, errors.New("origin_url cannot be empty") - } - address, err := extractOriginAddress(originURL) - if err != nil { - return "", nil, err - } - origin, findErr := model.GetOriginByAddress(address) - if findErr == nil { - return originURL, &origin.ID, nil - } - if !errors.Is(findErr, gorm.ErrRecordNotFound) { - return "", nil, findErr - } - return originURL, nil, nil -} - -func hasStructuredOriginInput(input ProxyRouteInput) bool { - return (input.OriginID != nil && *input.OriginID != 0) || - strings.TrimSpace(input.OriginScheme) != "" || - strings.TrimSpace(input.OriginAddress) != "" || - strings.TrimSpace(input.OriginPort) != "" || - strings.TrimSpace(input.OriginURI) != "" -} - -func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRouteCustomHeaderInput, error) { - if len(headers) == 0 { - return []ProxyRouteCustomHeaderInput{}, nil - } - normalized := make([]ProxyRouteCustomHeaderInput, 0, len(headers)) - for _, header := range headers { - key := strings.TrimSpace(header.Key) - value := strings.TrimSpace(header.Value) - if key == "" && value == "" { - continue - } - if key == "" { - return nil, errors.New("custom header key cannot be empty") - } - if !proxyHeaderKeyPattern.MatchString(key) { - return nil, errors.New("custom header key format is invalid") - } - if strings.ContainsAny(key, "\r\n") || strings.ContainsAny(value, "\r\n") { - return nil, errors.New("custom headers cannot contain newlines") - } - normalized = append(normalized, ProxyRouteCustomHeaderInput{ - Key: key, - Value: value, - }) - } - return normalized, nil -} - -func normalizeUpstreams(originURL string, upstreams []string) ([]string, error) { - candidates := make([]string, 0, len(upstreams)+1) - if strings.TrimSpace(originURL) != "" { - candidates = append(candidates, originURL) - } - candidates = append(candidates, upstreams...) - trimmed := make([]string, 0, len(candidates)) - for _, candidate := range candidates { - item := strings.TrimSpace(candidate) - if item == "" { - continue - } - trimmed = append(trimmed, item) - } - unique := utils.Unique(trimmed) - normalized := make([]string, 0, len(unique)) - var scheme string - multiUpstream := len(unique) > 1 - for _, item := range unique { - if err := validateOriginURL(item); err != nil { - return nil, err - } - parsed, err := url.ParseRequestURI(item) - if err != nil { - return nil, errors.New("origin URL format is invalid") - } - if multiUpstream && parsed.Path != "" && parsed.Path != "/" { - return nil, errors.New("multi-upstream mode does not support origin paths") - } - if multiUpstream && parsed.RawQuery != "" { - return nil, errors.New("multi-upstream mode does not support origin query strings") - } - if scheme == "" { - scheme = parsed.Scheme - } else if scheme != parsed.Scheme { - return nil, errors.New("all upstreams must use the same scheme") - } - normalized = append(normalized, item) - } - if len(normalized) == 0 { - return nil, errors.New("at least one upstream is required") - } - return normalized, nil -} - -func decodeStoredCustomHeaders(raw string) ([]ProxyRouteCustomHeaderInput, error) { - text := strings.TrimSpace(raw) - if text == "" { - return []ProxyRouteCustomHeaderInput{}, nil - } - var headers []ProxyRouteCustomHeaderInput - if err := json.Unmarshal([]byte(text), &headers); err != nil { - return nil, errors.New("custom_headers payload is invalid") - } - return normalizeCustomHeaders(headers) -} - -func normalizeCachePolicy(enabled bool, raw string) string { - if !enabled { - return "" - } - policy := strings.TrimSpace(raw) - if policy == "" { - return proxyRouteCachePolicyURL - } - return policy -} - -func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]string, error) { - if !enabled { - return []string{}, nil - } - policy := normalizeCachePolicy(enabled, rawPolicy) - switch policy { - case proxyRouteCachePolicyURL: - return []string{}, nil - case proxyRouteCachePolicySuffix: - return normalizeCacheSuffixRules(rules) - case proxyRouteCachePolicyPathPrefix: - return normalizeCachePathRules(rules, true) - case proxyRouteCachePolicyPathExact: - return normalizeCachePathRules(rules, false) - default: - return nil, errors.New("cache policy is not supported") - } -} - -func normalizeCacheSuffixRules(rules []string) ([]string, error) { - normalized := make([]string, 0, len(rules)) - seen := make(map[string]struct{}, len(rules)) - for _, rule := range rules { - item := strings.TrimSpace(strings.TrimPrefix(rule, ".")) - if item == "" { - continue - } - if strings.ContainsAny(item, "/\\ \t\r\n") { - return nil, errors.New("cache suffix format is invalid") - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - normalized = append(normalized, item) - } - if len(normalized) == 0 { - return nil, errors.New("at least one suffix is required") - } - return normalized, nil -} - -func normalizeCachePathRules(rules []string, allowPrefix bool) ([]string, error) { - normalized := make([]string, 0, len(rules)) - seen := make(map[string]struct{}, len(rules)) - for _, rule := range rules { - item := strings.TrimSpace(rule) - if item == "" { - continue - } - if !strings.HasPrefix(item, "/") || strings.Contains(item, "://") || strings.ContainsAny(item, " \t\r\n") { - return nil, errors.New("cache path rule format is invalid") - } - if !allowPrefix && strings.HasSuffix(item, "/") && len(item) > 1 { - item = strings.TrimRight(item, "/") - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - normalized = append(normalized, item) - } - if len(normalized) == 0 { - if allowPrefix { - return nil, errors.New("at least one path prefix is required") - } - return nil, errors.New("at least one exact path is required") - } - return normalized, nil -} - -func decodeStoredCacheRules(raw string) ([]string, error) { - text := strings.TrimSpace(raw) - if text == "" { - return []string{}, nil - } - var rules []string - if err := json.Unmarshal([]byte(text), &rules); err != nil { - return nil, errors.New("cache_rules payload is invalid") - } - normalized := make([]string, 0, len(rules)) - for _, rule := range rules { - item := strings.TrimSpace(rule) - if item == "" { - continue - } - normalized = append(normalized, item) - } - return normalized, nil -} - -func decodeStoredUpstreams(raw string, fallbackOriginURL string) ([]string, error) { - text := strings.TrimSpace(raw) - if text == "" { - return normalizeUpstreams(fallbackOriginURL, nil) - } - var upstreams []string - if err := json.Unmarshal([]byte(text), &upstreams); err != nil { - return nil, errors.New("upstreams payload is invalid") - } - return normalizeUpstreams(fallbackOriginURL, upstreams) -} - -func decodeStoredDomains(raw string, fallbackDomain string) ([]string, error) { - text := strings.TrimSpace(raw) - if text == "" { - return normalizeProxyRouteDomains([]string{fallbackDomain}) - } - var domains []string - if err := json.Unmarshal([]byte(text), &domains); err != nil { - return nil, errors.New("domains payload is invalid") - } - return normalizeProxyRouteDomains(domains) -} - -func decodeStoredCertIDs(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, errors.New("cert_ids payload is invalid") - } - 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 validateOriginURL(raw string) error { - if raw == "" { - return errors.New("origin URL cannot be empty") - } - parsed, err := url.ParseRequestURI(raw) - if err != nil { - return errors.New("origin URL format is invalid") - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return errors.New("origin URL must start with http:// or https://") - } - if parsed.Host == "" { - return errors.New("origin URL format is invalid") - } - return nil -} - -func validateOriginHost(raw string) error { - if raw == "" { - return nil - } - if strings.ContainsAny(raw, "/\\ \t\r\n") || strings.Contains(raw, "://") { - return errors.New("origin_host format is invalid") - } - parsed, err := url.Parse("//" + raw) - if err != nil || parsed.Host == "" || parsed.Host != raw { - return errors.New("origin_host format is invalid") - } - if parsed.Hostname() == "" { - return errors.New("origin_host format is invalid") - } - return nil -} - -// PoW configuration types and validation - -type ProxyRoutePoWListConfig struct { - IPs []string `json:"ips"` - IPCidrs []string `json:"ip_cidrs"` - Paths []string `json:"paths"` - PathRegexes []string `json:"path_regexes"` - UserAgents []string `json:"user_agents"` -} - -type ProxyRoutePoWConfig struct { - Difficulty int `json:"difficulty"` - Algorithm string `json:"algorithm"` - SessionTTL int `json:"session_ttl"` - ChallengeTTL int `json:"challenge_ttl"` - Whitelist ProxyRoutePoWListConfig `json:"whitelist"` - Blacklist ProxyRoutePoWListConfig `json:"blacklist"` -} - -var powAlgorithmValues = map[string]bool{"fast": true, "slow": true} - -func defaultPoWConfig() ProxyRoutePoWConfig { - return ProxyRoutePoWConfig{ - Difficulty: 4, - Algorithm: "fast", - SessionTTL: 600, - ChallengeTTL: 300, - Whitelist: ProxyRoutePoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}}, - Blacklist: ProxyRoutePoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}}, - } -} - -func normalizePoWConfig(enabled bool, raw string) (ProxyRoutePoWConfig, error) { - if !enabled { - return defaultPoWConfig(), nil - } - - cfg := defaultPoWConfig() - text := strings.TrimSpace(raw) - if text != "" && text != "{}" { - if err := json.Unmarshal([]byte(text), &cfg); err != nil { - return cfg, errors.New("pow_config 格式无效") - } - } - - if cfg.Difficulty < 1 || cfg.Difficulty > 16 { - return cfg, errors.New("pow_config.difficulty 必须在 1-16 之间") - } - if !powAlgorithmValues[cfg.Algorithm] { - return cfg, errors.New("pow_config.algorithm 必须为 fast 或 slow") - } - if cfg.SessionTTL < 60 { - return cfg, errors.New("pow_config.session_ttl 不能小于 60 秒") - } - if cfg.ChallengeTTL < 30 { - return cfg, errors.New("pow_config.challenge_ttl 不能小于 30 秒") - } - - for _, cidr := range cfg.Whitelist.IPCidrs { - if _, _, err := net.ParseCIDR(cidr); err != nil { - return cfg, fmt.Errorf("pow_config 白名单 IP CIDR 格式无效: %s", cidr) - } - } - for _, cidr := range cfg.Blacklist.IPCidrs { - if _, _, err := net.ParseCIDR(cidr); err != nil { - return cfg, fmt.Errorf("pow_config 黑名单 IP CIDR 格式无效: %s", cidr) - } - } - - for _, re := range cfg.Whitelist.PathRegexes { - if _, err := regexp.Compile(re); err != nil { - return cfg, fmt.Errorf("pow_config 白名单路径正则格式无效: %s", re) - } - } - for _, re := range cfg.Blacklist.PathRegexes { - if _, err := regexp.Compile(re); err != nil { - return cfg, fmt.Errorf("pow_config 黑名单路径正则格式无效: %s", re) - } - } - - for _, ip := range cfg.Whitelist.IPs { - if net.ParseIP(ip) == nil { - return cfg, fmt.Errorf("pow_config 白名单 IP 格式无效: %s", ip) - } - } - for _, ip := range cfg.Blacklist.IPs { - if net.ParseIP(ip) == nil { - return cfg, fmt.Errorf("pow_config 黑名单 IP 格式无效: %s", ip) - } - } - - type dimension struct { - name string - wl []string - bl []string - } - dimensions := []dimension{ - {"IP", cfg.Whitelist.IPs, cfg.Blacklist.IPs}, - {"IP CIDR", cfg.Whitelist.IPCidrs, cfg.Blacklist.IPCidrs}, - {"路径", cfg.Whitelist.Paths, cfg.Blacklist.Paths}, - {"路径正则", cfg.Whitelist.PathRegexes, cfg.Blacklist.PathRegexes}, - {"User-Agent", cfg.Whitelist.UserAgents, cfg.Blacklist.UserAgents}, - } - for _, dim := range dimensions { - if len(dim.wl) > 0 && len(dim.bl) > 0 { - return cfg, fmt.Errorf("pow_config %s 不能同时配置白名单和黑名单", dim.name) - } - } - - return cfg, nil -} - -func decodeStoredPoWConfig(enabled bool, raw string) (*ProxyRoutePoWConfig, error) { - if !enabled { - cfg := defaultPoWConfig() - return &cfg, nil - } - text := strings.TrimSpace(raw) - if text == "" || text == "{}" { - cfg := defaultPoWConfig() - return &cfg, nil - } - var cfg ProxyRoutePoWConfig - if err := json.Unmarshal([]byte(text), &cfg); err != nil { - return nil, errors.New("pow_config 格式无效") - } - return &cfg, nil -} - -func normalizeUpstreamType(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "tunnel": - return "tunnel" - case "pages": - return "pages" - default: - return "direct" - } -} - -func normalizeTunnelTargetProtocol(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case "https": - return "https" - default: - return "http" - } -} diff --git a/openflare-server/internal/service/relay.go b/openflare-server/internal/service/relay.go deleted file mode 100644 index 4876d8bc..00000000 --- a/openflare-server/internal/service/relay.go +++ /dev/null @@ -1,569 +0,0 @@ -package service - -import ( - "errors" - "fmt" - "log/slog" - "net" - "strconv" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -// RelayHeartbeatPayload is the payload sent by OpenFlareRelay in each heartbeat. -type RelayHeartbeatPayload struct { - Version string `json:"version"` - ExtVersion string `json:"frp_version"` - RelayStatus string `json:"relay_status"` - FrpsConnCount int `json:"frps_connections"` - FrpsProxyCount int `json:"frps_proxy_count"` - FrpsClientCount int `json:"frps_client_count"` - FrpsProxies []RelayProxyStat `json:"frps_proxies,omitempty"` - Name string `json:"name"` - IP string `json:"ip"` - Profile *AgentNodeSystemProfile `json:"profile,omitempty"` - Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"` - HealthEvents []AgentNodeHealthEvent `json:"health_events,omitempty"` -} - -const relayFrpsUnhealthyEventType = "frps_unhealthy" - -// RelayConfig is the frps configuration sent to the Relay. -type RelayConfig struct { - BindPort int `json:"bind_port"` - VhostHTTPPort int `json:"vhost_http_port"` - AuthToken string `json:"auth_token"` - LogLevel string `json:"log_level"` - WebServerEnabled bool `json:"web_server_enabled"` -} - -// RelaySettings contains runtime settings for the Relay. -type RelaySettings struct { - HeartbeatInterval int `json:"heartbeat_interval"` - WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"` - AutoUpdate bool `json:"auto_update"` - UpdateRepo string `json:"update_repo"` - UpdateNow bool `json:"update_now"` - UpdateChannel string `json:"update_channel"` - UpdateTag string `json:"update_tag"` -} - -// RelayHeartbeatResponse is the response returned to the Relay from a heartbeat. -type RelayHeartbeatResponse struct { - RelayConfig *RelayConfig `json:"relay_config"` - RelaySettings *RelaySettings `json:"relay_settings"` -} - -// HeartbeatRelay processes a relay heartbeat, updates node status, and returns config. -func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHeartbeatResponse, error) { - if node == nil { - return nil, fmt.Errorf("relay node is nil") - } - slog.Debug("relay heartbeat received", "node_id", node.NodeID) - - payload.Version = strings.TrimSpace(payload.Version) - payload.ExtVersion = strings.TrimSpace(payload.ExtVersion) - payload.RelayStatus = normalizeRelayStatus(payload.RelayStatus) - payload.Name = strings.TrimSpace(payload.Name) - payload.IP = strings.TrimSpace(payload.IP) - - previous := *node - updateNow := node.UpdateRequested - updateChannel := normalizeReleaseChannel(node.UpdateChannel) - updateTag := strings.TrimSpace(node.UpdateTag) - - node.UpdateRequested = false - node.UpdateChannel = ReleaseChannelStable.String() - node.UpdateTag = "" - - changes := make(map[string]any) - appendRelayChange := func(key string, before any, after any) { - if before != after { - changes[key] = after - } - } - now := time.Now() - appendRelayChange("version", node.Version, payload.Version) - appendRelayChange("ext_version", node.ExtVersion, payload.ExtVersion) - appendRelayChange("relay_status", node.RelayStatus, payload.RelayStatus) - - if previous.UpdateRequested { - appendRelayChange("update_requested", previous.UpdateRequested, false) - } - if previous.UpdateChannel != ReleaseChannelStable.String() { - appendRelayChange("update_channel", previous.UpdateChannel, ReleaseChannelStable.String()) - } - if previous.UpdateTag != "" { - appendRelayChange("update_tag", previous.UpdateTag, "") - } - - if payload.Name != "" && strings.TrimSpace(node.Name) == "" { - appendRelayChange("name", node.Name, payload.Name) - node.Name = payload.Name - } - if payload.IP != "" && !node.IPManualOverride { - appendRelayChange("ip", node.IP, payload.IP) - node.IP = payload.IP - if !node.GeoManualOverride { - applyGeoInfoFromIP(node, node.IP) - changes["geo_name"] = node.GeoName - changes["geo_latitude"] = node.GeoLatitude - changes["geo_longitude"] = node.GeoLongitude - } - } - if !node.LastSeenAt.Equal(now) { - changes["last_seen_at"] = now - } - changes["status"] = NodeStatusOnline - - node.Version = payload.Version - node.ExtVersion = payload.ExtVersion - node.RelayStatus = payload.RelayStatus - - node.LastSeenAt = now - node.Status = NodeStatusOnline - - if len(changes) > 0 { - if err := model.DB.Model(node).Updates(changes).Error; err != nil { - return nil, fmt.Errorf("update relay heartbeat: %w", err) - } - } - if err := reconcileRelayHealthEvents(node.NodeID, payload.RelayStatus, now); err != nil { - return nil, fmt.Errorf("reconcile relay health events: %w", err) - } - refreshAccessTokenCache(node) - persistRelayHeartbeatObservability(node.NodeID, payload, node.LastSeenAt) - - return &RelayHeartbeatResponse{ - RelayConfig: buildRelayConfig(node), - RelaySettings: buildRelaySettings(node, updateNow, updateChannel.String(), updateTag), - }, nil -} - -func persistRelayHeartbeatObservability(nodeID string, payload RelayHeartbeatPayload, reportedAt time.Time) { - persistHeartbeatObservability(nodeID, AgentNodePayload{ - Profile: payload.Profile, - Snapshot: payload.Snapshot, - HealthEvents: payload.HealthEvents, - }, reportedAt) - - frpsObs := &model.NodeObservationFrps{ - NodeID: nodeID, - CapturedAt: reportedAt, - FrpsConnections: 0, - FrpsProxyCount: 0, - FrpsClientCount: 0, - FrpsProxies: "", - } - _ = frpsObs.Insert() -} - -func buildRelayConfig(node *model.Node) *RelayConfig { - if node == nil { - return nil - } - return &RelayConfig{ - BindPort: node.RelayBindPort, - VhostHTTPPort: node.RelayVhostHTTPPort, - AuthToken: node.RelayAuthToken, - LogLevel: "info", - WebServerEnabled: node.RelayWebServerEnabled, - } -} - -func buildRelaySettings(node *model.Node, updateNow bool, updateChannel string, updateTag string) *RelaySettings { - autoUpdate := false - if node != nil { - autoUpdate = node.AutoUpdateEnabled - } - if strings.TrimSpace(updateChannel) == "" { - updateChannel = ReleaseChannelStable.String() - } - return &RelaySettings{ - HeartbeatInterval: common.AgentHeartbeatInterval, - WebsocketUpgradeEnabled: common.AgentWebsocketUpgradeEnabled, - AutoUpdate: autoUpdate, - UpdateRepo: common.AgentUpdateRepo, - UpdateNow: updateNow, - UpdateChannel: updateChannel, - UpdateTag: strings.TrimSpace(updateTag), - } -} - -func reconcileRelayHealthEvents(nodeID string, relayStatus string, reportedAt time.Time) error { - if relayStatus == "unknown" { - return nil - } - managedTypes := map[string]struct{}{ - relayFrpsUnhealthyEventType: {}, - } - events := []AgentNodeHealthEvent{} - if relayStatus == "unhealthy" { - events = append(events, AgentNodeHealthEvent{ - EventType: relayFrpsUnhealthyEventType, - Severity: NodeHealthSeverityCritical, - Message: "frps runtime is not healthy", - TriggeredAtUnix: reportedAt.Unix(), - Metadata: map[string]string{ - "relay_status": relayStatus, - }, - }) - } - return model.DB.Transaction(func(tx *gorm.DB) error { - return reconcileScopedNodeHealthEvents(tx, nodeID, events, reportedAt, managedTypes) - }) -} - -func normalizeRelayStatus(status string) string { - switch strings.ToLower(strings.TrimSpace(status)) { - case "healthy": - return "healthy" - case "unhealthy": - return "unhealthy" - default: - return "unknown" - } -} - -// FlaredHeartbeatPayload is the payload sent by OpenFlared in each heartbeat. -type FlaredHeartbeatPayload struct { - ClientVersion string `json:"client_version"` - FrpVersion string `json:"frp_version"` - IP string `json:"ip"` - TunnelStatus string `json:"tunnel_status"` - ConnectedRelays []FlaredConnectedRelay `json:"connected_relays"` - CurrentVersion string `json:"current_version"` - CurrentChecksum string `json:"current_checksum"` -} - -func normalizeFlaredHeartbeatPayload(payload FlaredHeartbeatPayload) FlaredHeartbeatPayload { - payload.ClientVersion = strings.TrimSpace(payload.ClientVersion) - payload.FrpVersion = strings.TrimSpace(payload.FrpVersion) - payload.IP = strings.TrimSpace(payload.IP) - payload.TunnelStatus = strings.ToLower(strings.TrimSpace(payload.TunnelStatus)) - payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion) - payload.CurrentChecksum = strings.TrimSpace(payload.CurrentChecksum) - cleaned := make([]FlaredConnectedRelay, 0, len(payload.ConnectedRelays)) - for _, relay := range payload.ConnectedRelays { - relay.RelayNodeID = strings.TrimSpace(relay.RelayNodeID) - relay.Status = strings.ToLower(strings.TrimSpace(relay.Status)) - if relay.RelayNodeID == "" { - continue - } - if relay.Status == "" { - relay.Status = "unknown" - } - cleaned = append(cleaned, relay) - } - payload.ConnectedRelays = cleaned - return payload -} - -// HeartbeatFlared processes an OpenFlared heartbeat, refreshes node status, -// persists the connected relay snapshot, and returns the active tunnel -// config summary plus runtime settings. -func HeartbeatFlared(node *model.Node, payload FlaredHeartbeatPayload) (*FlaredHeartbeatResponse, error) { - if node == nil { - return nil, fmt.Errorf("tunnel client node is nil") - } - if node.NodeType != "tunnel_client" { - return nil, fmt.Errorf("node %s is not a tunnel_client", node.NodeID) - } - slog.Debug("flared heartbeat received", "node_id", node.NodeID, "client_version", payload.ClientVersion) - payload = normalizeFlaredHeartbeatPayload(payload) - - now := time.Now() - previous := *node - updateNow := node.UpdateRequested - updateChannel := normalizeReleaseChannel(node.UpdateChannel) - updateTag := strings.TrimSpace(node.UpdateTag) - - node.UpdateRequested = false - node.UpdateChannel = ReleaseChannelStable.String() - node.UpdateTag = "" - - changes := make(map[string]any) - if previous.Version != payload.ClientVersion { - changes["version"] = payload.ClientVersion - } - if previous.ExtVersion != payload.FrpVersion { - changes["ext_version"] = payload.FrpVersion - } - if previous.CurrentVersion != payload.CurrentVersion { - changes["current_version"] = payload.CurrentVersion - } - if !previous.LastSeenAt.Equal(now) { - changes["last_seen_at"] = now - } - changes["status"] = NodeStatusOnline - - node.Version = payload.ClientVersion - node.ExtVersion = payload.FrpVersion - node.CurrentVersion = payload.CurrentVersion - node.LastSeenAt = now - node.Status = NodeStatusOnline - - if previous.UpdateRequested { - changes["update_requested"] = false - } - if previous.UpdateChannel != ReleaseChannelStable.String() { - changes["update_channel"] = ReleaseChannelStable.String() - } - if previous.UpdateTag != "" { - changes["update_tag"] = "" - } - - if !node.IPManualOverride && payload.IP != "" && previous.IP != payload.IP { - changes["ip"] = payload.IP - node.IP = payload.IP - } - - if !node.GeoManualOverride { - applyGeoInfoFromIP(node, node.IP) - if previous.GeoName != node.GeoName { - changes["geo_name"] = node.GeoName - } - if !coordinatesEqual(previous.GeoLatitude, node.GeoLatitude) { - changes["geo_latitude"] = node.GeoLatitude - } - if !coordinatesEqual(previous.GeoLongitude, node.GeoLongitude) { - changes["geo_longitude"] = node.GeoLongitude - } - } - - if len(changes) > 0 { - if err := model.DB.Model(node).Updates(changes).Error; err != nil { - return nil, fmt.Errorf("update flared heartbeat: %w", err) - } - } - refreshAccessTokenCache(node) - persistFlaredObservability(node.NodeID, payload, now) - - activeConfig, err := GetActiveConfigMetaForAgent() - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - return &FlaredHeartbeatResponse{ - ActiveConfig: activeConfig, - TunnelSettings: buildRelaySettings(node, updateNow, updateChannel.String(), updateTag), - }, nil -} - -// persistFlaredObservability records the latest connection snapshot and -// health event for the OpenFlared client. -func persistFlaredObservability(nodeID string, payload FlaredHeartbeatPayload, reportedAt time.Time) { - connected := make([]string, 0, len(payload.ConnectedRelays)) - for _, relay := range payload.ConnectedRelays { - connected = append(connected, fmt.Sprintf("%s:%s", relay.RelayNodeID, relay.Status)) - } - managedTypes := map[string]struct{}{ - "flared_runtime_unhealthy": {}, - } - var events []AgentNodeHealthEvent - if payload.TunnelStatus == "unhealthy" { - events = append(events, AgentNodeHealthEvent{ - EventType: "flared_runtime_unhealthy", - Severity: NodeHealthSeverityCritical, - Message: "openflared runtime is not healthy", - TriggeredAtUnix: reportedAt.Unix(), - Metadata: map[string]string{ - "tunnel_status": payload.TunnelStatus, - "client_version": payload.ClientVersion, - "current_version": payload.CurrentVersion, - "current_checksum": payload.CurrentChecksum, - "connected_relays": strings.Join(connected, ","), - }, - }) - } - _ = reconcileScopedNodeHealthEvents(model.DB, nodeID, events, reportedAt, managedTypes) -} - -// FlaredConnectedRelay describes the status of a relay connection from a client. -type FlaredConnectedRelay struct { - RelayNodeID string `json:"relay_node_id"` - Status string `json:"status"` - ProxyCount int `json:"proxy_count"` -} - -// FlaredHeartbeatResponse is the response returned to the OpenFlared client. -type FlaredHeartbeatResponse struct { - ActiveConfig *ActiveConfigMeta `json:"active_config"` - TunnelSettings *RelaySettings `json:"tunnel_settings"` -} - -// FlaredTunnelConfigResponse is the full tunnel routing config sent to the client. -type FlaredTunnelConfigResponse struct { - Version string `json:"version"` - Checksum string `json:"checksum"` - Relays []FlaredRelayInfo `json:"relays"` - Proxies []FlaredProxyEntry `json:"proxies"` -} - -// FlaredRelayInfo describes a relay that the client should connect to. -type FlaredRelayInfo struct { - RelayNodeID string `json:"relay_node_id"` - Address string `json:"address"` - AuthToken string `json:"auth_token"` - ProxyURL string `json:"proxy_url"` -} - -// FlaredProxyEntry describes a single frpc proxy definition. -type FlaredProxyEntry struct { - Name string `json:"name"` - Type string `json:"type"` - LocalAddr string `json:"local_addr"` - LocalPort int `json:"local_port"` - CustomDomains []string `json:"custom_domains"` -} - -// GetFlaredTunnelConfig builds the full tunnel routing config for an OpenFlared client. -func GetFlaredTunnelConfig(node *model.Node) (*FlaredTunnelConfigResponse, error) { - if node == nil { - return nil, fmt.Errorf("node is nil") - } - - activeVersion, err := model.GetActiveConfigVersion() - if err != nil { - return nil, fmt.Errorf("no active config version: %w", err) - } - - // Get all enabled proxy routes with tunnel upstream targeting this tunnel - routes, err := model.GetEnabledProxyRoutes() - if err != nil { - return nil, fmt.Errorf("get proxy routes: %w", err) - } - - // Get all online tunnel relay nodes - relayNodes, err := model.ListNodesByType("tunnel_relay") - if err != nil { - return nil, fmt.Errorf("get relay nodes: %w", err) - } - - // Build relay info - relays := make([]FlaredRelayInfo, 0, len(relayNodes)) - for _, node := range relayNodes { - if node.RelayStatus == "healthy" || node.Status == NodeStatusOnline { - addr := relayClientAddress(node) - relays = append(relays, FlaredRelayInfo{ - RelayNodeID: node.NodeID, - Address: addr, - AuthToken: node.RelayAuthToken, - ProxyURL: strings.TrimSpace(node.RelayClientProxyURL), - }) - } - } - - // Build proxy entries from routes - proxies := make([]FlaredProxyEntry, 0) - for _, route := range routes { - if route.UpstreamType != "tunnel" || route.TunnelNodeID == nil || *route.TunnelNodeID != node.ID { - continue - } - if !route.Enabled { - continue - } - domains, err := decodeStoredDomains(route.Domains, route.Domain) - if err != nil { - continue - } - localAddr, localPort := parseTunnelTargetAddr(route.TunnelTargetAddr) - proxies = append(proxies, FlaredProxyEntry{ - Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(domains[0])), - Type: "http", - LocalAddr: localAddr, - LocalPort: localPort, - CustomDomains: domains, - }) - } - - return &FlaredTunnelConfigResponse{ - Version: activeVersion.Version, - Checksum: activeVersion.Checksum, - Relays: relays, - Proxies: proxies, - }, nil -} - -func relayClientAddress(node *model.Node) string { - if node == nil { - return "" - } - port := node.RelayBindPort - if port <= 0 { - port = 7000 - } - addr := strings.TrimSpace(node.RelayClientAccessAddr) - if addr == "" { - addr = strings.TrimSpace(node.IP) - } - if addr == "" { - return fmt.Sprintf("127.0.0.1:%d", port) - } - if _, _, err := net.SplitHostPort(addr); err == nil { - return addr - } - if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 { - return net.JoinHostPort(addr, strconv.Itoa(port)) - } - return fmt.Sprintf("%s:%d", addr, port) -} - -func relayAgentAddress(node *model.Node) string { - if node == nil { - return "" - } - port := node.RelayVhostHTTPPort - if port <= 0 { - port = 8080 - } - addr := strings.TrimSpace(node.RelayAgentAccessAddr) - if addr == "" { - addr = strings.TrimSpace(node.RelayClientAccessAddr) - } - if addr == "" { - addr = strings.TrimSpace(node.IP) - } - if addr == "" { - return fmt.Sprintf("127.0.0.1:%d", port) - } - if _, _, err := net.SplitHostPort(addr); err == nil { - return addr - } - if strings.Contains(addr, ":") && strings.Count(addr, ":") > 1 { - return net.JoinHostPort(addr, strconv.Itoa(port)) - } - return fmt.Sprintf("%s:%d", addr, port) -} - -func parseTunnelTargetAddr(addr string) (string, int) { - addr = strings.TrimSpace(addr) - if addr == "" { - return "127.0.0.1", 80 - } - host, portStr, err := splitHostPort(addr) - if err != nil { - return addr, 80 - } - port := 80 - if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil { - port = 80 - } - return host, port -} - -func splitHostPort(addr string) (string, string, error) { - lastColon := strings.LastIndex(addr, ":") - if lastColon < 0 { - return addr, "", fmt.Errorf("no port") - } - return addr[:lastColon], addr[lastColon+1:], nil -} - -func sanitizeProxyName(domain string) string { - return strings.ReplaceAll(strings.ReplaceAll(domain, ".", "-"), "*", "wildcard") -} diff --git a/openflare-server/internal/service/relay_test.go b/openflare-server/internal/service/relay_test.go deleted file mode 100644 index 21bd61ca..00000000 --- a/openflare-server/internal/service/relay_test.go +++ /dev/null @@ -1,529 +0,0 @@ -package service - -import ( - "errors" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - "gorm.io/gorm" -) - -func TestHeartbeatRelayPersistsRuntimeAndObservability(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-relay-observe", - Name: "relay-1", - IP: "", - AccessToken: "relay-token", - Status: NodeStatusPending, - NodeType: "tunnel_relay", - RelayStatus: "unknown", - Version: "", - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed relay node: %v", err) - } - - now := time.Now().UTC() - _, err := HeartbeatRelay(node, RelayHeartbeatPayload{ - Version: "v0.1.0", - ExtVersion: "0.61.0", - RelayStatus: "healthy", - FrpsConnCount: 7, - FrpsProxyCount: 3, - Name: "relay-runtime", - IP: "203.0.113.9", - Profile: &AgentNodeSystemProfile{ - Hostname: "relay-runtime", - OSName: "Ubuntu", - OSVersion: "24.04", - Architecture: "amd64", - CPUCores: 4, - ReportedAtUnix: now.Unix(), - }, - Snapshot: &AgentNodeMetricSnapshot{ - CapturedAtUnix: now.Unix(), - CPUUsagePercent: 12.5, - NetworkRxBytes: 1024, - NetworkTxBytes: 2048, - }, - HealthEvents: []AgentNodeHealthEvent{}, - }) - if err != nil { - t.Fatalf("HeartbeatRelay failed: %v", err) - } - - updated, err := model.GetNodeByNodeID(node.NodeID) - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if updated.Status != NodeStatusOnline || updated.RelayStatus != "healthy" { - t.Fatalf("unexpected relay status: %+v", updated) - } - if updated.IP != "203.0.113.9" { - t.Fatalf("expected relay IP to be updated, got %q", updated.IP) - } - if updated.Version != "v0.1.0" || updated.ExtVersion != "0.61.0" { - t.Fatalf("expected relay versions to be updated, got relay=%q frp=%q", updated.Version, updated.ExtVersion) - } - - profile, err := model.GetNodeSystemProfile(node.NodeID) - if err != nil { - t.Fatalf("expected relay system profile: %v", err) - } - if profile.Hostname != "relay-runtime" || profile.OSName != "Ubuntu" { - t.Fatalf("unexpected relay profile: %+v", profile) - } - - snapshots, err := model.ListNodeMetricSnapshots(node.NodeID, now.Add(-time.Minute), 10) - if err != nil { - t.Fatalf("failed to list relay snapshots: %v", err) - } - if len(snapshots) != 1 || snapshots[0].CPUUsagePercent != 12.5 { - t.Fatalf("unexpected relay snapshots: %+v", snapshots) - } - - observability, err := GetNodeObservability(updated.ID, NodeObservabilityQuery{Hours: 1, Limit: 10}) - if err != nil { - t.Fatalf("GetNodeObservability failed: %v", err) - } - if observability.RelayDashboard == nil { - t.Fatal("expected relay dashboard snapshot") - } - if observability.RelayDashboard.TotalConnections != 0 || observability.RelayDashboard.TotalProxies != 0 { - // Frps telemetry collection is disabled; dashboard values are always zero. - t.Fatalf("unexpected relay dashboard: %+v", observability.RelayDashboard) - } -} - -func TestHeartbeatFlaredRejectsWrongNodeType(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-not-tunnel-client", - Name: "edge", - IP: "10.0.0.1", - AccessToken: "edge-token", - Status: NodeStatusPending, - NodeType: "edge_node", - Version: "v0.0.0", - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed edge node: %v", err) - } - - _, err := HeartbeatFlared(node, FlaredHeartbeatPayload{ - ClientVersion: "v0.1.0", - FrpVersion: "0.61.0", - TunnelStatus: "running", - CurrentVersion: "v1", - }) - if err == nil { - t.Fatal("expected error for non-tunnel_client node type") - } -} - -func TestHeartbeatFlaredRejectsNilNode(t *testing.T) { - if _, err := HeartbeatFlared(nil, FlaredHeartbeatPayload{}); err == nil { - t.Fatal("expected error when node is nil") - } -} - -func TestHeartbeatFlaredPersistsRuntime(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-flared-1", - Name: "flared-1", - IP: "", - AccessToken: "tunnel-token-abc", - Status: NodeStatusPending, - NodeType: "tunnel_client", - Version: "", - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed flared node: %v", err) - } - - resp, err := HeartbeatFlared(node, FlaredHeartbeatPayload{ - ClientVersion: " v0.2.0 ", - FrpVersion: " 0.61.1 ", - IP: " 192.168.1.10 ", - TunnelStatus: " RUNNING ", - ConnectedRelays: []FlaredConnectedRelay{ - {RelayNodeID: " node-relay-1 ", Status: " HEALTHY ", ProxyCount: 3}, - {RelayNodeID: "", Status: "running"}, - {RelayNodeID: "node-relay-2", Status: ""}, - }, - CurrentVersion: "v1", - CurrentChecksum: "checksum-1", - }) - if err != nil { - t.Fatalf("HeartbeatFlared failed: %v", err) - } - if resp == nil { - t.Fatal("expected non-nil response") - } - if resp.TunnelSettings == nil { - t.Fatal("expected tunnel_settings in response") - } - if resp.TunnelSettings.HeartbeatInterval == 0 { - t.Fatal("expected heartbeat interval to be set in tunnel_settings") - } - - updated, err := model.GetNodeByNodeID(node.NodeID) - if err != nil { - t.Fatalf("failed to reload flared node: %v", err) - } - if updated.Status != NodeStatusOnline { - t.Fatalf("expected flared node to be online, got %q", updated.Status) - } - if updated.Version != "v0.2.0" { - t.Fatalf("expected client_version to be trimmed and stored, got %q", updated.Version) - } - if updated.ExtVersion != "0.61.1" { - t.Fatalf("expected frp_version to be trimmed and stored, got %q", updated.ExtVersion) - } - if updated.IP != "192.168.1.10" { - t.Fatalf("expected IP to be trimmed and stored, got %q", updated.IP) - } - if updated.CurrentVersion != "v1" { - t.Fatalf("expected current_version to be stored, got %q", updated.CurrentVersion) - } - if updated.LastSeenAt.IsZero() { - t.Fatal("expected last_seen_at to be updated") - } - - // Test IPManualOverride - updated.IPManualOverride = true - if err := updated.Update(); err != nil { - t.Fatalf("failed to lock IP: %v", err) - } - - _, err = HeartbeatFlared(updated, FlaredHeartbeatPayload{ - ClientVersion: "v0.2.0", - FrpVersion: "0.61.1", - IP: "10.0.0.99", - TunnelStatus: "running", - }) - if err != nil { - t.Fatalf("second HeartbeatFlared failed: %v", err) - } - - lockedNode, err := model.GetNodeByNodeID(node.NodeID) - if err != nil { - t.Fatalf("failed to reload locked node: %v", err) - } - if lockedNode.IP != "192.168.1.10" { - t.Fatalf("expected IP to stay locked at 192.168.1.10, but got %q", lockedNode.IP) - } -} - -func TestHeartbeatFlaredTrimsAndFiltersRelays(t *testing.T) { - normalized := normalizeFlaredHeartbeatPayload(FlaredHeartbeatPayload{ - TunnelStatus: " UNHEALTHY ", - ConnectedRelays: []FlaredConnectedRelay{ - {RelayNodeID: " node-a ", Status: " OK "}, - {RelayNodeID: "", Status: "running"}, - }, - }) - if normalized.TunnelStatus != "unhealthy" { - t.Fatalf("expected tunnel_status to be lower-cased, got %q", normalized.TunnelStatus) - } - if len(normalized.ConnectedRelays) != 1 { - t.Fatalf("expected empty relay_node_id to be dropped, got %+v", normalized.ConnectedRelays) - } - relay := normalized.ConnectedRelays[0] - if relay.RelayNodeID != "node-a" { - t.Fatalf("expected relay_node_id to be trimmed, got %q", relay.RelayNodeID) - } - if relay.Status != "ok" { - t.Fatalf("expected status to be lower-cased, got %q", relay.Status) - } -} - -func TestHeartbeatFlaredEmitsHealthEventOnUnhealthy(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-flared-unhealthy", - Name: "flared-unhealthy", - IP: "", - AccessToken: "tunnel-token-unhealthy", - Status: NodeStatusPending, - NodeType: "tunnel_client", - Version: "", - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed flared node: %v", err) - } - - if _, err := HeartbeatFlared(node, FlaredHeartbeatPayload{ - ClientVersion: "v0.2.0", - FrpVersion: "0.61.0", - TunnelStatus: "unhealthy", - CurrentVersion: "v1", - CurrentChecksum: "checksum-1", - }); err != nil { - t.Fatalf("HeartbeatFlared failed: %v", err) - } - - events, err := model.ListNodeHealthEvents(node.NodeID, false, 20) - if err != nil { - t.Fatalf("ListNodeHealthEvents failed: %v", err) - } - if len(events) == 0 { - t.Fatal("expected unhealthy heartbeat to emit a node health event") - } - foundUnhealthy := false - for _, event := range events { - if event.EventType == "flared_runtime_unhealthy" { - foundUnhealthy = true - } - } - if !foundUnhealthy { - t.Fatalf("expected flared_runtime_unhealthy event in %+v", events) - } -} - -func TestHeartbeatFlaredEmitsEmptyConnectedRelays(t *testing.T) { - normalized := normalizeFlaredHeartbeatPayload(FlaredHeartbeatPayload{}) - if normalized.ConnectedRelays == nil { - t.Fatal("expected ConnectedRelays to be non-nil empty slice for nil input") - } - if len(normalized.ConnectedRelays) != 0 { - t.Fatalf("expected empty ConnectedRelays, got %+v", normalized.ConnectedRelays) - } -} - -func TestGetFlaredTunnelConfigRequiresActiveVersion(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "node-flared-noactive", - Name: "flared-noactive", - IP: "", - AccessToken: "tunnel-token-na", - Status: NodeStatusPending, - NodeType: "tunnel_client", - Version: "", - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed flared node: %v", err) - } - - _, err := GetFlaredTunnelConfig(node) - if err == nil { - t.Fatal("expected error when no active config version exists") - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - // We accept either wrapping the underlying error or surfacing a friendly message. - // Just ensure we surface a clear failure instead of a nil result. - t.Logf("GetFlaredTunnelConfig returned wrapped error: %v", err) - } -} - -func TestTunnelRoutePublishAndFlaredConfigUseRelayPorts(t *testing.T) { - setupServiceTestDB(t) - - relayNode := &model.Node{ - NodeID: "node-relay-ports", - Name: "relay-ports", - IP: "85.235.64.179", - AccessToken: "relay-token-ports", - Status: NodeStatusOnline, - NodeType: "tunnel_relay", - RelayStatus: "healthy", - RelayBindPort: 17000, - RelayVhostHTTPPort: 18080, - RelayAuthToken: "relay-auth-token", - RelayClientAccessAddr: "de-e", - } - if err := relayNode.Insert(); err != nil { - t.Fatalf("failed to seed relay node: %v", err) - } - tunnelNode := &model.Node{ - NodeID: "node-flared-ports", - Name: "flared-ports", - IP: "", - AccessToken: "tunnel-token-ports", - Status: NodeStatusOnline, - NodeType: "tunnel_client", - Version: "v0.2.0", - } - if err := tunnelNode.Insert(); err != nil { - t.Fatalf("failed to seed tunnel client node: %v", err) - } - - route, err := CreateProxyRoute(ProxyRouteInput{ - Domain: "flared.example.com", - UpstreamType: "tunnel", - TunnelID: &tunnelNode.ID, - TunnelTargetAddr: "10.0.0.8:8080", - TunnelTargetProtocol: "http", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - if route.TunnelNodeID == nil || *route.TunnelNodeID != tunnelNode.ID { - t.Fatalf("expected legacy tunnel_id to bind tunnel_node_id, got %+v", route.TunnelNodeID) - } - - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, "server de-e:18080 max_fails=3 fail_timeout=10s;") { - t.Fatalf("expected rendered OpenResty upstream to use relay vhost port, got:\n%s", result.Version.RenderedConfig) - } - - config, err := GetFlaredTunnelConfig(tunnelNode) - if err != nil { - t.Fatalf("GetFlaredTunnelConfig failed: %v", err) - } - if len(config.Relays) != 1 { - t.Fatalf("expected one relay, got %+v", config.Relays) - } - if config.Relays[0].Address != "de-e:17000" { - t.Fatalf("expected relay client address to include bind port, got %q", config.Relays[0].Address) - } - if len(config.Proxies) != 1 { - t.Fatalf("expected one proxy, got %+v", config.Proxies) - } - proxy := config.Proxies[0] - if proxy.LocalAddr != "10.0.0.8" || proxy.LocalPort != 8080 { - t.Fatalf("unexpected proxy target: %+v", proxy) - } - if len(proxy.CustomDomains) != 1 || proxy.CustomDomains[0] != "flared.example.com" { - t.Fatalf("unexpected proxy domains: %+v", proxy.CustomDomains) - } -} - -func TestHeartbeatRelaySelfUpdatePropagationAndReset(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "relay-update-node", - Name: "relay-u", - IP: "1.1.1.1", - AccessToken: "relay-update-token", - Status: NodeStatusPending, - NodeType: "tunnel_relay", - AutoUpdateEnabled: true, - UpdateRequested: true, - UpdateChannel: "preview", - UpdateTag: "v1.2.3", - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed relay node: %v", err) - } - - resp, err := HeartbeatRelay(node, RelayHeartbeatPayload{ - Version: "v1.0.0", - ExtVersion: "0.61.0", - RelayStatus: "healthy", - }) - if err != nil { - t.Fatalf("HeartbeatRelay failed: %v", err) - } - - if resp == nil || resp.RelaySettings == nil { - t.Fatal("expected non-nil response with RelaySettings") - } - - settings := resp.RelaySettings - if !settings.AutoUpdate { - t.Error("expected AutoUpdate to be true") - } - if !settings.UpdateNow { - t.Error("expected UpdateNow to be true") - } - if settings.UpdateChannel != "preview" { - t.Errorf("expected UpdateChannel to be preview, got %q", settings.UpdateChannel) - } - if settings.UpdateTag != "v1.2.3" { - t.Errorf("expected UpdateTag to be v1.2.3, got %q", settings.UpdateTag) - } - - // Verify that the requested update was cleared in the DB - updated, err := model.GetNodeByNodeID(node.NodeID) - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if updated.UpdateRequested { - t.Error("expected UpdateRequested to be reset to false in the database") - } - if updated.UpdateChannel != "stable" { - t.Errorf("expected UpdateChannel to be reset to stable, got %q", updated.UpdateChannel) - } - if updated.UpdateTag != "" { - t.Errorf("expected UpdateTag to be reset to empty, got %q", updated.UpdateTag) - } -} - -func TestHeartbeatFlaredSelfUpdatePropagationAndReset(t *testing.T) { - setupServiceTestDB(t) - - node := &model.Node{ - NodeID: "flared-update-node", - Name: "flared-u", - IP: "1.1.1.2", - AccessToken: "flared-update-token", - Status: NodeStatusPending, - NodeType: "tunnel_client", - AutoUpdateEnabled: true, - UpdateRequested: true, - UpdateChannel: "stable", - UpdateTag: "v2.3.4", - } - if err := node.Insert(); err != nil { - t.Fatalf("failed to seed flared node: %v", err) - } - - resp, err := HeartbeatFlared(node, FlaredHeartbeatPayload{ - ClientVersion: "v1.0.0", - FrpVersion: "0.61.0", - TunnelStatus: "running", - }) - if err != nil { - t.Fatalf("HeartbeatFlared failed: %v", err) - } - - if resp == nil || resp.TunnelSettings == nil { - t.Fatal("expected non-nil response with TunnelSettings") - } - - settings := resp.TunnelSettings - if !settings.AutoUpdate { - t.Error("expected AutoUpdate to be true") - } - if !settings.UpdateNow { - t.Error("expected UpdateNow to be true") - } - if settings.UpdateChannel != "stable" { - t.Errorf("expected UpdateChannel to be stable, got %q", settings.UpdateChannel) - } - if settings.UpdateTag != "v2.3.4" { - t.Errorf("expected UpdateTag to be v2.3.4, got %q", settings.UpdateTag) - } - - // Verify that the requested update was cleared in the DB - updated, err := model.GetNodeByNodeID(node.NodeID) - if err != nil { - t.Fatalf("failed to reload node: %v", err) - } - if updated.UpdateRequested { - t.Error("expected UpdateRequested to be reset to false in the database") - } - if updated.UpdateChannel != "stable" { - t.Errorf("expected UpdateChannel to be reset to stable, got %q", updated.UpdateChannel) - } - if updated.UpdateTag != "" { - t.Errorf("expected UpdateTag to be reset to empty, got %q", updated.UpdateTag) - } -} diff --git a/openflare-server/internal/service/relay_ws.go b/openflare-server/internal/service/relay_ws.go deleted file mode 100644 index ddaad9c6..00000000 --- a/openflare-server/internal/service/relay_ws.go +++ /dev/null @@ -1,41 +0,0 @@ -package service - -const ( - RelayWSConnectedLastSeenValue = "__OPENFLARE_WS_CONNECTED__" -) - -var DefaultRelayWSHub = NewWSHub("relay") - -func RegisterRelayWSClient(nodeID string) *WSClient { - return DefaultRelayWSHub.Register(nodeID) -} - -func UnregisterRelayWSClient(client *WSClient) { - DefaultRelayWSHub.Unregister(client) -} - -func IsRelayWSConnected(nodeID string) bool { - return DefaultRelayWSHub.IsConnected(nodeID) -} - -func SendRelayWSPing(nodeID string) bool { - return DefaultRelayWSHub.SendMessage(nodeID, WSMessage{ - Type: "ping", - }) -} - -func SendRelayWSPong(nodeID string) bool { - return DefaultRelayWSHub.SendMessage(nodeID, WSMessage{ - Type: "pong", - }) -} - -func SendRelayWSConfig(nodeID string, config *RelayConfig) bool { - if config == nil { - return false - } - return DefaultRelayWSHub.SendMessage(nodeID, WSMessage{ - Type: "relay_config", - Payload: config, - }) -} diff --git a/openflare-server/internal/service/tls_acme_test.go b/openflare-server/internal/service/tls_acme_test.go deleted file mode 100644 index e72d351f..00000000 --- a/openflare-server/internal/service/tls_acme_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package service - -import ( - "errors" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestAcmeAndDnsIntegration(t *testing.T) { - setupServiceTestDB(t) - - // 1. Create a DNS Account - dnsAccount := &model.DnsAccount{ - Name: "Test Cloudflare", - Type: "cloudflare", - Authorization: `{"api_token": "dummy_token"}`, - } - if err := dnsAccount.Insert(); err != nil { - t.Fatalf("Failed to insert DNS Account: %v", err) - } - - // 2. Apply for TLS Certificate (using the new ApplyTLSCertificate function) - certInput := TLSApplyInput{ - Name: "Test ACME Cert", - PrimaryDomain: "example.com", - OtherDomains: "*.example.com", - DnsAccountID: dnsAccount.ID, - KeyAlgorithm: "RSA2048", - AutoRenew: true, - } - - cert, err := ApplyTLSCertificate(certInput) - if err != nil { - t.Fatalf("ApplyTLSCertificate failed: %v", err) - } - - if cert.ApplyStatus != "applying" { - t.Fatalf("Expected cert ApplyStatus to be applying, got %s", cert.ApplyStatus) - } - - if cert.Provider != "acme" { - t.Fatalf("Expected cert Provider to be acme, got %s", cert.Provider) - } - - // 3. Try to delete the DNS account (should fail since it's used by the cert) - // Actually, the delete logic is in the controller for the foreign key check. - // But let's check if the controller logic can be tested here, or we just trust the DB setup. - var count int64 - model.DB.Model(&model.TLSCertificate{}).Where("dns_account_id = ?", dnsAccount.ID).Count(&count) - if count != 1 { - t.Fatalf("Expected 1 certificate associated with DNS account, got %d", count) - } - - // 4. Test RenewTLSCertificate - renewedCert, err := RenewTLSCertificate(cert.ID) - if err != nil { - t.Fatalf("RenewTLSCertificate failed: %v", err) - } - if renewedCert.ApplyStatus != "applying" { - t.Fatalf("Expected renewed cert ApplyStatus to be applying, got %s", renewedCert.ApplyStatus) - } - - // Wait for the async goroutine to fail (it now registers an LE account, which takes longer) - time.Sleep(5 * time.Second) - - // Reload cert and verify error status - finalCert, err := model.GetTLSCertificateByID(renewedCert.ID) - if err != nil { - t.Fatalf("Failed to reload cert: %v", err) - } - if finalCert.ApplyStatus != "error" { - t.Fatalf("Expected final cert ApplyStatus to be error, got %s", finalCert.ApplyStatus) - } - if finalCert.ApplyMessage == "" { - t.Fatalf("Expected final cert ApplyMessage to be populated, got empty") - } - - // Clean up - if err := DeleteTLSCertificate(cert.ID); err != nil { - t.Fatalf("DeleteTLSCertificate failed: %v", err) - } - - if err := dnsAccount.Delete(); err != nil { - t.Fatalf("Failed to delete DNS Account after cert cleanup: %v", err) - } -} - -func TestConvertTLSCertificateToAcmePreservesUploadUntilSuccess(t *testing.T) { - setupServiceTestDB(t) - - originalCertPEM, originalKeyPEM := generateCertificatePair(t, []string{"manual.example.com"}) - cert, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "manual-cert", - CertPEM: originalCertPEM, - KeyPEM: originalKeyPEM, - Remark: "manual upload", - }) - if err != nil { - t.Fatalf("CreateTLSCertificate failed: %v", err) - } - originalCertPEM = cert.CertPEM - originalKeyPEM = cert.KeyPEM - - newCertPEM, newKeyPEM := generateCertificatePair(t, []string{"managed.example.com"}) - started := make(chan struct{}, 1) - release := make(chan struct{}) - restore := SetTLSCertificateObtainFuncForTest(func(c *model.TLSCertificate) error { - started <- struct{}{} - <-release - c.CertPEM = newCertPEM - c.KeyPEM = newKeyPEM - c.NotBefore = time.Now().Add(-time.Hour) - c.NotAfter = time.Now().Add(90 * 24 * time.Hour) - c.ApplyStatus = "ready" - c.ApplyMessage = "" - return model.DB.Save(c).Error - }) - t.Cleanup(restore) - - converted, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{ - Name: "managed-cert", - Remark: "converted", - AcmeAccountID: 1, - DnsAccountID: 2, - KeyAlgorithm: "EC256", - AutoRenew: true, - PrimaryDomain: "managed.example.com", - OtherDomains: "www.managed.example.com", - }) - if err != nil { - t.Fatalf("ConvertTLSCertificateToAcme failed: %v", err) - } - if converted.ID != cert.ID { - t.Fatalf("expected converted certificate to keep id %d, got %d", cert.ID, converted.ID) - } - - select { - case <-started: - case <-time.After(time.Second): - t.Fatal("expected conversion obtain task to start") - } - - applying, err := model.GetTLSCertificateByID(cert.ID) - if err != nil { - t.Fatalf("reload applying certificate failed: %v", err) - } - if applying.Provider != "upload" { - t.Fatalf("expected provider to remain upload while applying, got %s", applying.Provider) - } - if applying.ApplyStatus != "applying" { - t.Fatalf("expected applying status, got %s", applying.ApplyStatus) - } - if applying.CertPEM != originalCertPEM || applying.KeyPEM != originalKeyPEM { - t.Fatal("expected original PEM payloads to be preserved while applying") - } - - close(release) - - finalCert := waitForCertificateState(t, cert.ID, func(c *model.TLSCertificate) bool { - return c.Provider == "acme" && c.ApplyStatus == "ready" - }) - if finalCert.CertPEM != newCertPEM || finalCert.KeyPEM != newKeyPEM { - t.Fatal("expected successful conversion to replace PEM payloads") - } - if !finalCert.AutoRenew { - t.Fatal("expected converted certificate to keep auto renew enabled") - } - if finalCert.PrimaryDomain != "managed.example.com" || finalCert.OtherDomains != "www.managed.example.com" { - t.Fatalf("expected converted certificate to persist ACME domains, got %+v", finalCert) - } -} - -func TestConvertTLSCertificateToAcmePreservesUploadOnFailure(t *testing.T) { - setupServiceTestDB(t) - - originalCertPEM, originalKeyPEM := generateCertificatePair(t, []string{"manual.example.com"}) - cert, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "manual-cert", - CertPEM: originalCertPEM, - KeyPEM: originalKeyPEM, - }) - if err != nil { - t.Fatalf("CreateTLSCertificate failed: %v", err) - } - originalCertPEM = cert.CertPEM - originalKeyPEM = cert.KeyPEM - - restore := SetTLSCertificateObtainFuncForTest(func(c *model.TLSCertificate) error { - err := errors.New("dns challenge failed") - updateCertError(c, err.Error()) - return err - }) - t.Cleanup(restore) - - if _, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{ - Name: "manual-cert", - DnsAccountID: 1, - PrimaryDomain: "manual.example.com", - }); err != nil { - t.Fatalf("ConvertTLSCertificateToAcme failed: %v", err) - } - - finalCert := waitForCertificateState(t, cert.ID, func(c *model.TLSCertificate) bool { - return c.ApplyStatus == "error" - }) - if finalCert.Provider != "upload" { - t.Fatalf("expected failed conversion to keep upload provider, got %s", finalCert.Provider) - } - if finalCert.CertPEM != originalCertPEM || finalCert.KeyPEM != originalKeyPEM { - t.Fatal("expected failed conversion to preserve original PEM payloads") - } - if !strings.Contains(finalCert.ApplyMessage, "dns challenge failed") { - t.Fatalf("expected conversion error message, got %q", finalCert.ApplyMessage) - } -} - -func TestConvertTLSCertificateToAcmeRejectsInvalidStates(t *testing.T) { - setupServiceTestDB(t) - - certPEM, keyPEM := generateCertificatePair(t, []string{"manual.example.com"}) - cert, err := CreateTLSCertificate(TLSCertificateInput{ - Name: "manual-cert", - CertPEM: certPEM, - KeyPEM: keyPEM, - }) - if err != nil { - t.Fatalf("CreateTLSCertificate failed: %v", err) - } - - cert.Provider = "acme" - if err := cert.Update(); err != nil { - t.Fatalf("failed to mark certificate acme: %v", err) - } - if _, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{Name: "manual-cert"}); err == nil || !strings.Contains(err.Error(), "only uploaded") { - t.Fatalf("expected non-upload conversion to fail, got %v", err) - } - - cert.Provider = "upload" - cert.ApplyStatus = "applying" - if err := cert.Update(); err != nil { - t.Fatalf("failed to mark certificate applying: %v", err) - } - if _, err := ConvertTLSCertificateToAcme(cert.ID, TLSApplyInput{Name: "manual-cert"}); err == nil || !strings.Contains(err.Error(), "already applying") { - t.Fatalf("expected applying conversion to fail, got %v", err) - } -} - -func waitForCertificateState(t *testing.T, id uint, matches func(*model.TLSCertificate) bool) *model.TLSCertificate { - t.Helper() - - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - cert, err := model.GetTLSCertificateByID(id) - if err != nil { - t.Fatalf("reload certificate %d failed: %v", id, err) - } - if matches(cert) { - return cert - } - time.Sleep(10 * time.Millisecond) - } - - cert, err := model.GetTLSCertificateByID(id) - if err != nil { - t.Fatalf("reload certificate %d failed: %v", id, err) - } - t.Fatalf("certificate %d did not reach expected state: %+v", id, cert) - return nil -} diff --git a/openflare-server/internal/service/tls_certificate.go b/openflare-server/internal/service/tls_certificate.go deleted file mode 100644 index 68d47e54..00000000 --- a/openflare-server/internal/service/tls_certificate.go +++ /dev/null @@ -1,365 +0,0 @@ -package service - -import ( - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "mime/multipart" - "strings" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -type TLSCertificateInput struct { - Name string `json:"name"` - CertPEM string `json:"cert_pem"` - KeyPEM string `json:"key_pem"` - Remark string `json:"remark"` -} - -type TLSCertificateContent struct { - ID uint `json:"id"` - Name string `json:"name"` - CertPEM string `json:"cert_pem"` - KeyPEM string `json:"key_pem"` - Remark string `json:"remark"` - Provider string `json:"provider"` - AcmeAccountID uint `json:"acme_account_id"` - DnsAccountID uint `json:"dns_account_id"` - KeyAlgorithm string `json:"key_algorithm"` - AutoRenew bool `json:"auto_renew"` - PrimaryDomain string `json:"primary_domain"` - OtherDomains string `json:"other_domains"` - DisableCNAME bool `json:"disable_cname"` - SkipDNS bool `json:"skip_dns"` - DNS1 string `json:"dns1"` - DNS2 string `json:"dns2"` - ApplyStatus string `json:"apply_status"` - ApplyMessage string `json:"apply_message"` -} - -type TLSApplyInput struct { - Name string `json:"name"` - Remark string `json:"remark"` - AcmeAccountID uint `json:"acme_account_id"` - DnsAccountID uint `json:"dns_account_id"` - KeyAlgorithm string `json:"key_algorithm"` - AutoRenew bool `json:"auto_renew"` - PrimaryDomain string `json:"primary_domain"` - OtherDomains string `json:"other_domains"` - DisableCNAME bool `json:"disable_cname"` - SkipDNS bool `json:"skip_dns"` - DNS1 string `json:"dns1"` - DNS2 string `json:"dns2"` -} - -var obtainTLSCertificate = ObtainSSL - -func SetTLSCertificateObtainFuncForTest(fn func(*model.TLSCertificate) error) func() { - previous := obtainTLSCertificate - obtainTLSCertificate = fn - return func() { - obtainTLSCertificate = previous - } -} - -func ListTLSCertificates() ([]*model.TLSCertificate, error) { - return model.ListTLSCertificates() -} - -func GetTLSCertificate(id uint) (*model.TLSCertificate, error) { - return model.GetTLSCertificateByID(id) -} - -func GetTLSCertificateContent(id uint) (*TLSCertificateContent, error) { - certificate, err := model.GetTLSCertificateByID(id) - if err != nil { - return nil, err - } - - return &TLSCertificateContent{ - ID: certificate.ID, - Name: certificate.Name, - CertPEM: certificate.CertPEM, - KeyPEM: certificate.KeyPEM, - Remark: certificate.Remark, - Provider: certificate.Provider, - AcmeAccountID: certificate.AcmeAccountID, - DnsAccountID: certificate.DnsAccountID, - KeyAlgorithm: certificate.KeyAlgorithm, - AutoRenew: certificate.AutoRenew, - PrimaryDomain: certificate.PrimaryDomain, - OtherDomains: certificate.OtherDomains, - DisableCNAME: certificate.DisableCNAME, - SkipDNS: certificate.SkipDNS, - DNS1: certificate.DNS1, - DNS2: certificate.DNS2, - ApplyStatus: certificate.ApplyStatus, - ApplyMessage: certificate.ApplyMessage, - }, nil -} - -func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, error) { - certificate, err := buildTLSCertificate(nil, input) - if err != nil { - return nil, err - } - if err = certificate.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("certificate name already exists") - } - return nil, err - } - return certificate, nil -} - -func CreateTLSCertificateFromFiles(name string, certFile *multipart.FileHeader, keyFile *multipart.FileHeader, remark string) (*model.TLSCertificate, error) { - if certFile == nil || keyFile == nil { - return nil, errors.New("certificate file and key file cannot be empty") - } - certContent, err := readMultipartFile(certFile) - if err != nil { - return nil, err - } - keyContent, err := readMultipartFile(keyFile) - if err != nil { - return nil, err - } - return CreateTLSCertificate(TLSCertificateInput{ - Name: name, - CertPEM: certContent, - KeyPEM: keyContent, - Remark: remark, - }) -} - -func UpdateTLSCertificate(id uint, input TLSCertificateInput) (*model.TLSCertificate, error) { - existing, err := model.GetTLSCertificateByID(id) - if err != nil { - return nil, err - } - - certificate, err := buildTLSCertificate(existing, input) - if err != nil { - return nil, err - } - if err = certificate.Update(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("certificate name already exists") - } - return nil, err - } - return certificate, nil -} - -func DeleteTLSCertificate(id uint) error { - routes, err := model.ListProxyRoutes() - if err != nil { - return err - } - 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 - } - return certificate.Delete() -} - -func fillAcmeCertificateFields(cert *model.TLSCertificate, input TLSApplyInput) { - cert.Name = strings.TrimSpace(input.Name) - cert.Remark = strings.TrimSpace(input.Remark) - cert.AcmeAccountID = input.AcmeAccountID - cert.DnsAccountID = input.DnsAccountID - cert.KeyAlgorithm = input.KeyAlgorithm - cert.AutoRenew = input.AutoRenew - cert.PrimaryDomain = strings.TrimSpace(input.PrimaryDomain) - cert.OtherDomains = strings.TrimSpace(input.OtherDomains) - cert.DisableCNAME = input.DisableCNAME - cert.SkipDNS = input.SkipDNS - cert.DNS1 = strings.TrimSpace(input.DNS1) - cert.DNS2 = strings.TrimSpace(input.DNS2) - cert.ApplyStatus = "applying" -} - -func ApplyTLSCertificate(input TLSApplyInput) (*model.TLSCertificate, error) { - cert := &model.TLSCertificate{ - Provider: "acme", - CertPEM: " ", // Temporary empty value, since gorm may prevent empty insert - KeyPEM: " ", // Temporary empty value - } - fillAcmeCertificateFields(cert, input) - - if cert.Name == "" { - return nil, errors.New("certificate name cannot be empty") - } - - if err := cert.Insert(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("certificate name already exists") - } - return nil, err - } - - // Async obtain SSL - go func(c *model.TLSCertificate) { - _ = obtainTLSCertificate(c) - }(cert) - - return cert, nil -} - -func UpdateAcmeCertificate(id uint, input TLSApplyInput) (*model.TLSCertificate, error) { - cert, err := model.GetTLSCertificateByID(id) - if err != nil { - return nil, err - } - if cert.Provider != "acme" { - return nil, errors.New("only acme certificates can be updated via this endpoint") - } - - fillAcmeCertificateFields(cert, input) - if cert.Name == "" { - return nil, errors.New("certificate name cannot be empty") - } - - if err := cert.Update(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("certificate name already exists") - } - return nil, err - } - - // Async obtain SSL with updated config - go func(c *model.TLSCertificate) { - _ = obtainTLSCertificate(c) - }(cert) - - return cert, nil -} - -func ConvertTLSCertificateToAcme(id uint, input TLSApplyInput) (*model.TLSCertificate, error) { - cert, err := model.GetTLSCertificateByID(id) - if err != nil { - return nil, err - } - if cert.Provider != "upload" { - return nil, errors.New("only uploaded certificates can be converted to acme") - } - if cert.ApplyStatus == "applying" { - return nil, errors.New("certificate is already applying") - } - - fillAcmeCertificateFields(cert, input) - if cert.Name == "" { - return nil, errors.New("certificate name cannot be empty") - } - cert.ApplyMessage = "" - - if err := cert.Update(); err != nil { - if model.IsUniqueConstraintError(err) { - return nil, errors.New("certificate name already exists") - } - return nil, err - } - - go func(c *model.TLSCertificate) { - if err := obtainTLSCertificate(c); err != nil { - return - } - - latest, err := model.GetTLSCertificateByID(c.ID) - if err != nil { - return - } - latest.Provider = "acme" - latest.ApplyStatus = "ready" - latest.ApplyMessage = "" - _ = latest.Update() - }(cert) - - return cert, nil -} - -func RenewTLSCertificate(id uint) (*model.TLSCertificate, error) { - cert, err := model.GetTLSCertificateByID(id) - if err != nil { - return nil, err - } - if cert.Provider != "acme" { - return nil, errors.New("only acme certificates can be renewed") - } - - // Async obtain SSL - go func(c *model.TLSCertificate) { - _ = obtainTLSCertificate(c) - }(cert) - - cert.ApplyStatus = "applying" - cert.Update() - - return cert, nil -} - -func buildTLSCertificate(existing *model.TLSCertificate, input TLSCertificateInput) (*model.TLSCertificate, error) { - name := strings.TrimSpace(input.Name) - certPEM := strings.TrimSpace(input.CertPEM) - keyPEM := strings.TrimSpace(input.KeyPEM) - remark := strings.TrimSpace(input.Remark) - if name == "" { - return nil, errors.New("certificate name cannot be empty") - } - if certPEM == "" || keyPEM == "" { - 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("certificate or key format is invalid: %w", err) - } - if len(parsed.Certificate) == 0 { - return nil, errors.New("certificate content is invalid") - } - leaf, err := parseLeafCertificate(certPEM) - if err != nil { - return nil, err - } - if existing == nil { - existing = &model.TLSCertificate{} - } - existing.Name = name - existing.CertPEM = certPEM - existing.KeyPEM = keyPEM - existing.NotBefore = leaf.NotBefore - existing.NotAfter = leaf.NotAfter - existing.Remark = remark - return existing, nil -} diff --git a/openflare-server/internal/service/tls_certificate_helpers.go b/openflare-server/internal/service/tls_certificate_helpers.go deleted file mode 100644 index fc9fb2af..00000000 --- a/openflare-server/internal/service/tls_certificate_helpers.go +++ /dev/null @@ -1,34 +0,0 @@ -package service - -import ( - "crypto/x509" - "encoding/pem" - "errors" - "io" - "mime/multipart" -) - -func parseLeafCertificate(certPEM string) (*x509.Certificate, error) { - certPEMBlock, _ := pem.Decode([]byte(certPEM)) - if certPEMBlock == nil { - return nil, errors.New("证书 PEM 内容不合法") - } - leaf, err := x509.ParseCertificate(certPEMBlock.Bytes) - if err != nil { - return nil, err - } - return leaf, nil -} - -func readMultipartFile(fileHeader *multipart.FileHeader) (string, error) { - file, err := fileHeader.Open() - if err != nil { - return "", err - } - defer file.Close() - data, err := io.ReadAll(file) - if err != nil { - return "", err - } - return string(data), nil -} diff --git a/openflare-server/internal/service/update.go b/openflare-server/internal/service/update.go deleted file mode 100644 index 901159c0..00000000 --- a/openflare-server/internal/service/update.go +++ /dev/null @@ -1,868 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/pkg/utils" -) - -const ( - serverReleaseRepo = "Rain-kl/OpenFlare" - githubReleasesAPIBase = "https://api.github.com/repos/%s/releases" -) - -type ReleaseChannel string - -const ( - ReleaseChannelStable ReleaseChannel = "stable" - ReleaseChannelPreview ReleaseChannel = "preview" -) - -var updateHTTPClient = &http.Client{ - Timeout: 30 * time.Second, -} - -var serverUpgradeState struct { - sync.Mutex - inProgress bool - status string - logs []ServerUpgradeLogRecord -} - -var serverUpgradeSubscribers struct { - sync.Mutex - nextID int - listeners map[int]chan ServerUpgradeStreamSnapshot -} - -var manualServerBinaryState struct { - sync.Mutex - candidate *manualServerBinaryCandidate -} - -var serverBinaryUpgradeExecutor = replaceAndRestartServer - -var serverUpgradeDispatchDelay = 500 * time.Millisecond - -type LatestServerRelease struct { - TagName string `json:"tag_name"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` - PublishedAt string `json:"published_at"` - Channel string `json:"channel"` - Prerelease bool `json:"prerelease"` - CurrentVersion string `json:"current_version"` - HasUpdate bool `json:"has_update"` - UpgradeSupported bool `json:"upgrade_supported"` - InProgress bool `json:"in_progress"` - UpgradeStatus string `json:"upgrade_status"` - UpgradeLogs []ServerUpgradeLogRecord `json:"upgrade_logs"` -} - -type ServerUpgradeLogRecord struct { - Level string `json:"level"` - Message string `json:"message"` - CreatedAt time.Time `json:"created_at"` -} - -type ServerUpgradeStreamSnapshot struct { - InProgress bool `json:"in_progress"` - UpgradeStatus string `json:"upgrade_status"` - UpgradeLogs []ServerUpgradeLogRecord `json:"upgrade_logs"` -} - -type githubReleaseResponse struct { - TagName string `json:"tag_name"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` - PublishedAt string `json:"published_at"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` - Assets []githubAsset `json:"assets"` -} - -type githubAsset struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` -} - -type preparedServerUpgrade struct { - release *LatestServerRelease - downloadURL string - execPath string -} - -type UploadedServerBinary struct { - UploadToken string `json:"upload_token"` - FileName string `json:"file_name"` - DetectedVersion string `json:"detected_version"` - CurrentVersion string `json:"current_version"` - HasUpdate bool `json:"has_update"` - UpgradeSupported bool `json:"upgrade_supported"` - ReadyToUpgrade bool `json:"ready_to_upgrade"` - ComparisonMessage string `json:"comparison_message"` - UploadedAt time.Time `json:"uploaded_at"` -} - -type manualServerBinaryCandidate struct { - UploadToken string - FileName string - DetectedVersion string - CurrentVersion string - TempPath string - ExecPath string - UploadedAt time.Time -} - -func GetLatestServerRelease(ctx context.Context, channel string) (*LatestServerRelease, error) { - normalizedChannel := normalizeReleaseChannel(channel) - release, err := fetchLatestRelease(ctx, normalizedChannel) - if err != nil { - return nil, err - } - return buildLatestServerReleaseView(release, normalizedChannel), nil -} - -func ScheduleServerUpgrade(channel string) (*LatestServerRelease, error) { - normalizedChannel := normalizeReleaseChannel(channel) - serverUpgradeState.Lock() - if serverUpgradeState.inProgress { - serverUpgradeState.Unlock() - return nil, fmt.Errorf("服务升级正在执行中,请稍后再试") - } - - resetServerUpgradeLogsLocked() - serverUpgradeState.status = "running" - appendServerUpgradeLogLocked("info", fmt.Sprintf("Automatic upgrade scheduled for channel: %s.", normalizedChannel.String())) - serverUpgradeState.Unlock() - broadcastServerUpgradeSnapshot() - - prepared, err := prepareServerUpgrade(context.Background(), normalizedChannel) - if err != nil { - serverUpgradeState.Lock() - serverUpgradeState.status = "failed" - appendServerUpgradeLogLocked("error", err.Error()) - serverUpgradeState.Unlock() - broadcastServerUpgradeSnapshot() - return nil, err - } - - serverUpgradeState.Lock() - serverUpgradeState.inProgress = true - serverUpgradeState.Unlock() - broadcastServerUpgradeSnapshot() - - prepared.release.InProgress = true - - go func(task *preparedServerUpgrade) { - time.Sleep(serverUpgradeDispatchDelay) - if err := executeServerUpgrade(task); err != nil { - recordServerUpgradeFailure(err) - slog.Error("server self-update failed", "error", err) - } - }(prepared) - - return prepared.release, nil -} - -func UploadManualServerBinary(ctx context.Context, fileName string, reader io.Reader) (*UploadedServerBinary, error) { - inProgress, _, _ := snapshotServerUpgradeState() - if inProgress { - return nil, fmt.Errorf("服务升级正在执行中,请稍后再试") - } - if strings.TrimSpace(fileName) == "" { - return nil, fmt.Errorf("缺少上传文件名") - } - if reader == nil { - return nil, fmt.Errorf("缺少上传文件内容") - } - - execPath, err := os.Executable() - if err != nil { - return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err) - } - if err = verifyExecutableDirectoryWritable(execPath); err != nil { - return nil, err - } - tempPath, err := persistUploadedServerBinary(filepath.Dir(execPath), fileName, reader) - if err != nil { - return nil, err - } - - detectedVersion, err := detectUploadedServerBinaryVersion(ctx, tempPath) - if err != nil { - _ = os.Remove(tempPath) - return nil, err - } - - currentVersion := strings.TrimSpace(common.Version) - uploadedAt := time.Now() - info := buildUploadedServerBinaryView(fileName, currentVersion, detectedVersion, uploadedAt) - if !info.ReadyToUpgrade { - _ = os.Remove(tempPath) - return info, nil - } - - uploadToken, err := newUpgradeToken() - if err != nil { - _ = os.Remove(tempPath) - return nil, fmt.Errorf("生成升级令牌失败: %v", err) - } - - manualServerBinaryState.Lock() - cleanupManualServerBinaryCandidateLocked() - manualServerBinaryState.candidate = &manualServerBinaryCandidate{ - UploadToken: uploadToken, - FileName: fileName, - DetectedVersion: detectedVersion, - CurrentVersion: currentVersion, - TempPath: tempPath, - ExecPath: execPath, - UploadedAt: uploadedAt, - } - manualServerBinaryState.Unlock() - - info.UploadToken = uploadToken - return info, nil -} - -func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, error) { - uploadToken = strings.TrimSpace(uploadToken) - if uploadToken == "" { - return nil, fmt.Errorf("缺少升级令牌") - } - - serverUpgradeState.Lock() - if serverUpgradeState.inProgress { - serverUpgradeState.Unlock() - return nil, fmt.Errorf("服务升级正在执行中,请稍后再试") - } - serverUpgradeState.Unlock() - - manualServerBinaryState.Lock() - candidate := manualServerBinaryState.candidate - if candidate == nil { - manualServerBinaryState.Unlock() - return nil, fmt.Errorf("未找到待确认的上传升级包,请重新上传") - } - if candidate.UploadToken != uploadToken { - manualServerBinaryState.Unlock() - return nil, fmt.Errorf("升级令牌无效或已过期,请重新上传") - } - manualServerBinaryState.candidate = nil - manualServerBinaryState.Unlock() - - info := buildUploadedServerBinaryView(candidate.FileName, candidate.CurrentVersion, candidate.DetectedVersion, candidate.UploadedAt) - info.UploadToken = candidate.UploadToken - if !info.ReadyToUpgrade { - _ = os.Remove(candidate.TempPath) - return nil, fmt.Errorf("当前上传的二进制不满足升级条件") - } - - serverUpgradeState.Lock() - serverUpgradeState.inProgress = true - resetServerUpgradeLogsLocked() - serverUpgradeState.status = "running" - appendServerUpgradeLogLocked("info", fmt.Sprintf("Manual upgrade confirmed for version: %s.", strings.TrimSpace(candidate.DetectedVersion))) - serverUpgradeState.Unlock() - broadcastServerUpgradeSnapshot() - - go func(task *manualServerBinaryCandidate) { - time.Sleep(serverUpgradeDispatchDelay) - if err := executeServerBinaryCandidateUpgrade(task, "manual"); err != nil { - recordServerUpgradeFailure(err) - slog.Error("server manual upgrade failed", "error", err) - _ = os.Remove(task.TempPath) - } - }(candidate) - - return info, nil -} - -func fetchLatestRelease(ctx context.Context, channel ReleaseChannel) (*githubReleaseResponse, error) { - return fetchLatestGitHubRelease(ctx, serverReleaseRepo, channel) -} - -func fetchLatestGitHubRelease(ctx context.Context, repo string, channel ReleaseChannel) (*githubReleaseResponse, error) { - switch normalizeReleaseChannel(string(channel)) { - case ReleaseChannelPreview: - return fetchLatestPreviewGitHubRelease(ctx, repo) - default: - return fetchLatestStableGitHubRelease(ctx, repo) - } -} - -func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) { - url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo)) - req, err := newGitHubReleaseRequest(ctx, url) - if err != nil { - return nil, fmt.Errorf("创建更新请求失败") - } - - resp, err := updateHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("获取最新版本失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) - } - - return decodeGitHubRelease(resp.Body) -} - -func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) { - url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo)) - req, err := newGitHubReleaseRequest(ctx, url) - if err != nil { - return nil, fmt.Errorf("创建更新请求失败") - } - - resp, err := updateHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("获取 preview 版本失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) - } - - var releases []githubReleaseResponse - if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil { - return nil, fmt.Errorf("解析 preview 版本信息失败") - } - for _, release := range releases { - if release.Draft || !release.Prerelease { - continue - } - releaseCopy := release - return &releaseCopy, nil - } - return nil, fmt.Errorf("当前没有可用的 preview 发布") -} - -func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) { - tag = strings.TrimSpace(tag) - if tag == "" { - return nil, fmt.Errorf("缺少发布版本号") - } - url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag) - req, err := newGitHubReleaseRequest(ctx, url) - if err != nil { - return nil, fmt.Errorf("创建更新请求失败") - } - - resp, err := updateHTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("获取指定版本失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("未找到指定版本: %s", tag) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) - } - - return decodeGitHubRelease(resp.Body) -} - -func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "OpenFlare-Server") - return req, nil -} - -func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) { - var release githubReleaseResponse - if err := json.NewDecoder(reader).Decode(&release); err != nil { - return nil, fmt.Errorf("解析版本信息失败") - } - return &release, nil -} - -func buildLatestServerReleaseView(release *githubReleaseResponse, channel ReleaseChannel) *LatestServerRelease { - currentVersion := strings.TrimSpace(common.Version) - isDevBuild := currentVersion == "" || strings.EqualFold(currentVersion, "dev") - hasUpdate := false - if release != nil && !isDevBuild { - if channel == ReleaseChannelPreview { - // Preview releases use a "major.minor.patch-git-" scheme that cannot - // be meaningfully compared against the running stable version, so we skip the - // version check and always allow upgrading when the user explicitly selects - // the preview channel. - hasUpdate = true - } else { - hasUpdate = isVersionNewer(currentVersion, release.TagName) - } - } - - inProgress, upgradeStatus, upgradeLogs := snapshotServerUpgradeState() - - view := &LatestServerRelease{ - Channel: channel.String(), - CurrentVersion: currentVersion, - HasUpdate: hasUpdate, - UpgradeSupported: !isDevBuild && runtime.GOOS != "windows", - InProgress: inProgress, - UpgradeStatus: upgradeStatus, - UpgradeLogs: upgradeLogs, - } - if release != nil { - view.TagName = release.TagName - view.Body = release.Body - view.HTMLURL = release.HTMLURL - view.PublishedAt = release.PublishedAt - view.Prerelease = release.Prerelease - } - return view -} - -func prepareServerUpgrade(ctx context.Context, channel ReleaseChannel) (*preparedServerUpgrade, error) { - release, err := fetchLatestRelease(ctx, channel) - if err != nil { - return nil, err - } - - view := buildLatestServerReleaseView(release, channel) - if !view.HasUpdate { - return nil, fmt.Errorf("当前已经是最新版本") - } - if !view.UpgradeSupported { - return nil, fmt.Errorf("当前平台暂不支持自动升级") - } - - assetName := serverAssetName(runtime.GOOS, runtime.GOARCH) - recordServerUpgradeLog("info", fmt.Sprintf("Matching release asset: %s.", assetName)) - - var downloadURL string - for _, asset := range release.Assets { - if asset.Name == assetName { - downloadURL = asset.BrowserDownloadURL - break - } - } - if downloadURL == "" { - return nil, fmt.Errorf("最新版本缺少当前平台的服务端二进制: %s", assetName) - } - - execPath, err := os.Executable() - if err != nil { - return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err) - } - if err = verifyExecutableDirectoryWritable(execPath); err != nil { - return nil, err - } - recordServerUpgradeLog("info", "Verified current executable directory is writable.") - - return &preparedServerUpgrade{ - release: view, - downloadURL: downloadURL, - execPath: execPath, - }, nil -} - -func verifyExecutableDirectoryWritable(execPath string) error { - dir := filepath.Dir(execPath) - tempFile, err := os.CreateTemp(dir, "openflare-server-upgrade-check-*") - if err != nil { - return fmt.Errorf("当前服务二进制目录不可写,无法升级: %v", err) - } - tempPath := tempFile.Name() - if closeErr := tempFile.Close(); closeErr != nil { - _ = os.Remove(tempPath) - return fmt.Errorf("校验服务升级目录失败: %v", closeErr) - } - if err = os.Remove(tempPath); err != nil { - return fmt.Errorf("清理升级校验文件失败: %v", err) - } - return nil -} - -func executeServerUpgrade(task *preparedServerUpgrade) error { - recordServerUpgradeLog("info", fmt.Sprintf("Downloading automatic upgrade package for version: %s.", strings.TrimSpace(task.release.TagName))) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, task.downloadURL, nil) - if err != nil { - return err - } - req.Header.Set("Accept", "application/octet-stream") - req.Header.Set("User-Agent", "OpenFlare-Server") - - resp, err := updateHTTPClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("下载服务端升级包失败: %s", resp.Status) - } - - recordServerUpgradeLog("info", "Download finished, validating binary version.") - candidate, err := persistDownloadedServerBinary(ctx, task.execPath, task.release.TagName, resp.Body) - if err != nil { - return err - } - return executeServerBinaryCandidateUpgrade(candidate, "auto") -} - -func executeServerBinaryCandidateUpgrade(task *manualServerBinaryCandidate, source string) error { - recordServerUpgradeLog("info", fmt.Sprintf("Validated binary version: %s -> %s.", strings.TrimSpace(task.CurrentVersion), strings.TrimSpace(task.DetectedVersion))) - recordServerUpgradeLog("info", "Replacing executable and preparing restart.") - if source == "manual" { - slog.Info("server manual self-update starting", "from", strings.TrimSpace(task.CurrentVersion), "to", strings.TrimSpace(task.DetectedVersion)) - } else { - slog.Info("server self-update starting", "from", strings.TrimSpace(task.CurrentVersion), "to", strings.TrimSpace(task.DetectedVersion)) - } - markServerUpgradeSucceeded() - return serverBinaryUpgradeExecutor(task.ExecPath, task.TempPath) -} - -func serverAssetName(goos string, goarch string) string { - name := fmt.Sprintf("openflare-server-%s-%s", goos, goarch) - if goos == "windows" { - return name + ".exe" - } - return name -} - -func normalizeReleaseChannel(channel string) ReleaseChannel { - switch strings.ToLower(strings.TrimSpace(channel)) { - case string(ReleaseChannelPreview): - return ReleaseChannelPreview - default: - return ReleaseChannelStable - } -} - -func (channel ReleaseChannel) String() string { - if channel == ReleaseChannelPreview { - return string(ReleaseChannelPreview) - } - return string(ReleaseChannelStable) -} - -func isVersionNewer(current string, latest string) bool { - return utils.CompareVersions(current, latest) < 0 -} - -func buildUploadedServerBinaryView(fileName string, currentVersion string, detectedVersion string, uploadedAt time.Time) *UploadedServerBinary { - upgradeSupported := isManualServerUpgradeSupported(currentVersion) - hasUpdate := false - comparisonMessage := "" - - switch { - case !upgradeSupported: - comparisonMessage = "当前服务版本不支持手动升级确认流程" - case normalizeVersion(currentVersion) == normalizeVersion(detectedVersion): - comparisonMessage = "上传二进制与当前服务版本一致,无需升级" - case isVersionNewer(currentVersion, detectedVersion): - hasUpdate = true - comparisonMessage = fmt.Sprintf("检测到可升级版本:%s -> %s", strings.TrimSpace(currentVersion), strings.TrimSpace(detectedVersion)) - default: - comparisonMessage = "上传二进制版本不高于当前服务版本,已拒绝升级" - } - - return &UploadedServerBinary{ - FileName: strings.TrimSpace(fileName), - DetectedVersion: strings.TrimSpace(detectedVersion), - CurrentVersion: strings.TrimSpace(currentVersion), - HasUpdate: hasUpdate, - UpgradeSupported: upgradeSupported, - ReadyToUpgrade: upgradeSupported && hasUpdate, - ComparisonMessage: comparisonMessage, - UploadedAt: uploadedAt, - } -} - -func isManualServerUpgradeSupported(currentVersion string) bool { - normalized := strings.TrimSpace(strings.TrimPrefix(currentVersion, "v")) - return normalized != "" && !strings.EqualFold(normalized, "dev") -} - -func persistUploadedServerBinary(tempDir string, fileName string, reader io.Reader) (string, error) { - suffix := filepath.Ext(strings.TrimSpace(fileName)) - if runtime.GOOS == "windows" && suffix == "" { - suffix = ".exe" - } - tempDir = strings.TrimSpace(tempDir) - if tempDir == "" { - tempDir = os.TempDir() - } - tempFile, err := os.CreateTemp(tempDir, "openflare-server-manual-upgrade-*"+suffix) - if err != nil { - return "", fmt.Errorf("创建临时升级文件失败: %v", err) - } - tempPath := tempFile.Name() - if _, err = io.Copy(tempFile, reader); err != nil { - _ = tempFile.Close() - _ = os.Remove(tempPath) - return "", fmt.Errorf("写入上传二进制失败: %v", err) - } - if err = tempFile.Close(); err != nil { - _ = os.Remove(tempPath) - return "", fmt.Errorf("关闭临时升级文件失败: %v", err) - } - if err = os.Chmod(tempPath, 0o755); err != nil && runtime.GOOS != "windows" { - _ = os.Remove(tempPath) - return "", fmt.Errorf("设置临时升级文件权限失败: %v", err) - } - return tempPath, nil -} - -func detectUploadedServerBinaryVersion(ctx context.Context, filePath string) (string, error) { - commandCtx := ctx - if commandCtx == nil { - commandCtx = context.Background() - } - cmd := exec.CommandContext(commandCtx, filePath, "--version") - output, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("检查上传二进制版本失败: %w: %s", err, strings.TrimSpace(string(output))) - } - version := strings.TrimSpace(string(output)) - if version == "" { - return "", fmt.Errorf("上传二进制未返回有效版本号") - } - for _, line := range strings.Split(version, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - return trimmed, nil - } - } - return "", fmt.Errorf("上传二进制未返回有效版本号") -} - -func persistDownloadedServerBinary(ctx context.Context, execPath string, releaseTag string, reader io.Reader) (*manualServerBinaryCandidate, error) { - fileName := serverAssetName(runtime.GOOS, runtime.GOARCH) - tempPath, err := persistUploadedServerBinary(filepath.Dir(execPath), fileName, reader) - if err != nil { - return nil, err - } - - detectedVersion, err := detectUploadedServerBinaryVersion(ctx, tempPath) - if err != nil { - _ = os.Remove(tempPath) - return nil, err - } - recordServerUpgradeLog("info", fmt.Sprintf("Detected downloaded binary version: %s.", strings.TrimSpace(detectedVersion))) - - if normalizeVersion(detectedVersion) != normalizeVersion(releaseTag) { - _ = os.Remove(tempPath) - return nil, fmt.Errorf("下载包版本校验失败:release=%s,binary=%s", strings.TrimSpace(releaseTag), strings.TrimSpace(detectedVersion)) - } - - info := buildUploadedServerBinaryView(fileName, common.Version, detectedVersion, time.Now()) - if !info.ReadyToUpgrade { - _ = os.Remove(tempPath) - return nil, errors.New(info.ComparisonMessage) - } - - return &manualServerBinaryCandidate{ - FileName: fileName, - DetectedVersion: detectedVersion, - CurrentVersion: strings.TrimSpace(common.Version), - TempPath: tempPath, - ExecPath: execPath, - UploadedAt: time.Now(), - }, nil -} - -func cleanupManualServerBinaryCandidateLocked() { - if manualServerBinaryState.candidate == nil { - return - } - _ = os.Remove(manualServerBinaryState.candidate.TempPath) - manualServerBinaryState.candidate = nil -} - -func newUpgradeToken() (string, error) { - buffer := make([]byte, 16) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return hex.EncodeToString(buffer), nil -} - -func normalizeVersion(version string) string { - return strings.TrimSpace(strings.TrimPrefix(version, "v")) -} - -func snapshotServerUpgradeState() (bool, string, []ServerUpgradeLogRecord) { - serverUpgradeState.Lock() - defer serverUpgradeState.Unlock() - - status := strings.TrimSpace(serverUpgradeState.status) - if status == "" { - status = "idle" - } - logs := make([]ServerUpgradeLogRecord, len(serverUpgradeState.logs)) - copy(logs, serverUpgradeState.logs) - return serverUpgradeState.inProgress, status, logs -} - -func snapshotServerUpgradeStream() ServerUpgradeStreamSnapshot { - inProgress, status, logs := snapshotServerUpgradeState() - return ServerUpgradeStreamSnapshot{ - InProgress: inProgress, - UpgradeStatus: status, - UpgradeLogs: logs, - } -} - -func resetServerUpgradeLogsLocked() { - serverUpgradeState.logs = nil -} - -func appendServerUpgradeLogLocked(level string, message string) { - serverUpgradeState.logs = append(serverUpgradeState.logs, ServerUpgradeLogRecord{ - Level: strings.TrimSpace(level), - Message: strings.TrimSpace(message), - CreatedAt: time.Now(), - }) - if len(serverUpgradeState.logs) > 100 { - serverUpgradeState.logs = append([]ServerUpgradeLogRecord(nil), serverUpgradeState.logs[len(serverUpgradeState.logs)-100:]...) - } -} - -func recordServerUpgradeLog(level string, message string) { - serverUpgradeState.Lock() - appendServerUpgradeLogLocked(level, message) - serverUpgradeState.Unlock() - broadcastServerUpgradeSnapshot() -} - -func markServerUpgradeSucceeded() { - serverUpgradeState.Lock() - serverUpgradeState.inProgress = false - serverUpgradeState.status = "succeeded" - appendServerUpgradeLogLocked("info", "Upgrade binary is ready; server restart will begin.") - serverUpgradeState.Unlock() - broadcastServerUpgradeSnapshot() -} - -func recordServerUpgradeFailure(err error) { - serverUpgradeState.Lock() - serverUpgradeState.inProgress = false - serverUpgradeState.status = "failed" - if err != nil { - appendServerUpgradeLogLocked("error", err.Error()) - } - serverUpgradeState.Unlock() - broadcastServerUpgradeSnapshot() -} - -func SubscribeServerUpgradeStream() (<-chan ServerUpgradeStreamSnapshot, func()) { - serverUpgradeSubscribers.Lock() - if serverUpgradeSubscribers.listeners == nil { - serverUpgradeSubscribers.listeners = make(map[int]chan ServerUpgradeStreamSnapshot) - } - serverUpgradeSubscribers.nextID++ - listenerID := serverUpgradeSubscribers.nextID - listener := make(chan ServerUpgradeStreamSnapshot, 8) - serverUpgradeSubscribers.listeners[listenerID] = listener - serverUpgradeSubscribers.Unlock() - - listener <- snapshotServerUpgradeStream() - - unsubscribe := func() { - serverUpgradeSubscribers.Lock() - ch, ok := serverUpgradeSubscribers.listeners[listenerID] - if ok { - delete(serverUpgradeSubscribers.listeners, listenerID) - } - serverUpgradeSubscribers.Unlock() - if ok { - close(ch) - } - } - - return listener, unsubscribe -} - -func broadcastServerUpgradeSnapshot() { - snapshot := snapshotServerUpgradeStream() - - serverUpgradeSubscribers.Lock() - if len(serverUpgradeSubscribers.listeners) == 0 { - serverUpgradeSubscribers.Unlock() - return - } - listeners := make([]chan ServerUpgradeStreamSnapshot, 0, len(serverUpgradeSubscribers.listeners)) - for _, listener := range serverUpgradeSubscribers.listeners { - listeners = append(listeners, listener) - } - serverUpgradeSubscribers.Unlock() - - for _, listener := range listeners { - select { - case listener <- snapshot: - default: - select { - case <-listener: - default: - } - select { - case listener <- snapshot: - default: - } - } - } -} - -func UpdateHTTPClientForTest() *http.Client { - return updateHTTPClient -} - -func SetUpdateHTTPClientForTest(client *http.Client) { - updateHTTPClient = client -} - -func ServerBinaryUpgradeExecutorForTest() func(string, string) error { - return serverBinaryUpgradeExecutor -} - -func SetServerBinaryUpgradeExecutorForTest(executor func(string, string) error) { - if executor == nil { - serverBinaryUpgradeExecutor = replaceAndRestartServer - return - } - serverBinaryUpgradeExecutor = executor -} - -func ServerUpgradeDispatchDelayForTest() time.Duration { - return serverUpgradeDispatchDelay -} - -func SetServerUpgradeDispatchDelayForTest(delay time.Duration) { - if delay < 0 { - delay = 0 - } - serverUpgradeDispatchDelay = delay -} diff --git a/openflare-server/internal/service/update_restart_unix.go b/openflare-server/internal/service/update_restart_unix.go deleted file mode 100644 index 20dd4a21..00000000 --- a/openflare-server/internal/service/update_restart_unix.go +++ /dev/null @@ -1,73 +0,0 @@ -//go:build !windows - -package service - -import ( - "fmt" - "io" - "os" - "syscall" -) - -var unixRename = os.Rename - -func replaceAndRestartServer(execPath string, tmpPath string) error { - backupPath := execPath + ".bak" - _ = os.Remove(backupPath) - if err := unixRename(execPath, backupPath); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("备份当前服务端二进制失败: %w", err) - } - if err := replaceFileUnix(tmpPath, execPath); err != nil { - _ = unixRename(backupPath, execPath) - return fmt.Errorf("替换服务端二进制失败: %w", err) - } - _ = os.Remove(backupPath) - if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil { - return fmt.Errorf("重启服务失败: %w", err) - } - return fmt.Errorf("unreachable after exec") -} - -func replaceFileUnix(srcPath string, dstPath string) error { - if err := unixRename(srcPath, dstPath); err == nil { - return nil - } else if linkErr, ok := err.(*os.LinkError); !ok || linkErr.Err != syscall.EXDEV { - return err - } - - sourceFile, err := os.Open(srcPath) - if err != nil { - return err - } - defer sourceFile.Close() - - info, err := sourceFile.Stat() - if err != nil { - return err - } - - destinationFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) - if err != nil { - return err - } - - copyErr := func() error { - defer destinationFile.Close() - if _, err = io.Copy(destinationFile, sourceFile); err != nil { - return err - } - if err = destinationFile.Sync(); err != nil { - return err - } - return nil - }() - if copyErr != nil { - return copyErr - } - - if err = os.Chmod(dstPath, info.Mode().Perm()); err != nil { - return err - } - return os.Remove(srcPath) -} diff --git a/openflare-server/internal/service/update_restart_unix_test.go b/openflare-server/internal/service/update_restart_unix_test.go deleted file mode 100644 index b71cc268..00000000 --- a/openflare-server/internal/service/update_restart_unix_test.go +++ /dev/null @@ -1,50 +0,0 @@ -//go:build !windows - -package service - -import ( - "errors" - "os" - "path/filepath" - "syscall" - "testing" -) - -func TestReplaceFileUnixFallsBackOnCrossDeviceRename(t *testing.T) { - tempDir := t.TempDir() - srcPath := filepath.Join(tempDir, "source.bin") - dstPath := filepath.Join(tempDir, "target.bin") - - if err := os.WriteFile(srcPath, []byte("new-binary"), 0o755); err != nil { - t.Fatalf("failed to write source file: %v", err) - } - if err := os.WriteFile(dstPath, []byte("old-binary"), 0o755); err != nil { - t.Fatalf("failed to write target file: %v", err) - } - - originalRename := unixRename - unixRename = func(oldPath string, newPath string) error { - if oldPath == srcPath && newPath == dstPath { - return &os.LinkError{Op: "rename", Old: oldPath, New: newPath, Err: syscall.EXDEV} - } - return os.Rename(oldPath, newPath) - } - t.Cleanup(func() { - unixRename = originalRename - }) - - if err := replaceFileUnix(srcPath, dstPath); err != nil { - t.Fatalf("expected cross-device fallback to succeed: %v", err) - } - - content, err := os.ReadFile(dstPath) - if err != nil { - t.Fatalf("failed to read target file: %v", err) - } - if string(content) != "new-binary" { - t.Fatalf("unexpected target content: %s", string(content)) - } - if _, err = os.Stat(srcPath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("expected source file to be removed, got err=%v", err) - } -} diff --git a/openflare-server/internal/service/update_restart_windows.go b/openflare-server/internal/service/update_restart_windows.go deleted file mode 100644 index 34756c4d..00000000 --- a/openflare-server/internal/service/update_restart_windows.go +++ /dev/null @@ -1,55 +0,0 @@ -//go:build windows - -package service - -import ( - "fmt" - "os" - "os/exec" - "strings" -) - -func replaceAndRestartServer(execPath string, tmpPath string) error { - backupPath := execPath + ".bak" - scriptPath := execPath + ".update.cmd" - script := fmt.Sprintf(`@echo off -setlocal -:waitloop -move /Y "%s" "%s" >nul 2>nul -if errorlevel 1 ( - ping 127.0.0.1 -n 2 >nul - goto waitloop -) -move /Y "%s" "%s" >nul 2>nul -if errorlevel 1 exit /b 1 -start "" %s -del /Q "%s" >nul 2>nul -del /Q "%%~f0" >nul 2>nul -`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath) - if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("写入升级重启脚本失败: %w", err) - } - - cmd := exec.Command("cmd", "/C", "start", "", scriptPath) - if err := cmd.Start(); err != nil { - _ = os.Remove(scriptPath) - _ = os.Remove(tmpPath) - return fmt.Errorf("调度升级重启失败: %w", err) - } - - os.Exit(0) - return nil -} - -func buildWindowsCommandLine(execPath string, args []string) string { - parts := []string{quoteWindowsArg(execPath)} - for _, arg := range args { - parts = append(parts, quoteWindowsArg(arg)) - } - return strings.Join(parts, " ") -} - -func quoteWindowsArg(value string) string { - return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` -} diff --git a/openflare-server/internal/service/update_test.go b/openflare-server/internal/service/update_test.go deleted file mode 100644 index 76c321aa..00000000 --- a/openflare-server/internal/service/update_test.go +++ /dev/null @@ -1,415 +0,0 @@ -package service - -import ( - "bytes" - "context" - "io" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" -) - -type serverUpdateRoundTripFunc func(req *http.Request) (*http.Response, error) - -func (f serverUpdateRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func resetServerUpgradeTestState(t *testing.T) { - t.Helper() - serverUpgradeState.Lock() - serverUpgradeState.inProgress = false - serverUpgradeState.status = "" - serverUpgradeState.logs = nil - serverUpgradeState.Unlock() - manualServerBinaryState.Lock() - cleanupManualServerBinaryCandidateLocked() - manualServerBinaryState.Unlock() -} - -func fakeServerBinaryFixture(version string) (string, []byte) { - if runtime.GOOS == "windows" { - return "openflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n") - } - return "openflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n") -} - -func TestIsVersionNewer(t *testing.T) { - testCases := []struct { - name string - current string - latest string - expected bool - }{ - {name: "newer patch", current: "v1.2.3", latest: "v1.2.4", expected: true}, - {name: "same version", current: "v1.2.3", latest: "v1.2.3", expected: false}, - {name: "older remote", current: "v1.3.0", latest: "v1.2.9", expected: false}, - {name: "double digit segment", current: "v1.9.9", latest: "v1.10.0", expected: true}, - {name: "stable newer than prerelease", current: "v1.2.3-rc.1", latest: "v1.2.3", expected: true}, - {name: "prerelease not newer than same stable", current: "v1.2.3", latest: "v1.2.3-rc.1", expected: false}, - {name: "newer prerelease sequence", current: "v1.2.3-rc.1", latest: "v1.2.3-rc.2", expected: true}, - {name: "git describe newer than same tag", current: "v0.6.3", latest: "v0.6.3-2-gf4d36be", expected: true}, - {name: "git describe distance compares numerically", current: "v0.6.3-2-gf4d36be", latest: "v0.6.3-5-gabc1234", expected: true}, - {name: "dev build", current: "dev", latest: "v0.4.0", expected: true}, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - actual := isVersionNewer(testCase.current, testCase.latest) - if actual != testCase.expected { - t.Fatalf("unexpected compare result: current=%s latest=%s actual=%v expected=%v", testCase.current, testCase.latest, actual, testCase.expected) - } - }) - } -} - -func TestBuildLatestServerReleaseView(t *testing.T) { - originalVersion := common.Version - common.Version = "v0.4.0" - t.Cleanup(func() { - common.Version = originalVersion - serverUpgradeState.Lock() - serverUpgradeState.inProgress = false - serverUpgradeState.Unlock() - }) - - serverUpgradeState.Lock() - serverUpgradeState.inProgress = true - serverUpgradeState.Unlock() - - view := buildLatestServerReleaseView(&githubReleaseResponse{ - TagName: "v0.5.0", - Body: "release notes", - HTMLURL: "https://github.com/Rain-kl/OpenFlare/releases/tag/v0.5.0", - PublishedAt: "2026-03-11T00:00:00Z", - }, ReleaseChannelStable) - - if view.CurrentVersion != "v0.4.0" { - t.Fatalf("unexpected current version: %s", view.CurrentVersion) - } - if !view.HasUpdate { - t.Fatal("expected has_update to be true") - } - if !view.InProgress { - t.Fatal("expected in_progress to reflect upgrade state") - } - if view.TagName != "v0.5.0" { - t.Fatalf("unexpected tag name: %s", view.TagName) - } - if view.Channel != ReleaseChannelStable.String() { - t.Fatalf("unexpected channel: %s", view.Channel) - } -} - -func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) { - originalVersion := common.Version - common.Version = "dev" - t.Cleanup(func() { - common.Version = originalVersion - serverUpgradeState.Lock() - serverUpgradeState.inProgress = false - serverUpgradeState.Unlock() - }) - - view := buildLatestServerReleaseView(&githubReleaseResponse{ - TagName: "v0.5.0", - }, ReleaseChannelStable) - - if view.HasUpdate { - t.Fatal("expected dev build not to report update availability") - } - if view.UpgradeSupported { - t.Fatal("expected dev build not to support self-upgrade") - } -} - -func TestBuildLatestServerReleaseViewPreview(t *testing.T) { - originalVersion := common.Version - common.Version = "v0.5.0-rc.1" - t.Cleanup(func() { - common.Version = originalVersion - resetServerUpgradeTestState(t) - }) - - view := buildLatestServerReleaseView(&githubReleaseResponse{ - TagName: "v0.5.0-rc.2", - Prerelease: true, - PublishedAt: "2026-03-12T00:00:00Z", - }, ReleaseChannelPreview) - - if !view.HasUpdate { - t.Fatal("expected preview release to be newer") - } - if !view.Prerelease { - t.Fatal("expected preview flag to be true") - } - if view.Channel != ReleaseChannelPreview.String() { - t.Fatalf("unexpected channel: %s", view.Channel) - } -} - -// TestBuildLatestServerReleaseViewPreviewBypassVersionCheck verifies that switching to -// the preview channel always reports has_update=true, even when the preview tag uses a -// "major.minor.patch-git-" scheme that would otherwise compare as equal-or-older -// than the currently running stable version. -func TestBuildLatestServerReleaseViewPreviewBypassVersionCheck(t *testing.T) { - originalVersion := common.Version - common.Version = "v1.0.0" - t.Cleanup(func() { - common.Version = originalVersion - resetServerUpgradeTestState(t) - }) - - // A typical preview tag: same base version as stable but with a git-commit suffix. - // Without the bypass, isVersionNewer("v1.0.0", "v1.0.0-git-abc1234") returns false - // because a version without a prerelease identifier is considered higher than one - // with a prerelease identifier under semver rules. - view := buildLatestServerReleaseView(&githubReleaseResponse{ - TagName: "v1.0.0-git-abc1234", - Prerelease: true, - PublishedAt: "2026-03-12T00:00:00Z", - }, ReleaseChannelPreview) - - if !view.HasUpdate { - t.Fatal("expected preview channel to bypass version comparison and report has_update=true") - } - if view.Channel != ReleaseChannelPreview.String() { - t.Fatalf("unexpected channel: %s", view.Channel) - } -} - -func TestUploadManualServerBinary(t *testing.T) { - originalVersion := common.Version - common.Version = "v0.4.0" - t.Cleanup(func() { - common.Version = originalVersion - resetServerUpgradeTestState(t) - }) - - fileName, content := fakeServerBinaryFixture("v0.5.0") - info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content)) - if err != nil { - t.Fatalf("expected upload to succeed: %v", err) - } - if !info.ReadyToUpgrade { - t.Fatal("expected uploaded binary to be ready for upgrade") - } - if info.UploadToken == "" { - t.Fatal("expected upload token to be returned") - } - if info.DetectedVersion != "v0.5.0" { - t.Fatalf("unexpected detected version: %s", info.DetectedVersion) - } - - manualServerBinaryState.Lock() - candidate := manualServerBinaryState.candidate - manualServerBinaryState.Unlock() - if candidate == nil { - t.Fatal("expected manual upgrade candidate to be stored") - } - if _, err := os.Stat(candidate.TempPath); err != nil { - t.Fatalf("expected temporary binary to exist: %v", err) - } - if candidate.UploadToken != info.UploadToken { - t.Fatalf("unexpected stored upload token: %s", candidate.UploadToken) - } - execPath, err := os.Executable() - if err != nil { - t.Fatalf("failed to get executable path: %v", err) - } - if filepath.Dir(candidate.TempPath) != filepath.Dir(execPath) { - t.Fatalf("expected temporary binary in executable dir, got %s want %s", filepath.Dir(candidate.TempPath), filepath.Dir(execPath)) - } -} - -func TestBuildUploadedServerBinaryViewAcceptsGitDescribeNewerThanTag(t *testing.T) { - info := buildUploadedServerBinaryView("openflare-server-test", "v0.6.3", "v0.6.3-2-gf4d36be", time.Now()) - if !info.HasUpdate || !info.ReadyToUpgrade { - t.Fatalf("expected git describe binary to be upgradeable: %+v", info) - } -} - -func TestUploadManualServerBinaryRejectsSameVersion(t *testing.T) { - originalVersion := common.Version - common.Version = "v0.5.0" - t.Cleanup(func() { - common.Version = originalVersion - resetServerUpgradeTestState(t) - }) - - fileName, content := fakeServerBinaryFixture("v0.5.0") - info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content)) - if err != nil { - t.Fatalf("expected upload to succeed: %v", err) - } - if info.ReadyToUpgrade { - t.Fatal("expected same-version upload not to be upgradeable") - } - if info.UploadToken != "" { - t.Fatal("expected same-version upload not to issue a token") - } - - manualServerBinaryState.Lock() - defer manualServerBinaryState.Unlock() - if manualServerBinaryState.candidate != nil { - t.Fatal("expected no pending manual upgrade candidate") - } -} - -func TestConfirmManualServerUpgrade(t *testing.T) { - originalVersion := common.Version - originalExecutor := ServerBinaryUpgradeExecutorForTest() - originalDelay := ServerUpgradeDispatchDelayForTest() - common.Version = "v0.4.0" - called := make(chan string, 1) - SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error { - called <- tempPath - return nil - }) - SetServerUpgradeDispatchDelayForTest(0) - t.Cleanup(func() { - common.Version = originalVersion - SetServerBinaryUpgradeExecutorForTest(originalExecutor) - SetServerUpgradeDispatchDelayForTest(originalDelay) - resetServerUpgradeTestState(t) - }) - - fileName, content := fakeServerBinaryFixture("v0.5.0") - info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content)) - if err != nil { - t.Fatalf("expected upload to succeed: %v", err) - } - - confirmed, err := ConfirmManualServerUpgrade(info.UploadToken) - if err != nil { - t.Fatalf("expected confirm to succeed: %v", err) - } - if confirmed.UploadToken != info.UploadToken { - t.Fatalf("unexpected confirmed upload token: %s", confirmed.UploadToken) - } - - select { - case tempPath := <-called: - if tempPath == "" { - t.Fatal("expected upgrade executor to receive temp path") - } - case <-time.After(time.Second): - t.Fatal("expected manual upgrade executor to be called") - } -} - -func TestBuildLatestServerReleaseViewIncludesUpgradeLogs(t *testing.T) { - originalVersion := common.Version - common.Version = "v0.4.0" - t.Cleanup(func() { - common.Version = originalVersion - resetServerUpgradeTestState(t) - }) - - serverUpgradeState.Lock() - serverUpgradeState.inProgress = true - serverUpgradeState.status = "running" - serverUpgradeState.logs = []ServerUpgradeLogRecord{ - { - Level: "info", - Message: "download started", - CreatedAt: time.Now(), - }, - } - serverUpgradeState.Unlock() - - view := buildLatestServerReleaseView(&githubReleaseResponse{ - TagName: "v0.5.0", - }, ReleaseChannelStable) - - if view.UpgradeStatus != "running" { - t.Fatalf("expected upgrade status to be running, got %s", view.UpgradeStatus) - } - if len(view.UpgradeLogs) != 1 { - t.Fatalf("expected one upgrade log, got %d", len(view.UpgradeLogs)) - } - if view.UpgradeLogs[0].Message != "download started" { - t.Fatalf("unexpected upgrade log message: %s", view.UpgradeLogs[0].Message) - } -} - -func TestScheduleServerUpgradeUsesDownloadedBinaryValidation(t *testing.T) { - originalVersion := common.Version - originalClient := UpdateHTTPClientForTest() - originalExecutor := ServerBinaryUpgradeExecutorForTest() - originalDelay := ServerUpgradeDispatchDelayForTest() - common.Version = "v0.4.0" - called := make(chan string, 1) - - SetUpdateHTTPClientForTest(&http.Client{ - Transport: serverUpdateRoundTripFunc(func(req *http.Request) (*http.Response, error) { - switch req.URL.String() { - case "https://api.github.com/repos/Rain-kl/OpenFlare/releases/latest": - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{ - "tag_name":"v0.5.0", - "body":"release notes", - "html_url":"https://github.com/Rain-kl/OpenFlare/releases/tag/v0.5.0", - "published_at":"2026-03-11T00:00:00Z", - "assets":[{"name":"openflare-server-` + runtime.GOOS + `-` + runtime.GOARCH + `","browser_download_url":"https://downloads.example.com/openflare-server"}] - }`)), - }, nil - case "https://downloads.example.com/openflare-server": - _, content := fakeServerBinaryFixture("v0.5.0") - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewReader(content)), - }, nil - default: - t.Fatalf("unexpected request url: %s", req.URL.String()) - return nil, nil - } - }), - }) - SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error { - called <- tempPath - return nil - }) - SetServerUpgradeDispatchDelayForTest(0) - t.Cleanup(func() { - common.Version = originalVersion - SetUpdateHTTPClientForTest(originalClient) - SetServerBinaryUpgradeExecutorForTest(originalExecutor) - SetServerUpgradeDispatchDelayForTest(originalDelay) - resetServerUpgradeTestState(t) - }) - - release, err := ScheduleServerUpgrade("stable") - if err != nil { - t.Fatalf("expected schedule to succeed: %v", err) - } - if !release.InProgress { - t.Fatal("expected release to report in-progress upgrade") - } - - select { - case tempPath := <-called: - if tempPath == "" { - t.Fatal("expected upgrade executor to receive temp path") - } - case <-time.After(time.Second): - t.Fatal("expected automatic upgrade executor to be called") - } - - _, status, logs := snapshotServerUpgradeState() - if status != "succeeded" { - t.Fatalf("expected succeeded status after executor call, got %s", status) - } - if len(logs) == 0 { - t.Fatal("expected upgrade logs to be recorded") - } -} diff --git a/openflare-server/internal/service/uptimekuma.go b/openflare-server/internal/service/uptimekuma.go deleted file mode 100644 index b5f1fc8b..00000000 --- a/openflare-server/internal/service/uptimekuma.go +++ /dev/null @@ -1,305 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "strings" - "sync/atomic" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/utils/uptimekuma" -) - -var isSyncing atomic.Bool - -func SyncToUptimeKuma() error { - if !common.UptimeKumaEnabled { - return fmt.Errorf("Uptime Kuma integration is disabled") - } - - if !isSyncing.CompareAndSwap(false, true) { - return fmt.Errorf("sync task is already in progress, please try again later") - } - defer isSyncing.Store(false) - - kumaUrl := strings.TrimSpace(common.UptimeKumaUrl) - kumaUsername := strings.TrimSpace(common.UptimeKumaUsername) - kumaPassword := strings.TrimSpace(common.UptimeKumaPassword) - if kumaUrl == "" || kumaUsername == "" || kumaPassword == "" { - return fmt.Errorf("Uptime Kuma URL, username, or password is not configured (URL: %q, Username: %q, PasswordLength: %d)", kumaUrl, kumaUsername, len(kumaPassword)) - } - - slog.Info("Starting Uptime Kuma sync process", "url", kumaUrl, "username", kumaUsername, "scope", common.UptimeKumaMonitorScope) - - // 1. Fetch expected sites - allRoutes, err := model.ListProxyRoutes() - if err != nil { - return fmt.Errorf("failed to list local proxy routes: %w", err) - } - - var expectedRoutes []*model.ProxyRoute - scope := common.UptimeKumaMonitorScope - if scope == "selected" { - selectedList := strings.Split(common.UptimeKumaSelectedSites, ",") - selectedMap := make(map[string]bool) - for _, name := range selectedList { - trimmedName := strings.TrimSpace(name) - if trimmedName != "" { - selectedMap[trimmedName] = true - } - } - for _, route := range allRoutes { - if route.Enabled && selectedMap[route.SiteName] { - expectedRoutes = append(expectedRoutes, route) - } - } - } else { - for _, route := range allRoutes { - if route.Enabled { - expectedRoutes = append(expectedRoutes, route) - } - } - } - - // 2. Connect to Uptime Kuma - slog.Debug("Connecting to Uptime Kuma socket endpoint", "url", kumaUrl) - client := uptimekuma.NewSocketIOClient(kumaUrl) - if err := client.Connect(); err != nil { - slog.Error("Failed to connect to Uptime Kuma endpoint", "url", kumaUrl, "error", err) - return fmt.Errorf("failed to connect to Uptime Kuma: %w", err) - } - defer client.Close() - - // 3. Login - slog.Debug("Sending login request to Uptime Kuma", "username", kumaUsername) - var loginAck string - loginPayload := map[string]string{ - "username": kumaUsername, - "password": kumaPassword, - } - loginAck, err = client.Emit("login", loginPayload) - if err != nil { - slog.Error("Failed to send login request to Uptime Kuma", "username", kumaUsername, "error", err) - return fmt.Errorf("login request failed: %w", err) - } - - var loginResult struct { - Ok bool `json:"ok"` - } - if err := uptimekuma.ParseAckResponse(loginAck, &loginResult); err != nil || !loginResult.Ok { - slog.Error("Uptime Kuma login verification failed", "username", kumaUsername, "error", err) - return fmt.Errorf("login failed: %w", err) - } - slog.Debug("Successfully logged into Uptime Kuma", "username", kumaUsername) - - // 4. Wait for monitor list event - slog.Debug("Waiting for monitor list push from Uptime Kuma") - select { - case <-client.GetMonitorListChan(): - slog.Debug("Received monitor list from Uptime Kuma") - case <-time.After(5 * time.Second): - slog.Error("Timeout waiting for Uptime Kuma monitorList push event") - return fmt.Errorf("timeout waiting for monitorList event from Uptime Kuma") - } - - // 5. Get existing tags to find "OpenFlare" - slog.Debug("Fetching tags from Uptime Kuma") - tagsAck, err := client.Emit("getTags") - if err != nil { - slog.Error("Failed to request tags from Uptime Kuma", "error", err) - return fmt.Errorf("failed to fetch tags: %w", err) - } - - var tagsResult struct { - Ok bool `json:"ok"` - Tags []uptimekuma.UptimeKumaTagItem `json:"tags"` - } - if err := uptimekuma.ParseAckResponse(tagsAck, &tagsResult); err != nil { - slog.Error("Failed to parse tags response from Uptime Kuma", "error", err) - return fmt.Errorf("parse tags response failed: %w", err) - } - - var openFlareTagID int - for _, t := range tagsResult.Tags { - if t.Name == "OpenFlare" { - openFlareTagID = t.ID - break - } - } - - // Create "OpenFlare" tag if not exists - if openFlareTagID == 0 { - slog.Debug("OpenFlare tag not found, creating new tag") - addTagAck, err := client.Emit("addTag", map[string]string{ - "name": "OpenFlare", - "color": "#4f46e5", - }) - if err != nil { - slog.Error("Failed to create OpenFlare tag in Uptime Kuma", "error", err) - return fmt.Errorf("failed to create tag: %w", err) - } - var tagResult struct { - Ok bool `json:"ok"` - Tag struct { - ID int `json:"id"` - } `json:"tag"` - } - if err := uptimekuma.ParseAckResponse(addTagAck, &tagResult); err != nil || tagResult.Tag.ID == 0 { - slog.Error("Failed to parse addTag response from Uptime Kuma", "error", err) - return fmt.Errorf("parse addTag response failed: %w", err) - } - openFlareTagID = tagResult.Tag.ID - slog.Debug("Successfully created OpenFlare tag", "tag_id", openFlareTagID) - } else { - slog.Debug("Found existing OpenFlare tag", "tag_id", openFlareTagID) - } - - // 6. Filter existing monitors by "OpenFlare" tag - existingOpenFlareMonitors := make(map[string]uptimekuma.UptimeKumaMonitor) - monitors := client.GetMonitorList() - for _, m := range monitors { - hasOpenFlareTag := false - for _, tag := range m.Tags { - if tag.Name == "OpenFlare" || tag.ID == openFlareTagID { - hasOpenFlareTag = true - break - } - } - if hasOpenFlareTag { - existingOpenFlareMonitors[m.Name] = m - } - } - - // Helper to format route URL - getRouteURL := func(route *model.ProxyRoute) string { - domains, err := decodeStoredDomains(route.Domains, route.Domain) - domain := route.Domain - if err == nil && len(domains) > 0 { - domain = domains[0] - } - if route.EnableHTTPS { - return "https://" + domain - } - return "http://" + domain - } - - expectedSitesMap := make(map[string]bool) - - // 7. Sync Loop - for _, route := range expectedRoutes { - expectedSitesMap[route.SiteName] = true - targetURL := getRouteURL(route) - - existing, exists := existingOpenFlareMonitors[route.SiteName] - if !exists { - // Create monitor - slog.Info("Creating monitor in Uptime Kuma", "name", route.SiteName, "url", targetURL) - monitorPayload := map[string]any{ - "type": "http", - "name": route.SiteName, - "url": targetURL, - "interval": common.UptimeKumaInterval, - "maxretries": common.UptimeKumaRetry, - "retryInterval": common.UptimeKumaRetryInterval, - "timeout": common.UptimeKumaTimeout, - "active": true, - "resendInterval": 0, - "expiryNotification": false, - "ignoreTls": false, - "accepted_statuscodes": []string{"200-299"}, - "dns_resolve_type": "A", - "conditions": []any{}, - } - addAck, err := client.Emit("add", monitorPayload) - if err != nil { - slog.Error("Failed to add monitor to Uptime Kuma", "name", route.SiteName, "error", err) - continue - } - var addResult struct { - Ok bool `json:"ok"` - MonitorID int `json:"monitorID"` - } - if err := uptimekuma.ParseAckResponse(addAck, &addResult); err != nil || addResult.MonitorID == 0 { - slog.Error("Failed to parse add monitor result", "name", route.SiteName, "error", err) - continue - } - - // Add tag - slog.Debug("Adding OpenFlare tag to the new monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID, "tag_id", openFlareTagID) - tagAck, err := client.Emit("addMonitorTag", openFlareTagID, addResult.MonitorID, "") - if err != nil { - slog.Error("Failed to add tag to monitor in Uptime Kuma", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err) - } else { - if err := uptimekuma.ParseAckResponse(tagAck, nil); err != nil { - slog.Error("Failed to parse add tag result", "name", route.SiteName, "monitorID", addResult.MonitorID, "error", err) - } else { - slog.Debug("OpenFlare tag successfully added to monitor", "name", route.SiteName, "monitor_id", addResult.MonitorID) - } - } - } else { - // Check if updates are needed - needsUpdate := existing.Url != targetURL || - existing.Interval != common.UptimeKumaInterval || - existing.MaxRetries != common.UptimeKumaRetry || - existing.RetryInterval != common.UptimeKumaRetryInterval || - existing.Timeout != common.UptimeKumaTimeout - - if needsUpdate { - slog.Info("Updating monitor in Uptime Kuma due to settings mismatch", - "name", route.SiteName, - "url_changed", existing.Url != targetURL, - "interval_changed", existing.Interval != common.UptimeKumaInterval, - "max_retries_changed", existing.MaxRetries != common.UptimeKumaRetry, - "retry_interval_changed", existing.RetryInterval != common.UptimeKumaRetryInterval, - "timeout_changed", existing.Timeout != common.UptimeKumaTimeout, - ) - monitorPayload := map[string]any{ - "id": existing.ID, - "type": "http", - "name": route.SiteName, - "url": targetURL, - "interval": common.UptimeKumaInterval, - "maxretries": common.UptimeKumaRetry, - "retryInterval": common.UptimeKumaRetryInterval, - "timeout": common.UptimeKumaTimeout, - "active": true, - "resendInterval": 0, - "expiryNotification": false, - "ignoreTls": false, - "accepted_statuscodes": []string{"200-299"}, - "dns_resolve_type": "A", - "conditions": []any{}, - } - editAck, err := client.Emit("editMonitor", monitorPayload) - if err != nil { - slog.Error("Failed to edit monitor in Uptime Kuma", "name", route.SiteName, "error", err) - } else { - if err := uptimekuma.ParseAckResponse(editAck, nil); err != nil { - slog.Error("Failed to parse edit monitor result", "name", route.SiteName, "error", err) - } else { - slog.Info("Successfully updated monitor in Uptime Kuma", "name", route.SiteName) - } - } - } - } - } - - // 8. Delete Loop - for name, m := range existingOpenFlareMonitors { - if !expectedSitesMap[name] { - slog.Info("Deleting monitor in Uptime Kuma", "name", name, "monitorID", m.ID) - deleteAck, err := client.Emit("deleteMonitor", m.ID) - if err != nil { - slog.Error("Failed to delete monitor in Uptime Kuma", "name", name, "monitorID", m.ID, "error", err) - } else { - if err := uptimekuma.ParseAckResponse(deleteAck, nil); err != nil { - slog.Error("Failed to parse delete monitor result", "name", name, "monitorID", m.ID, "error", err) - } - } - } - } - - return nil -} diff --git a/openflare-server/internal/service/waf.go b/openflare-server/internal/service/waf.go deleted file mode 100644 index 79af1e99..00000000 --- a/openflare-server/internal/service/waf.go +++ /dev/null @@ -1,541 +0,0 @@ -package service - -import ( - "encoding/json" - "errors" - "fmt" - "net/netip" - "sort" - "strings" - "time" - "unicode" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/pkg/utils" - - "gorm.io/gorm" -) - -const ( - defaultWAFBlockStatusCode = 418 - maxWAFBlockBodyBytes = 16 * 1024 -) - -type WAFRuleGroupInput struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - BlockStatusCode int `json:"block_status_code"` - BlockResponseBody string `json:"block_response_body"` - IPWhitelist []string `json:"ip_whitelist"` - IPBlacklist []string `json:"ip_blacklist"` - IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"` - IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"` - CountryWhitelist []string `json:"country_whitelist"` - CountryBlacklist []string `json:"country_blacklist"` - RegionWhitelist []string `json:"region_whitelist"` - RegionBlacklist []string `json:"region_blacklist"` - Remark string `json:"remark"` - PoWEnabled bool `json:"pow_enabled"` - PoWConfig json.RawMessage `json:"pow_config"` -} - -type WAFRuleGroupView struct { - ID uint `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - IsGlobal bool `json:"is_global"` - BlockStatusCode int `json:"block_status_code"` - BlockResponseBody string `json:"block_response_body"` - IPWhitelist []string `json:"ip_whitelist"` - IPBlacklist []string `json:"ip_blacklist"` - IPWhitelistGroups []uint `json:"ip_whitelist_group_ids"` - IPBlacklistGroups []uint `json:"ip_blacklist_group_ids"` - CountryWhitelist []string `json:"country_whitelist"` - CountryBlacklist []string `json:"country_blacklist"` - RegionWhitelist []string `json:"region_whitelist"` - RegionBlacklist []string `json:"region_blacklist"` - Remark string `json:"remark"` - PoWEnabled bool `json:"pow_enabled"` - PoWConfig *ProxyRoutePoWConfig `json:"pow_config"` - AppliedSiteIDs []uint `json:"applied_site_ids"` - AppliedSiteCount int `json:"applied_site_count"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type WAFSiteRuleGroupsView struct { - RouteID uint `json:"route_id"` - GlobalRuleGroup *WAFRuleGroupView `json:"global_rule_group"` - RuleGroups []WAFRuleGroupView `json:"rule_groups"` - AppliedRuleGroups []WAFRuleGroupView `json:"applied_rule_groups"` - AppliedIDs []uint `json:"applied_ids"` -} - -func ListWAFRuleGroups() ([]WAFRuleGroupView, error) { - if err := EnsureDefaultWAFRuleGroup(); err != nil { - return nil, err - } - groups, err := model.ListWAFRuleGroups() - if err != nil { - return nil, err - } - bindings, err := loadWAFBindings() - if err != nil { - return nil, err - } - views := make([]WAFRuleGroupView, 0, len(groups)) - for _, group := range groups { - view, err := buildWAFRuleGroupView(group, bindings[group.ID]) - if err != nil { - return nil, err - } - views = append(views, view) - } - return views, nil -} - -func GetWAFRuleGroup(id uint) (*WAFRuleGroupView, error) { - group, err := model.GetWAFRuleGroupByID(id) - if err != nil { - return nil, err - } - bindings, err := loadWAFBindings() - if err != nil { - return nil, err - } - view, err := buildWAFRuleGroupView(group, bindings[group.ID]) - if err != nil { - return nil, err - } - return &view, nil -} - -func CreateWAFRuleGroup(input WAFRuleGroupInput) (*WAFRuleGroupView, error) { - group, err := buildWAFRuleGroup(nil, input) - if err != nil { - return nil, err - } - group.IsGlobal = false - if err := group.Insert(); err != nil { - return nil, err - } - return GetWAFRuleGroup(group.ID) -} - -func UpdateWAFRuleGroup(id uint, input WAFRuleGroupInput) (*WAFRuleGroupView, error) { - group, err := model.GetWAFRuleGroupByID(id) - if err != nil { - return nil, err - } - isGlobal := group.IsGlobal - group, err = buildWAFRuleGroup(group, input) - if err != nil { - return nil, err - } - group.IsGlobal = isGlobal - if isGlobal && strings.TrimSpace(group.Name) == "" { - group.Name = "全局规则组" - } - if err := group.Update(); err != nil { - return nil, err - } - return GetWAFRuleGroup(group.ID) -} - -func DeleteWAFRuleGroup(id uint) error { - group, err := model.GetWAFRuleGroupByID(id) - if err != nil { - return err - } - if group.IsGlobal { - return errors.New("全局 WAF 规则组不能删除") - } - return model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("rule_group_id = ?", group.ID).Delete(&model.WAFRuleGroupBinding{}).Error; err != nil { - return err - } - return tx.Delete(group).Error - }) -} - -func ReplaceWAFRuleGroupSites(groupID uint, routeIDs []uint) (*WAFRuleGroupView, error) { - group, err := model.GetWAFRuleGroupByID(groupID) - if err != nil { - return nil, err - } - if group.IsGlobal { - return nil, errors.New("全局 WAF 规则组默认应用到所有网站,不能手动绑定") - } - normalized, err := normalizeWAFRouteIDs(routeIDs) - if err != nil { - return nil, err - } - err = model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("rule_group_id = ?", groupID).Delete(&model.WAFRuleGroupBinding{}).Error; err != nil { - return err - } - for _, routeID := range normalized { - binding := model.WAFRuleGroupBinding{RuleGroupID: groupID, ProxyRouteID: routeID} - if err := tx.Create(&binding).Error; err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, err - } - return GetWAFRuleGroup(groupID) -} - -func GetWAFSiteRuleGroups(routeID uint) (*WAFSiteRuleGroupsView, error) { - if _, err := model.GetProxyRouteByID(routeID); err != nil { - return nil, err - } - groups, err := ListWAFRuleGroups() - if err != nil { - return nil, err - } - appliedIDs, err := ListWAFSiteRuleGroupIDs(routeID) - if err != nil { - return nil, err - } - appliedSet := make(map[uint]struct{}, len(appliedIDs)) - for _, id := range appliedIDs { - appliedSet[id] = struct{}{} - } - var global *WAFRuleGroupView - custom := make([]WAFRuleGroupView, 0, len(groups)) - applied := make([]WAFRuleGroupView, 0, len(appliedIDs)) - for index := range groups { - group := groups[index] - if group.IsGlobal { - item := group - global = &item - continue - } - custom = append(custom, group) - if _, ok := appliedSet[group.ID]; ok { - applied = append(applied, group) - } - } - return &WAFSiteRuleGroupsView{ - RouteID: routeID, - GlobalRuleGroup: global, - RuleGroups: custom, - AppliedRuleGroups: applied, - AppliedIDs: appliedIDs, - }, nil -} - -func ReplaceWAFSiteRuleGroups(routeID uint, groupIDs []uint) (*WAFSiteRuleGroupsView, error) { - if _, err := model.GetProxyRouteByID(routeID); err != nil { - return nil, err - } - normalized, err := normalizeWAFRuleGroupIDs(groupIDs) - if err != nil { - return nil, err - } - err = model.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("proxy_route_id = ?", routeID).Delete(&model.WAFRuleGroupBinding{}).Error; err != nil { - return err - } - for _, groupID := range normalized { - binding := model.WAFRuleGroupBinding{RuleGroupID: groupID, ProxyRouteID: routeID} - if err := tx.Create(&binding).Error; err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, err - } - return GetWAFSiteRuleGroups(routeID) -} - -func ListWAFSiteRuleGroupIDs(routeID uint) ([]uint, error) { - var bindings []model.WAFRuleGroupBinding - if err := model.DB.Where("proxy_route_id = ?", routeID).Order("rule_group_id asc").Find(&bindings).Error; err != nil { - return nil, err - } - ids := make([]uint, 0, len(bindings)) - for _, binding := range bindings { - ids = append(ids, binding.RuleGroupID) - } - return ids, nil -} - -func EnsureDefaultWAFRuleGroup() error { - _, err := model.GetGlobalWAFRuleGroup() - if err == nil { - return nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - group := &model.WAFRuleGroup{ - Name: "全局规则组", - Enabled: true, - IsGlobal: true, - BlockStatusCode: defaultWAFBlockStatusCode, - IPWhitelist: "[]", - IPBlacklist: "[]", - IPWhitelistGroups: "[]", - IPBlacklistGroups: "[]", - CountryWhitelist: "[]", - CountryBlacklist: "[]", - RegionWhitelist: "[]", - RegionBlacklist: "[]", - PoWEnabled: false, - PoWConfig: "{}", - BlockResponseBody: "", - } - return group.Insert() -} - -func buildWAFRuleGroup(group *model.WAFRuleGroup, input WAFRuleGroupInput) (*model.WAFRuleGroup, error) { - name := strings.TrimSpace(input.Name) - if name == "" { - return nil, errors.New("规则组名称不能为空") - } - statusCode := input.BlockStatusCode - if statusCode == 0 { - statusCode = defaultWAFBlockStatusCode - } - if statusCode < 400 || statusCode > 599 { - return nil, errors.New("拦截状态码必须在 400-599 之间") - } - if len([]byte(input.BlockResponseBody)) > maxWAFBlockBodyBytes { - return nil, fmt.Errorf("拦截页面内容不能超过 %d 字节", maxWAFBlockBodyBytes) - } - ipWhitelist, err := normalizeWAFIPList(input.IPWhitelist) - if err != nil { - return nil, fmt.Errorf("IP 白名单无效: %w", err) - } - ipBlacklist, err := normalizeWAFIPList(input.IPBlacklist) - if err != nil { - return nil, fmt.Errorf("IP 黑名单无效: %w", err) - } - ipWhitelistGroups, err := normalizeWAFIPGroupIDs(input.IPWhitelistGroups) - if err != nil { - return nil, fmt.Errorf("IP 白名单引用无效: %w", err) - } - ipBlacklistGroups, err := normalizeWAFIPGroupIDs(input.IPBlacklistGroups) - if err != nil { - return nil, fmt.Errorf("IP 黑名单引用无效: %w", err) - } - countryWhitelist, err := normalizeWAFCountryList(input.CountryWhitelist) - if err != nil { - return nil, fmt.Errorf("地域白名单无效: %w", err) - } - countryBlacklist, err := normalizeWAFCountryList(input.CountryBlacklist) - if err != nil { - return nil, fmt.Errorf("地域黑名单无效: %w", err) - } - regionWhitelist := normalizeStringList(input.RegionWhitelist) - regionBlacklist := normalizeStringList(input.RegionBlacklist) - powConfigRaw := strings.TrimSpace(string(input.PoWConfig)) - if powConfigRaw == "" { - powConfigRaw = "{}" - } - powConfig, err := normalizePoWConfig(input.PoWEnabled, powConfigRaw) - if err != nil { - return nil, err - } - powConfigJSON, _ := json.Marshal(powConfig) - - ipWhitelistJSON, _ := json.Marshal(ipWhitelist) - ipBlacklistJSON, _ := json.Marshal(ipBlacklist) - ipWhitelistGroupsJSON, _ := json.Marshal(ipWhitelistGroups) - ipBlacklistGroupsJSON, _ := json.Marshal(ipBlacklistGroups) - countryWhitelistJSON, _ := json.Marshal(countryWhitelist) - countryBlacklistJSON, _ := json.Marshal(countryBlacklist) - regionWhitelistJSON, _ := json.Marshal(regionWhitelist) - regionBlacklistJSON, _ := json.Marshal(regionBlacklist) - - if group == nil { - group = &model.WAFRuleGroup{} - } - group.Name = name - group.Enabled = input.Enabled - group.BlockStatusCode = statusCode - group.BlockResponseBody = input.BlockResponseBody - group.IPWhitelist = string(ipWhitelistJSON) - group.IPBlacklist = string(ipBlacklistJSON) - group.IPWhitelistGroups = string(ipWhitelistGroupsJSON) - group.IPBlacklistGroups = string(ipBlacklistGroupsJSON) - group.CountryWhitelist = string(countryWhitelistJSON) - group.CountryBlacklist = string(countryBlacklistJSON) - group.RegionWhitelist = string(regionWhitelistJSON) - group.RegionBlacklist = string(regionBlacklistJSON) - group.PoWEnabled = input.PoWEnabled - group.PoWConfig = string(powConfigJSON) - group.Remark = strings.TrimSpace(input.Remark) - return group, nil -} - -func buildWAFRuleGroupView(group *model.WAFRuleGroup, appliedSiteIDs []uint) (WAFRuleGroupView, error) { - if group == nil { - return WAFRuleGroupView{}, errors.New("waf rule group is nil") - } - sort.Slice(appliedSiteIDs, func(i, j int) bool { return appliedSiteIDs[i] < appliedSiteIDs[j] }) - view := WAFRuleGroupView{ - ID: group.ID, - Name: group.Name, - Enabled: group.Enabled, - IsGlobal: group.IsGlobal, - BlockStatusCode: group.BlockStatusCode, - BlockResponseBody: group.BlockResponseBody, - Remark: group.Remark, - PoWEnabled: group.PoWEnabled, - AppliedSiteIDs: appliedSiteIDs, - AppliedSiteCount: len(appliedSiteIDs), - CreatedAt: group.CreatedAt.Format(time.RFC3339), - UpdatedAt: group.UpdatedAt.Format(time.RFC3339), - } - var err error - if view.IPWhitelist, err = decodeStringList(group.IPWhitelist); err != nil { - return view, err - } - if view.IPBlacklist, err = decodeStringList(group.IPBlacklist); err != nil { - return view, err - } - view.IPWhitelistGroups = mustDecodeUintList(group.IPWhitelistGroups) - view.IPBlacklistGroups = mustDecodeUintList(group.IPBlacklistGroups) - if view.CountryWhitelist, err = decodeStringList(group.CountryWhitelist); err != nil { - return view, err - } - if view.CountryBlacklist, err = decodeStringList(group.CountryBlacklist); err != nil { - return view, err - } - if view.RegionWhitelist, err = decodeStringList(group.RegionWhitelist); err != nil { - return view, err - } - if view.RegionBlacklist, err = decodeStringList(group.RegionBlacklist); err != nil { - return view, err - } - if view.PoWConfig, err = decodeStoredPoWConfig(group.PoWEnabled, group.PoWConfig); err != nil { - return view, err - } - return view, nil -} - -func loadWAFBindings() (map[uint][]uint, error) { - var bindings []model.WAFRuleGroupBinding - if err := model.DB.Order("rule_group_id asc").Order("proxy_route_id asc").Find(&bindings).Error; err != nil { - return nil, err - } - result := make(map[uint][]uint, len(bindings)) - for _, binding := range bindings { - result[binding.RuleGroupID] = append(result[binding.RuleGroupID], binding.ProxyRouteID) - } - return result, nil -} - -func normalizeWAFIPList(items []string) ([]string, error) { - normalized := make([]string, 0, len(items)) - for _, raw := range items { - item := strings.TrimSpace(raw) - if item == "" { - continue - } - if strings.Contains(item, "/") { - prefix, err := netip.ParsePrefix(item) - if err != nil { - return nil, fmt.Errorf("%s 不是合法 IP 段", item) - } - item = prefix.Masked().String() - } else { - addr, err := netip.ParseAddr(item) - if err != nil { - return nil, fmt.Errorf("%s 不是合法 IP", item) - } - item = addr.String() - } - normalized = append(normalized, item) - } - normalized = utils.Unique(normalized) - sort.Strings(normalized) - return normalized, nil -} - -func normalizeWAFCountryList(items []string) ([]string, error) { - normalized := make([]string, 0, len(items)) - for _, raw := range items { - item := strings.ToUpper(strings.TrimSpace(raw)) - if item == "" { - continue - } - if len(item) != 2 || !unicode.IsLetter(rune(item[0])) || !unicode.IsLetter(rune(item[1])) { - return nil, fmt.Errorf("%s 不是合法国家代码", item) - } - normalized = append(normalized, item) - } - normalized = utils.Unique(normalized) - sort.Strings(normalized) - return normalized, nil -} - -func normalizeStringList(items []string) []string { - normalized := make([]string, 0, len(items)) - for _, raw := range items { - item := strings.TrimSpace(raw) - if item == "" { - continue - } - normalized = append(normalized, item) - } - normalized = utils.Unique(normalized) - sort.Strings(normalized) - return normalized -} - -func decodeStringList(raw string) ([]string, error) { - text := strings.TrimSpace(raw) - if text == "" { - return []string{}, nil - } - var items []string - if err := json.Unmarshal([]byte(text), &items); err != nil { - return nil, err - } - return items, nil -} - -func normalizeWAFRouteIDs(routeIDs []uint) ([]uint, error) { - normalized := uniqueUintIDs(routeIDs) - for _, routeID := range normalized { - if _, err := model.GetProxyRouteByID(routeID); err != nil { - return nil, fmt.Errorf("网站 %d 不存在", routeID) - } - } - return normalized, nil -} - -func normalizeWAFRuleGroupIDs(groupIDs []uint) ([]uint, error) { - normalized := uniqueUintIDs(groupIDs) - for _, groupID := range normalized { - group, err := model.GetWAFRuleGroupByID(groupID) - if err != nil { - return nil, fmt.Errorf("WAF 规则组 %d 不存在", groupID) - } - if group.IsGlobal { - return nil, errors.New("全局 WAF 规则组不需要手动绑定") - } - } - return normalized, nil -} - -func uniqueUintIDs(ids []uint) []uint { - normalized := make([]uint, 0, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - normalized = append(normalized, id) - } - normalized = utils.Unique(normalized) - sort.Slice(normalized, func(i, j int) bool { return normalized[i] < normalized[j] }) - return normalized -} diff --git a/openflare-server/internal/service/waf_ip_group.go b/openflare-server/internal/service/waf_ip_group.go deleted file mode 100644 index f84db436..00000000 --- a/openflare-server/internal/service/waf_ip_group.go +++ /dev/null @@ -1,1081 +0,0 @@ -package service - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net" - "net/http" - "net/netip" - "net/url" - "sort" - "strings" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" - - exprlang "github.com/expr-lang/expr" - "github.com/expr-lang/expr/vm" - "gorm.io/gorm" -) - -const ( - WAFIPGroupTypeManual = "manual" - WAFIPGroupTypeAutomatic = "automatic" - WAFIPGroupTypeSubscription = "subscription" - - WAFIPGroupSubscriptionFormatText = "text" - WAFIPGroupSubscriptionFormatJSON = "json" - - defaultWAFIPGroupSyncIntervalMinutes = 1440 - defaultWAFIPGroupAutoLookbackMinutes = 60 - minWAFIPGroupSyncIntervalMinutes = 5 - maxWAFIPGroupSyncIntervalMinutes = 43200 - maxWAFIPGroupSubscriptionBytes = 2 * 1024 * 1024 -) - -type wafIPGroupAutoConfig struct { - LookbackMinutes int `json:"lookback_minutes"` - TTL int `json:"ttl"` // in seconds, default -1 (permanent) - Rules []wafIPGroupAutoRule `json:"rules"` -} - -type WAFIPGroupExtIP struct { - IP string `json:"ip"` - CapturedAt time.Time `json:"captured_at"` -} - -type WAFIPGroupExtIPView struct { - IP string `json:"ip"` - CapturedAt string `json:"captured_at"` -} - -type wafIPGroupAutoRule struct { - Name string `json:"name"` - Expr string `json:"expr"` -} - -type wafIPGroupAutoRuleEnv struct { - IP string `expr:"ip"` - RequestCount int `expr:"request_count"` - Status404Count int `expr:"status_404_count"` - Status404Ratio float64 `expr:"status_404_ratio"` - IPHostCount int `expr:"ip_host_count"` - IPHostRatio float64 `expr:"ip_host_ratio"` - ClientErrorCount int `expr:"client_error_count"` - ServerErrorCount int `expr:"server_error_count"` - LastSeenUnix int64 `expr:"last_seen_unix"` - statusCounts map[int]int -} - -func (env wafIPGroupAutoRuleEnv) StatusCount(code int) int { - if env.statusCounts == nil { - return 0 - } - return env.statusCounts[code] -} - -func (env wafIPGroupAutoRuleEnv) StatusRatio(code int) float64 { - if env.RequestCount <= 0 || env.statusCounts == nil { - return 0.0 - } - return float64(env.statusCounts[code]) / float64(env.RequestCount) -} - -type wafIPGroupAutoAccumulator struct { - ip string - requestCount int - status404Count int - ipHostCount int - clientErrorCount int - serverErrorCount int - lastSeen time.Time - statusCounts map[int]int -} - -type WAFIPGroupInput struct { - Name string `json:"name"` - Type string `json:"type"` - Enabled bool `json:"enabled"` - IPList []string `json:"ip_list"` - AutoConfig json.RawMessage `json:"auto_config"` - SubscriptionURL string `json:"subscription_url"` - SubscriptionFormat string `json:"subscription_format"` - SubscriptionMappingRule string `json:"subscription_mapping_rule"` - SyncIntervalMinutes int `json:"sync_interval_minutes"` - Remark string `json:"remark"` -} - -type WAFIPGroupView struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Enabled bool `json:"enabled"` - IPList []string `json:"ip_list"` - AutoConfig json.RawMessage `json:"auto_config"` - ExtIPs []WAFIPGroupExtIPView `json:"ext_ips"` - SubscriptionURL string `json:"subscription_url"` - SubscriptionFormat string `json:"subscription_format"` - SubscriptionMappingRule string `json:"subscription_mapping_rule"` - SyncIntervalMinutes int `json:"sync_interval_minutes"` - LastSyncedAt string `json:"last_synced_at,omitempty"` - NextSyncAt string `json:"next_sync_at,omitempty"` - LastSyncStatus string `json:"last_sync_status"` - LastSyncMessage string `json:"last_sync_message"` - Remark string `json:"remark"` - ReferencedByRuleCount int `json:"referenced_by_rule_count"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type WAFIPGroupSyncResult struct { - Group WAFIPGroupView `json:"group"` - IPCount int `json:"ip_count"` - SyncedAt string `json:"synced_at"` - NextSyncAt string `json:"next_sync_at"` - Status string `json:"status"` - Message string `json:"message"` -} - -type WAFIPGroupAutoTestInput struct { - AutoConfig json.RawMessage `json:"auto_config"` -} - -type WAFIPGroupAutoTestResult struct { - MatchedIPs []string `json:"matched_ips"` - MatchedCount int `json:"matched_count"` - LookbackMinutes int `json:"lookback_minutes"` - RuleCount int `json:"rule_count"` - TestedAt string `json:"tested_at"` -} - -func ListWAFIPGroups() ([]WAFIPGroupView, error) { - groups, err := model.ListWAFIPGroups() - if err != nil { - return nil, err - } - referenceCounts, err := loadWAFIPGroupReferenceCounts() - if err != nil { - return nil, err - } - views := make([]WAFIPGroupView, 0, len(groups)) - for _, group := range groups { - view, err := buildWAFIPGroupView(group, referenceCounts[group.ID]) - if err != nil { - return nil, err - } - views = append(views, view) - } - return views, nil -} - -func GetWAFIPGroup(id uint) (*WAFIPGroupView, error) { - group, err := model.GetWAFIPGroupByID(id) - if err != nil { - return nil, err - } - referenceCounts, err := loadWAFIPGroupReferenceCounts() - if err != nil { - return nil, err - } - view, err := buildWAFIPGroupView(group, referenceCounts[group.ID]) - if err != nil { - return nil, err - } - return &view, nil -} - -func CreateWAFIPGroup(input WAFIPGroupInput) (*WAFIPGroupView, error) { - group, err := buildWAFIPGroup(nil, input) - if err != nil { - return nil, err - } - if err := group.Insert(); err != nil { - return nil, err - } - view, err := GetWAFIPGroup(group.ID) - if err == nil { - broadcastWAFIPGroupToAgents(group.ID) - } - return view, err -} - -func UpdateWAFIPGroup(id uint, input WAFIPGroupInput) (*WAFIPGroupView, error) { - group, err := model.GetWAFIPGroupByID(id) - if err != nil { - return nil, err - } - group, err = buildWAFIPGroup(group, input) - if err != nil { - return nil, err - } - if err := group.Update(); err != nil { - return nil, err - } - view, err := GetWAFIPGroup(group.ID) - if err == nil { - broadcastWAFIPGroupToAgents(group.ID) - } - return view, err -} - -func DeleteWAFIPGroup(id uint) error { - group, err := model.GetWAFIPGroupByID(id) - if err != nil { - return err - } - counts, err := loadWAFIPGroupReferenceCounts() - if err != nil { - return err - } - if counts[group.ID] > 0 { - return errors.New("IP 组已被 WAF 规则组引用,请先移除引用") - } - return group.Delete() -} - -func SyncWAFIPGroup(id uint) (*WAFIPGroupSyncResult, error) { - group, err := model.GetWAFIPGroupByID(id) - if err != nil { - return nil, err - } - return syncWAFIPGroup(group, time.Now().UTC()) -} - -func TestWAFIPGroupAutoConfig(input WAFIPGroupAutoTestInput) (*WAFIPGroupAutoTestResult, error) { - config, err := parseWAFIPGroupAutoConfig(input.AutoConfig) - if err != nil { - return nil, err - } - now := time.Now().UTC() - ips, err := evaluateParsedWAFIPGroupAutoConfig(config, now) - if err != nil { - return nil, err - } - return &WAFIPGroupAutoTestResult{ - MatchedIPs: ips, - MatchedCount: len(ips), - LookbackMinutes: config.LookbackMinutes, - RuleCount: len(config.Rules), - TestedAt: now.Format(time.RFC3339), - }, nil -} - -func SyncDueWAFIPGroups() error { - now := time.Now().UTC() - groups, err := model.ListDueWAFIPGroups(now) - if err != nil { - return err - } - for _, group := range groups { - if _, err := syncWAFIPGroup(group, now); err != nil { - continue - } - } - return nil -} - -func buildWAFIPGroup(group *model.WAFIPGroup, input WAFIPGroupInput) (*model.WAFIPGroup, error) { - name := strings.TrimSpace(input.Name) - if name == "" { - return nil, errors.New("IP 组名称不能为空") - } - groupType := normalizeWAFIPGroupType(input.Type) - if groupType == "" { - return nil, errors.New("IP 组类型无效") - } - ipList := input.IPList - subscriptionURL := "" - subscriptionFormat := normalizeWAFIPGroupSubscriptionFormat(input.SubscriptionFormat) - mappingRule := strings.TrimSpace(input.SubscriptionMappingRule) - syncInterval := normalizeWAFIPGroupSyncInterval(input.SyncIntervalMinutes) - autoConfig := "{}" - - switch groupType { - case WAFIPGroupTypeManual: - subscriptionFormat = WAFIPGroupSubscriptionFormatText - mappingRule = "" - case WAFIPGroupTypeAutomatic: - normalizedConfig, err := normalizeWAFIPGroupAutoConfig(input.AutoConfig) - if err != nil { - return nil, err - } - autoConfig = normalizedConfig - subscriptionFormat = WAFIPGroupSubscriptionFormatText - mappingRule = "" - case WAFIPGroupTypeSubscription: - subscriptionURL = strings.TrimSpace(input.SubscriptionURL) - if err := validateSubscriptionURL(subscriptionURL); err != nil { - return nil, err - } - if subscriptionFormat == "" { - subscriptionFormat = WAFIPGroupSubscriptionFormatText - } - } - - normalizedIPs, err := normalizeWAFIPList(ipList) - if err != nil { - return nil, err - } - ipListJSON, _ := json.Marshal(normalizedIPs) - if group == nil { - group = &model.WAFIPGroup{} - group.ExtIPs = "[]" - } - group.Name = name - group.Type = groupType - group.Enabled = input.Enabled - group.IPList = string(ipListJSON) - group.AutoConfig = autoConfig - group.SubscriptionURL = subscriptionURL - group.SubscriptionFormat = subscriptionFormat - group.SubscriptionMappingRule = mappingRule - group.SyncIntervalMinutes = syncInterval - group.NextSyncAt = nextWAFIPGroupSyncAt(group.Type, group.Enabled, syncInterval, group.NextSyncAt) - group.Remark = strings.TrimSpace(input.Remark) - return group, nil -} - -func buildWAFIPGroupView(group *model.WAFIPGroup, referenceCount int) (WAFIPGroupView, error) { - if group == nil { - return WAFIPGroupView{}, errors.New("waf ip group is nil") - } - ips, err := decodeStringList(group.IPList) - if err != nil { - return WAFIPGroupView{}, err - } - autoConfig := json.RawMessage(strings.TrimSpace(group.AutoConfig)) - if len(autoConfig) == 0 { - autoConfig = json.RawMessage("{}") - } - var extIPs []WAFIPGroupExtIP - if group.ExtIPs != "" && group.ExtIPs != "[]" { - _ = json.Unmarshal([]byte(group.ExtIPs), &extIPs) - } - viewExtIPs := make([]WAFIPGroupExtIPView, 0, len(extIPs)) - for _, extIP := range extIPs { - viewExtIPs = append(viewExtIPs, WAFIPGroupExtIPView{ - IP: extIP.IP, - CapturedAt: extIP.CapturedAt.Format(time.RFC3339), - }) - } - view := WAFIPGroupView{ - ID: group.ID, - Name: group.Name, - Type: group.Type, - Enabled: group.Enabled, - IPList: ips, - AutoConfig: autoConfig, - ExtIPs: viewExtIPs, - SubscriptionURL: group.SubscriptionURL, - SubscriptionFormat: group.SubscriptionFormat, - SubscriptionMappingRule: group.SubscriptionMappingRule, - SyncIntervalMinutes: group.SyncIntervalMinutes, - LastSyncStatus: group.LastSyncStatus, - LastSyncMessage: group.LastSyncMessage, - Remark: group.Remark, - ReferencedByRuleCount: referenceCount, - CreatedAt: group.CreatedAt.Format(time.RFC3339), - UpdatedAt: group.UpdatedAt.Format(time.RFC3339), - } - if group.LastSyncedAt != nil { - view.LastSyncedAt = group.LastSyncedAt.Format(time.RFC3339) - } - if group.NextSyncAt != nil { - view.NextSyncAt = group.NextSyncAt.Format(time.RFC3339) - } - return view, nil -} - -func ChangedWAFIPGroupsForAgent(ids []uint, checksums map[string]string) ([]AgentWAFIPGroup, error) { - targetIDs := uniqueUintIDs(ids) - if len(targetIDs) == 0 { - activeIDs, err := activeConfigWAFIPGroupIDs() - if err != nil { - return nil, err - } - targetIDs = activeIDs - } - if len(targetIDs) == 0 { - return []AgentWAFIPGroup{}, nil - } - groups, err := buildAgentWAFIPGroups(targetIDs) - if err != nil { - return nil, err - } - changed := make([]AgentWAFIPGroup, 0, len(groups)) - for _, group := range groups { - if strings.TrimSpace(checksums[fmt.Sprintf("%d", group.ID)]) == group.Checksum { - continue - } - changed = append(changed, group) - } - return changed, nil -} - -func SyncWAFIPGroupsForAgent(input AgentWAFIPGroupSyncInput) (*AgentWAFIPGroupSyncResult, error) { - groups, err := ChangedWAFIPGroupsForAgent(input.IDs, input.Checksums) - if err != nil { - return nil, err - } - return &AgentWAFIPGroupSyncResult{Groups: groups}, nil -} - -func buildAgentWAFIPGroups(ids []uint) ([]AgentWAFIPGroup, error) { - ids = uniqueUintIDs(ids) - if len(ids) == 0 { - return []AgentWAFIPGroup{}, nil - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - groups, err := model.ListWAFIPGroupsByIDs(ids) - if err != nil { - return nil, err - } - groupByID := make(map[uint]*model.WAFIPGroup, len(groups)) - for _, group := range groups { - groupByID[group.ID] = group - } - result := make([]AgentWAFIPGroup, 0, len(ids)) - for _, id := range ids { - group := groupByID[id] - if group == nil { - continue - } - agentGroup, err := buildAgentWAFIPGroup(group) - if err != nil { - return nil, err - } - result = append(result, agentGroup) - } - return result, nil -} - -func buildAgentWAFIPGroup(group *model.WAFIPGroup) (AgentWAFIPGroup, error) { - if group == nil { - return AgentWAFIPGroup{}, errors.New("IP 组不存在") - } - ips, err := decodeStringList(group.IPList) - if err != nil { - return AgentWAFIPGroup{}, err - } - if !group.Enabled { - ips = []string{} - } - agentGroup := AgentWAFIPGroup{ - ID: group.ID, - Name: group.Name, - Type: group.Type, - Enabled: group.Enabled, - IPList: ips, - } - agentGroup.Checksum = checksumAgentWAFIPGroup(agentGroup) - return agentGroup, nil -} - -func checksumAgentWAFIPGroup(group AgentWAFIPGroup) string { - payload := struct { - ID uint `json:"id"` - Enabled bool `json:"enabled"` - IPList []string `json:"ip_list"` - }{ - ID: group.ID, - Enabled: group.Enabled, - IPList: append([]string{}, group.IPList...), - } - sort.Strings(payload.IPList) - data, _ := json.Marshal(payload) - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} - -func activeConfigWAFIPGroupIDs() ([]uint, error) { - version, err := model.GetActiveConfigVersion() - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return []uint{}, nil - } - return nil, err - } - snapshot, err := parseSnapshotDocument(version.SnapshotJSON) - if err != nil { - return nil, err - } - idSet := make(map[uint]struct{}) - for _, group := range snapshot.WAF.RuleGroups { - for _, id := range group.IPWhitelistGroups { - if id > 0 { - idSet[id] = struct{}{} - } - } - for _, id := range group.IPBlacklistGroups { - if id > 0 { - idSet[id] = struct{}{} - } - } - } - ids := make([]uint, 0, len(idSet)) - for id := range idSet { - ids = append(ids, id) - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - return ids, nil -} - -func broadcastWAFIPGroupToAgents(id uint) { - groups, err := buildAgentWAFIPGroups([]uint{id}) - if err != nil || len(groups) == 0 { - if err != nil { - slog.Debug("build waf ip group broadcast payload failed", "id", id, "error", err) - } - return - } - BroadcastAgentWSWAFIPGroups(groups) -} - -func syncWAFIPGroup(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResult, error) { - if group == nil { - return nil, errors.New("IP 组不存在") - } - switch group.Type { - case WAFIPGroupTypeSubscription: - return syncWAFIPGroupSubscription(group, now) - case WAFIPGroupTypeAutomatic: - return syncWAFIPGroupAutomatic(group, now) - default: - return nil, errors.New("只有自动和订阅类型 IP 组支持同步") - } -} - -func syncWAFIPGroupSubscription(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResult, error) { - content, err := downloadWAFIPGroupSubscription(group.SubscriptionURL) - if err != nil { - recordWAFIPGroupSyncFailure(group, now, err) - return nil, err - } - ips, err := parseWAFIPGroupSubscription(content, group.SubscriptionFormat, group.SubscriptionMappingRule) - if err != nil { - recordWAFIPGroupSyncFailure(group, now, err) - return nil, err - } - ipListJSON, _ := json.Marshal(ips) - nextSyncAt := now.Add(time.Duration(group.SyncIntervalMinutes) * time.Minute) - group.IPList = string(ipListJSON) - group.LastSyncedAt = &now - group.NextSyncAt = &nextSyncAt - group.LastSyncStatus = "success" - group.LastSyncMessage = fmt.Sprintf("同步成功,共 %d 条 IP/IP 段", len(ips)) - if err := group.UpdateSyncResult(); err != nil { - return nil, err - } - broadcastWAFIPGroupToAgents(group.ID) - view, err := GetWAFIPGroup(group.ID) - if err != nil { - return nil, err - } - return &WAFIPGroupSyncResult{ - Group: *view, - IPCount: len(ips), - SyncedAt: now.Format(time.RFC3339), - NextSyncAt: nextSyncAt.Format(time.RFC3339), - Status: group.LastSyncStatus, - Message: group.LastSyncMessage, - }, nil -} - -func syncWAFIPGroupAutomatic(group *model.WAFIPGroup, now time.Time) (*WAFIPGroupSyncResult, error) { - config, err := parseWAFIPGroupAutoConfig(json.RawMessage(group.AutoConfig)) - if err != nil { - recordWAFIPGroupSyncFailure(group, now, err) - return nil, err - } - - var existingExtIPs []WAFIPGroupExtIP - if group.ExtIPs != "" && group.ExtIPs != "[]" { - _ = json.Unmarshal([]byte(group.ExtIPs), &existingExtIPs) - } - - activeExtIPs := make([]WAFIPGroupExtIP, 0, len(existingExtIPs)) - for _, extIP := range existingExtIPs { - if config.TTL > 0 { - expirationTime := extIP.CapturedAt.Add(time.Duration(config.TTL) * time.Second) - if expirationTime.Before(now) { - continue - } - } - activeExtIPs = append(activeExtIPs, extIP) - } - - ips, err := evaluateParsedWAFIPGroupAutoConfig(config, now) - if err != nil { - recordWAFIPGroupSyncFailure(group, now, err) - return nil, err - } - - extIPMap := make(map[string]int) - for idx, extIP := range activeExtIPs { - extIPMap[extIP.IP] = idx - } - - for _, ip := range ips { - if idx, ok := extIPMap[ip]; ok { - activeExtIPs[idx].CapturedAt = now - } else { - activeExtIPs = append(activeExtIPs, WAFIPGroupExtIP{ - IP: ip, - CapturedAt: now, - }) - } - } - - finalIPs := make([]string, 0, len(activeExtIPs)) - for _, extIP := range activeExtIPs { - finalIPs = append(finalIPs, extIP.IP) - } - finalIPs, err = normalizeWAFIPList(finalIPs) - if err != nil { - recordWAFIPGroupSyncFailure(group, now, err) - return nil, err - } - - extIPsJSON, _ := json.Marshal(activeExtIPs) - ipListJSON, _ := json.Marshal(finalIPs) - - nextSyncAt := now.Add(time.Duration(normalizeWAFIPGroupSyncInterval(group.SyncIntervalMinutes)) * time.Minute) - group.IPList = string(ipListJSON) - group.ExtIPs = string(extIPsJSON) - group.LastSyncedAt = &now - group.NextSyncAt = &nextSyncAt - group.LastSyncStatus = "success" - group.LastSyncMessage = fmt.Sprintf("自动规则执行成功,共命中 %d 个 IP,当前生效 %d 个 IP", len(ips), len(finalIPs)) - if err := group.UpdateSyncResult(); err != nil { - return nil, err - } - broadcastWAFIPGroupToAgents(group.ID) - view, err := GetWAFIPGroup(group.ID) - if err != nil { - return nil, err - } - return &WAFIPGroupSyncResult{ - Group: *view, - IPCount: len(finalIPs), - SyncedAt: now.Format(time.RFC3339), - NextSyncAt: nextSyncAt.Format(time.RFC3339), - Status: group.LastSyncStatus, - Message: group.LastSyncMessage, - }, nil -} - -func recordWAFIPGroupSyncFailure(group *model.WAFIPGroup, now time.Time, syncErr error) { - nextSyncAt := now.Add(time.Duration(normalizeWAFIPGroupSyncInterval(group.SyncIntervalMinutes)) * time.Minute) - group.LastSyncedAt = &now - group.NextSyncAt = &nextSyncAt - group.LastSyncStatus = "failed" - group.LastSyncMessage = syncErr.Error() - _ = group.UpdateSyncResult() -} - -func normalizeWAFIPGroupAutoConfig(raw json.RawMessage) (string, error) { - text := strings.TrimSpace(string(raw)) - if text == "" { - text = "{}" - } - config, err := parseWAFIPGroupAutoConfig(json.RawMessage(text)) - if err != nil { - return "", err - } - normalized, _ := json.Marshal(config) - return string(normalized), nil -} - -func evaluateWAFIPGroupAutoConfig(raw string, now time.Time) ([]string, error) { - config, err := parseWAFIPGroupAutoConfig(json.RawMessage(raw)) - if err != nil { - return nil, err - } - return evaluateParsedWAFIPGroupAutoConfig(config, now) -} - -func parseWAFIPGroupAutoConfig(raw json.RawMessage) (wafIPGroupAutoConfig, error) { - text := strings.TrimSpace(string(raw)) - if text == "" { - text = "{}" - } - var config wafIPGroupAutoConfig - if err := json.Unmarshal([]byte(text), &config); err != nil { - return wafIPGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象") - } - var object map[string]any - if err := json.Unmarshal([]byte(text), &object); err != nil || object == nil { - return wafIPGroupAutoConfig{}, errors.New("自动 IP 组配置必须是 JSON 对象") - } - if config.LookbackMinutes <= 0 { - config.LookbackMinutes = defaultWAFIPGroupAutoLookbackMinutes - } - if config.LookbackMinutes < 5 { - config.LookbackMinutes = 5 - } - if config.LookbackMinutes > 43200 { - config.LookbackMinutes = 43200 - } - if config.TTL == 0 { - config.TTL = -1 - } - if config.Rules == nil { - config.Rules = []wafIPGroupAutoRule{} - } - for i, rule := range config.Rules { - rule.Name = strings.TrimSpace(rule.Name) - rule.Expr = strings.TrimSpace(rule.Expr) - if rule.Expr == "" { - return wafIPGroupAutoConfig{}, fmt.Errorf("自动规则 %d 的 Expr 表达式不能为空", i+1) - } - if _, err := exprlang.Compile(rule.Expr, exprlang.Env(wafIPGroupAutoRuleEnv{}), exprlang.AsBool()); err != nil { - return wafIPGroupAutoConfig{}, fmt.Errorf("自动规则 %s Expr 无效: %w", displayWAFIPGroupAutoRuleName(rule, i), err) - } - config.Rules[i] = rule - } - return config, nil -} - -func evaluateParsedWAFIPGroupAutoConfig(config wafIPGroupAutoConfig, now time.Time) ([]string, error) { - if len(config.Rules) == 0 { - return []string{}, nil - } - programs := make([]*vm.Program, 0, len(config.Rules)) - for i, rule := range config.Rules { - program, err := exprlang.Compile(rule.Expr, exprlang.Env(wafIPGroupAutoRuleEnv{}), exprlang.AsBool()) - if err != nil { - return nil, fmt.Errorf("自动规则 %s Expr 无效: %w", displayWAFIPGroupAutoRuleName(rule, i), err) - } - programs = append(programs, program) - } - logs, err := model.ListNodeAccessLogsForWAFIPGroup(model.NodeAccessLogQuery{ - Since: now.Add(-time.Duration(config.LookbackMinutes) * time.Minute), - Until: now, - }) - if err != nil { - return nil, err - } - accumulators := make(map[string]*wafIPGroupAutoAccumulator) - for _, item := range logs { - if item == nil { - continue - } - ip, ok := normalizeIPLiteral(item.RemoteAddr) - if !ok { - continue - } - acc := accumulators[ip] - if acc == nil { - acc = &wafIPGroupAutoAccumulator{ - ip: ip, - statusCounts: make(map[int]int), - } - accumulators[ip] = acc - } - acc.requestCount++ - acc.statusCounts[item.StatusCode]++ - if item.StatusCode == http.StatusNotFound { - acc.status404Count++ - } - if item.StatusCode >= 400 && item.StatusCode < 500 { - acc.clientErrorCount++ - } - if item.StatusCode >= 500 { - acc.serverErrorCount++ - } - if hostIsIPLiteral(item.Host) { - acc.ipHostCount++ - } - if item.LoggedAt.After(acc.lastSeen) { - acc.lastSeen = item.LoggedAt - } - } - matched := make([]string, 0) - for _, acc := range accumulators { - env := acc.toExprEnv() - for _, program := range programs { - output, err := exprlang.Run(program, env) - if err != nil { - return nil, fmt.Errorf("执行自动规则失败: %w", err) - } - if matchedRule, ok := output.(bool); ok && matchedRule { - matched = append(matched, acc.ip) - break - } - } - } - return normalizeWAFIPList(matched) -} - -func (acc *wafIPGroupAutoAccumulator) toExprEnv() wafIPGroupAutoRuleEnv { - env := wafIPGroupAutoRuleEnv{ - IP: acc.ip, - RequestCount: acc.requestCount, - Status404Count: acc.status404Count, - IPHostCount: acc.ipHostCount, - ClientErrorCount: acc.clientErrorCount, - ServerErrorCount: acc.serverErrorCount, - statusCounts: acc.statusCounts, - } - if acc.requestCount > 0 { - env.Status404Ratio = float64(acc.status404Count) / float64(acc.requestCount) - env.IPHostRatio = float64(acc.ipHostCount) / float64(acc.requestCount) - } - if !acc.lastSeen.IsZero() { - env.LastSeenUnix = acc.lastSeen.Unix() - } - return env -} - -func displayWAFIPGroupAutoRuleName(rule wafIPGroupAutoRule, index int) string { - if rule.Name != "" { - return rule.Name - } - return fmt.Sprintf("#%d", index+1) -} - -func normalizeIPLiteral(value string) (string, bool) { - host := strings.TrimSpace(value) - if host == "" { - return "", false - } - if parsedHost, _, err := net.SplitHostPort(host); err == nil { - host = parsedHost - } - host = strings.Trim(host, "[]") - addr, err := netip.ParseAddr(host) - if err != nil { - return "", false - } - return addr.String(), true -} - -func hostIsIPLiteral(value string) bool { - _, ok := normalizeIPLiteral(value) - return ok -} - -func downloadWAFIPGroupSubscription(rawURL string) ([]byte, error) { - if err := validateSubscriptionURL(rawURL); err != nil { - return nil, err - } - client := http.Client{Timeout: 15 * time.Second} - resp, err := client.Get(rawURL) - if err != nil { - return nil, fmt.Errorf("下载订阅失败: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("订阅返回状态码 %d", resp.StatusCode) - } - var buffer bytes.Buffer - reader := io.LimitReader(resp.Body, maxWAFIPGroupSubscriptionBytes+1) - if _, err := buffer.ReadFrom(reader); err != nil { - return nil, fmt.Errorf("读取订阅内容失败: %w", err) - } - if buffer.Len() > maxWAFIPGroupSubscriptionBytes { - return nil, fmt.Errorf("订阅内容不能超过 %d 字节", maxWAFIPGroupSubscriptionBytes) - } - return buffer.Bytes(), nil -} - -func parseWAFIPGroupSubscription(content []byte, format string, mappingRule string) ([]string, error) { - switch normalizeWAFIPGroupSubscriptionFormat(format) { - case WAFIPGroupSubscriptionFormatJSON: - items, err := parseWAFIPGroupJSONSubscription(content, mappingRule) - if err != nil { - return nil, err - } - return normalizeWAFIPList(items) - default: - return normalizeWAFIPList(parseWAFIPGroupTextSubscription(string(content))) - } -} - -func parseWAFIPGroupTextSubscription(text string) []string { - lines := strings.Split(text, "\n") - items := make([]string, 0, len(lines)) - for _, line := range lines { - item := strings.TrimSpace(line) - if item == "" || strings.HasPrefix(item, "#") { - continue - } - items = append(items, item) - } - return items -} - -func parseWAFIPGroupJSONSubscription(content []byte, mappingRule string) ([]string, error) { - var payload any - if err := json.Unmarshal(content, &payload); err != nil { - return nil, fmt.Errorf("JSON 订阅解析失败: %w", err) - } - nodes, err := selectJSONMappingNodes(payload, mappingRule) - if err != nil { - return nil, err - } - items := make([]string, 0, len(nodes)) - for _, node := range nodes { - collectJSONStrings(node, &items) - } - if len(items) == 0 { - return nil, errors.New("JSON 订阅没有解析到 IP/IP 段") - } - return items, nil -} - -func selectJSONMappingNodes(payload any, mappingRule string) ([]any, error) { - rule := strings.TrimSpace(mappingRule) - if rule == "" || rule == "$" { - return []any{payload}, nil - } - rule = strings.TrimPrefix(rule, "$.") - nodes := []any{payload} - for _, rawSegment := range strings.Split(rule, ".") { - segment := strings.TrimSpace(rawSegment) - if segment == "" { - continue - } - expandArray := strings.HasSuffix(segment, "[]") - segment = strings.TrimSuffix(segment, "[]") - next := make([]any, 0) - for _, node := range nodes { - object, ok := node.(map[string]any) - if !ok { - continue - } - value, ok := object[segment] - if !ok { - continue - } - if expandArray { - array, ok := value.([]any) - if !ok { - continue - } - next = append(next, array...) - } else { - next = append(next, value) - } - } - nodes = next - } - if len(nodes) == 0 { - return nil, fmt.Errorf("JSON 映射规则 %q 未匹配到内容", mappingRule) - } - return nodes, nil -} - -func collectJSONStrings(node any, items *[]string) { - switch value := node.(type) { - case string: - *items = append(*items, value) - case []any: - for _, item := range value { - collectJSONStrings(item, items) - } - } -} - -func validateSubscriptionURL(rawURL string) error { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil || parsed.Host == "" { - return errors.New("订阅 URL 无效") - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return errors.New("订阅 URL 仅支持 http 或 https") - } - return nil -} - -func normalizeWAFIPGroupType(value string) string { - switch strings.TrimSpace(value) { - case WAFIPGroupTypeManual, "": - return WAFIPGroupTypeManual - case WAFIPGroupTypeAutomatic: - return WAFIPGroupTypeAutomatic - case WAFIPGroupTypeSubscription: - return WAFIPGroupTypeSubscription - default: - return "" - } -} - -func normalizeWAFIPGroupSubscriptionFormat(value string) string { - switch strings.TrimSpace(value) { - case WAFIPGroupSubscriptionFormatJSON: - return WAFIPGroupSubscriptionFormatJSON - default: - return WAFIPGroupSubscriptionFormatText - } -} - -func normalizeWAFIPGroupSyncInterval(value int) int { - if value <= 0 { - return defaultWAFIPGroupSyncIntervalMinutes - } - if value < minWAFIPGroupSyncIntervalMinutes { - return minWAFIPGroupSyncIntervalMinutes - } - if value > maxWAFIPGroupSyncIntervalMinutes { - return maxWAFIPGroupSyncIntervalMinutes - } - return value -} - -func nextWAFIPGroupSyncAt(groupType string, enabled bool, interval int, current *time.Time) *time.Time { - if (groupType != WAFIPGroupTypeSubscription && groupType != WAFIPGroupTypeAutomatic) || !enabled { - return nil - } - if current != nil && current.After(time.Now().UTC()) { - return current - } - next := time.Now().UTC().Add(time.Duration(normalizeWAFIPGroupSyncInterval(interval)) * time.Minute) - return &next -} - -func loadWAFIPGroupReferenceCounts() (map[uint]int, error) { - var groups []model.WAFRuleGroup - if err := model.DB.Select("ip_whitelist_groups", "ip_blacklist_groups").Find(&groups).Error; err != nil { - return nil, err - } - counts := make(map[uint]int) - for _, group := range groups { - for _, id := range mustDecodeUintList(group.IPWhitelistGroups) { - counts[id]++ - } - for _, id := range mustDecodeUintList(group.IPBlacklistGroups) { - counts[id]++ - } - } - return counts, nil -} - -func normalizeWAFIPGroupIDs(ids []uint) ([]uint, error) { - normalized := uniqueUintIDs(ids) - for _, id := range normalized { - if _, err := model.GetWAFIPGroupByID(id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("IP 组 %d 不存在", id) - } - return nil, err - } - } - return normalized, nil -} - -func mustDecodeUintList(raw string) []uint { - var values []uint - if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &values); err != nil { - return []uint{} - } - values = uniqueUintIDs(values) - sort.Slice(values, func(i, j int) bool { return values[i] < values[j] }) - return values -} diff --git a/openflare-server/internal/service/waf_test.go b/openflare-server/internal/service/waf_test.go deleted file mode 100644 index d9131ba8..00000000 --- a/openflare-server/internal/service/waf_test.go +++ /dev/null @@ -1,514 +0,0 @@ -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/rain-kl/openflare/openflare-server/internal/model" -) - -func TestWAFRuleGroupValidationAndNormalization(t *testing.T) { - setupServiceTestDB(t) - - group, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "edge guard", - Enabled: true, - BlockStatusCode: 451, - IPWhitelist: []string{" 192.0.2.1 ", "192.0.2.1", "198.51.100.0/24"}, - IPBlacklist: []string{"203.0.113.10"}, - CountryBlacklist: []string{" cn ", "CN", "us"}, - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - if len(group.IPWhitelist) != 2 || group.IPWhitelist[0] != "192.0.2.1" || group.IPWhitelist[1] != "198.51.100.0/24" { - t.Fatalf("unexpected normalized ip whitelist: %#v", group.IPWhitelist) - } - if len(group.CountryBlacklist) != 2 || group.CountryBlacklist[0] != "CN" || group.CountryBlacklist[1] != "US" { - t.Fatalf("unexpected normalized countries: %#v", group.CountryBlacklist) - } - - if _, err = CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "bad ip", - Enabled: true, - IPBlacklist: []string{"not-an-ip"}, - }); err == nil { - t.Fatal("expected invalid IP to be rejected") - } -} - -func TestWAFGlobalGroupAndBindings(t *testing.T) { - setupServiceTestDB(t) - - groups, err := ListWAFRuleGroups() - if err != nil { - t.Fatalf("ListWAFRuleGroups failed: %v", err) - } - if len(groups) == 0 || !groups[0].IsGlobal { - t.Fatalf("expected default global WAF rule group, got %#v", groups) - } - if err = DeleteWAFRuleGroup(groups[0].ID); err == nil { - t.Fatal("expected global WAF rule group delete to be rejected") - } - - route, err := CreateProxyRoute(ProxyRouteInput{ - SiteName: "waf-site", - Domains: []string{"waf.example.com"}, - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - custom, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "custom", - Enabled: true, - BlockStatusCode: 418, - IPBlacklist: []string{"203.0.113.10"}, - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - if _, err = ReplaceWAFRuleGroupSites(custom.ID, []uint{route.ID}); err != nil { - t.Fatalf("ReplaceWAFRuleGroupSites failed: %v", err) - } - siteGroups, err := GetWAFSiteRuleGroups(route.ID) - if err != nil { - t.Fatalf("GetWAFSiteRuleGroups failed: %v", err) - } - if len(siteGroups.AppliedIDs) != 1 || siteGroups.AppliedIDs[0] != custom.ID { - t.Fatalf("unexpected site WAF bindings: %#v", siteGroups.AppliedIDs) - } -} - -func TestPublishConfigVersionIncludesWAFSnapshotAndRuntimeConfig(t *testing.T) { - setupServiceTestDB(t) - - route, err := CreateProxyRoute(ProxyRouteInput{ - SiteName: "waf-publish", - Domains: []string{"waf-publish.example.com"}, - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - group, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "publish group", - Enabled: true, - BlockStatusCode: 451, - IPBlacklist: []string{"203.0.113.0/24"}, - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{group.ID}); err != nil { - t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err) - } - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.RenderedConfig, "access_by_lua_file __OPENFLARE_LUA_DIR__/waf/check.lua;") { - t.Fatal("expected route config to include WAF lua access hook") - } - if !strings.Contains(result.Version.SnapshotJSON, `"waf"`) { - t.Fatal("expected snapshot to include waf document") - } - var files []SupportFile - if err = json.Unmarshal([]byte(result.Version.SupportFilesJSON), &files); err != nil { - t.Fatalf("decode support files failed: %v", err) - } - found := false - for _, file := range files { - if file.Path == "waf_config.json" && strings.Contains(file.Content, "203.0.113.0/24") { - found = true - } - } - if !found { - t.Fatalf("expected waf_config.json support file, got %#v", files) - } -} - -func TestWAFIPGroupCRUDAndRuleGroupReference(t *testing.T) { - setupServiceTestDB(t) - - ipGroup, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "bad actors", - Type: WAFIPGroupTypeManual, - Enabled: true, - IPList: []string{"203.0.113.10", "203.0.113.10", "198.51.100.0/24"}, - }) - if err != nil { - t.Fatalf("CreateWAFIPGroup failed: %v", err) - } - if len(ipGroup.IPList) != 2 { - t.Fatalf("unexpected normalized IP group list: %#v", ipGroup.IPList) - } - - group, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "referenced", - Enabled: true, - BlockStatusCode: 403, - IPBlacklistGroups: []uint{ipGroup.ID}, - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - if len(group.IPBlacklistGroups) != 1 || group.IPBlacklistGroups[0] != ipGroup.ID { - t.Fatalf("unexpected blacklist group refs: %#v", group.IPBlacklistGroups) - } - if err = DeleteWAFIPGroup(ipGroup.ID); err == nil { - t.Fatal("expected referenced IP group delete to be rejected") - } -} - -func TestWAFIPGroupSubscriptionParsers(t *testing.T) { - textItems, err := parseWAFIPGroupSubscription([]byte("# comment\n203.0.113.10\n\n198.51.100.0/24\n"), "text", "") - if err != nil { - t.Fatalf("parse text subscription failed: %v", err) - } - if len(textItems) != 2 || textItems[0] != "198.51.100.0/24" || textItems[1] != "203.0.113.10" { - t.Fatalf("unexpected text subscription items: %#v", textItems) - } - - jsonItems, err := parseWAFIPGroupSubscription([]byte(`{"data":{"items":[{"ip":"203.0.113.11"},{"ip":"203.0.113.12"}]}}`), "json", "data.items[].ip") - if err != nil { - t.Fatalf("parse json subscription failed: %v", err) - } - if len(jsonItems) != 2 || jsonItems[0] != "203.0.113.11" || jsonItems[1] != "203.0.113.12" { - t.Fatalf("unexpected json subscription items: %#v", jsonItems) - } -} - -func TestSyncWAFIPGroupDownloadsSubscription(t *testing.T) { - setupServiceTestDB(t) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("203.0.113.20\n")) - })) - defer server.Close() - - group, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "subscription", - Type: WAFIPGroupTypeSubscription, - Enabled: true, - SubscriptionURL: server.URL, - SubscriptionFormat: WAFIPGroupSubscriptionFormatText, - SyncIntervalMinutes: 10, - }) - if err != nil { - t.Fatalf("CreateWAFIPGroup failed: %v", err) - } - result, err := SyncWAFIPGroup(group.ID) - if err != nil { - t.Fatalf("SyncWAFIPGroup failed: %v", err) - } - if result.IPCount != 1 || result.Group.IPList[0] != "203.0.113.20" { - t.Fatalf("unexpected sync result: %+v", result) - } -} - -func TestSyncWAFIPGroupAutomaticExprRules(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - seedWAFNodeAccessLogs(t, now, "203.0.113.10", "app.example.com", 101, 81) - seedWAFNodeAccessLogs(t, now, "203.0.113.11", "198.51.100.10", 60, 0) - seedWAFNodeAccessLogs(t, now, "203.0.113.12", "app.example.com", 120, 10) - - group, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "auto blacklist", - Type: WAFIPGroupTypeAutomatic, - Enabled: true, - AutoConfig: json.RawMessage(`{ - "lookback_minutes": 60, - "rules": [ - {"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"}, - {"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"} - ] - }`), - }) - if err != nil { - t.Fatalf("CreateWAFIPGroup failed: %v", err) - } - result, err := SyncWAFIPGroup(group.ID) - if err != nil { - t.Fatalf("SyncWAFIPGroup failed: %v", err) - } - if result.IPCount != 2 { - t.Fatalf("expected two matched IPs, got %#v", result) - } - want := map[string]bool{"203.0.113.10": true, "203.0.113.11": true} - for _, item := range result.Group.IPList { - if !want[item] { - t.Fatalf("unexpected matched IP %s in %#v", item, result.Group.IPList) - } - delete(want, item) - } - if len(want) != 0 { - t.Fatalf("missing matched IPs: %#v", want) - } -} - -func TestWAFIPGroupAutoConfigReturnsMatchedIPs(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - seedWAFNodeAccessLogs(t, now, "203.0.113.10", "app.example.com", 101, 81) - seedWAFNodeAccessLogs(t, now, "203.0.113.11", "198.51.100.10", 60, 0) - seedWAFNodeAccessLogs(t, now, "203.0.113.12", "app.example.com", 120, 10) - - result, err := TestWAFIPGroupAutoConfig(WAFIPGroupAutoTestInput{ - AutoConfig: json.RawMessage(`{ - "lookback_minutes": 60, - "rules": [ - {"name":"单 IP 404 高频扫描","expr":"request_count > 100 && StatusRatio(404) >= 0.8"}, - {"name":"单 IP 直连访问异常","expr":"ip_host_count > 50 && ip_host_ratio > 0.5"} - ] - }`), - }) - if err != nil { - t.Fatalf("TestWAFIPGroupAutoConfig failed: %v", err) - } - if result.MatchedCount != 2 || result.RuleCount != 2 || result.LookbackMinutes != 60 { - t.Fatalf("unexpected test result: %+v", result) - } - want := map[string]bool{"203.0.113.10": true, "203.0.113.11": true} - for _, item := range result.MatchedIPs { - if !want[item] { - t.Fatalf("unexpected matched IP %s in %#v", item, result.MatchedIPs) - } - delete(want, item) - } - if len(want) != 0 { - t.Fatalf("missing matched IPs: %#v", want) - } -} - -func TestWAFIPGroupAutomaticRejectsInvalidExpr(t *testing.T) { - setupServiceTestDB(t) - - if _, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "bad auto", - Type: WAFIPGroupTypeAutomatic, - Enabled: true, - AutoConfig: json.RawMessage(`{ - "rules": [{"name":"bad","expr":"request_count > "}] - }`), - }); err == nil { - t.Fatal("expected invalid Expr to be rejected") - } -} - -func TestPublishConfigVersionKeepsWAFIPGroupReferences(t *testing.T) { - setupServiceTestDB(t) - - route, err := CreateProxyRoute(ProxyRouteInput{ - SiteName: "waf-ip-groups", - Domains: []string{"waf-ip-groups.example.com"}, - OriginURL: "https://origin.internal", - Enabled: true, - }) - if err != nil { - t.Fatalf("CreateProxyRoute failed: %v", err) - } - ipGroup, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "publish refs", - Type: WAFIPGroupTypeManual, - Enabled: true, - IPList: []string{"203.0.113.30"}, - }) - if err != nil { - t.Fatalf("CreateWAFIPGroup failed: %v", err) - } - ruleGroup, err := CreateWAFRuleGroup(WAFRuleGroupInput{ - Name: "publish group refs", - Enabled: true, - BlockStatusCode: 451, - IPBlacklistGroups: []uint{ipGroup.ID}, - }) - if err != nil { - t.Fatalf("CreateWAFRuleGroup failed: %v", err) - } - if _, err = ReplaceWAFSiteRuleGroups(route.ID, []uint{ruleGroup.ID}); err != nil { - t.Fatalf("ReplaceWAFSiteRuleGroups failed: %v", err) - } - result, err := PublishConfigVersion("root", false) - if err != nil { - t.Fatalf("PublishConfigVersion failed: %v", err) - } - if !strings.Contains(result.Version.SnapshotJSON, `"ip_groups"`) { - t.Fatal("expected snapshot to include waf ip groups") - } - if strings.Contains(result.Version.SnapshotJSON, "203.0.113.30") { - t.Fatal("expected snapshot to avoid embedding waf ip group members") - } - var files []SupportFile - if err = json.Unmarshal([]byte(result.Version.SupportFilesJSON), &files); err != nil { - t.Fatalf("decode support files failed: %v", err) - } - foundReference := false - for _, file := range files { - if file.Path == "waf_config.json" { - if strings.Contains(file.Content, "203.0.113.30") { - t.Fatalf("expected waf_config.json to avoid expanded IP group members, got %s", file.Content) - } - if strings.Contains(file.Content, `"ip_blacklist_group_ids":[`) { - foundReference = true - } - } - } - if !foundReference { - t.Fatalf("expected IP group reference in waf_config.json, got %#v", files) - } -} - -func TestWAFIPGroupAutomaticTTLExpiration(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - // Seed access logs at now - seedWAFNodeAccessLogs(t, now, "203.0.113.10", "app.example.com", 120, 100) - - group, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "auto ttl blacklist", - Type: WAFIPGroupTypeAutomatic, - Enabled: true, - AutoConfig: json.RawMessage(`{ - "lookback_minutes": 60, - "ttl": 10, - "rules": [ - {"name":"404 Scan","expr":"request_count > 100 && StatusRatio(404) >= 0.8"} - ] - }`), - }) - if err != nil { - t.Fatalf("CreateWAFIPGroup failed: %v", err) - } - - groupModel, err := model.GetWAFIPGroupByID(group.ID) - if err != nil { - t.Fatalf("GetWAFIPGroupByID failed: %v", err) - } - - // First Sync (at now): should match 203.0.113.10 - res1, err := syncWAFIPGroup(groupModel, now) - if err != nil { - t.Fatalf("First Sync failed: %v", err) - } - if res1.IPCount != 1 || res1.Group.IPList[0] != "203.0.113.10" { - t.Fatalf("expected 203.0.113.10 to be blacklisted, got: %#v", res1.Group.IPList) - } - if len(res1.Group.ExtIPs) != 1 || res1.Group.ExtIPs[0].IP != "203.0.113.10" { - t.Fatalf("expected 203.0.113.10 to be in ExtIPs, got: %#v", res1.Group.ExtIPs) - } - - // Second Sync (65 minutes later): - // Since 65 minutes is outside the 60 minutes lookback window, the original logs won't match. - // And since 65 minutes > 10s TTL, it should be expired and removed! - futureTime := now.Add(65 * time.Minute) - res2, err := syncWAFIPGroup(groupModel, futureTime) - if err != nil { - t.Fatalf("Second Sync failed: %v", err) - } - if res2.IPCount != 0 { - t.Fatalf("expected IP to be expired and removed, got: %#v", res2.Group.IPList) - } - if len(res2.Group.ExtIPs) != 0 { - t.Fatalf("expected ExtIPs to be empty after expiration, got: %#v", res2.Group.ExtIPs) - } - - // Third Sync: test lease refresh / extension! - // Re-run sync at now to get it captured again first - _, err = syncWAFIPGroup(groupModel, now) - if err != nil { - t.Fatalf("Re-sync at now failed: %v", err) - } - - // Now run sync at now + 5 seconds (5s < 10s TTL, so not expired, but matched again!): - // Since it matches again, it should keep the IP active and extend CapturedAt to now + 5s! - futureTime2 := now.Add(5 * time.Second) - res3, err := syncWAFIPGroup(groupModel, futureTime2) - if err != nil { - t.Fatalf("Third Sync failed: %v", err) - } - if res3.IPCount != 1 || res3.Group.IPList[0] != "203.0.113.10" { - t.Fatalf("expected IP to remain active, got: %#v", res3.Group.IPList) - } - if len(res3.Group.ExtIPs) != 1 || res3.Group.ExtIPs[0].CapturedAt != futureTime2.Format(time.RFC3339) { - t.Fatalf("expected CapturedAt to be updated to %v, got %v", futureTime2.Format(time.RFC3339), res3.Group.ExtIPs[0].CapturedAt) - } -} - -func seedWAFNodeAccessLogs(t *testing.T, loggedAt time.Time, remoteAddr string, host string, total int, notFound int) { - t.Helper() - for i := 0; i < total; i++ { - statusCode := http.StatusOK - if i < notFound { - statusCode = http.StatusNotFound - } - if err := model.DB.Create(&model.NodeAccessLog{ - NodeID: "node-waf-auto", - LoggedAt: loggedAt.Add(-time.Duration(i%30) * time.Second), - RemoteAddr: remoteAddr, - Host: host, - Path: "/probe", - StatusCode: statusCode, - }).Error; err != nil { - t.Fatalf("failed to seed access log: %v", err) - } - } -} - -func TestSyncWAFIPGroupAutomaticCustomStatusRules(t *testing.T) { - setupServiceTestDB(t) - - now := time.Now().UTC() - // Seed 10 requests from 203.0.113.50, where 3 return 403, 7 return 200 - seedWAFNodeAccessLogsWithStatus(t, now, "203.0.113.50", "app.example.com", 7, http.StatusOK) - seedWAFNodeAccessLogsWithStatus(t, now, "203.0.113.50", "app.example.com", 3, http.StatusForbidden) - - group, err := CreateWAFIPGroup(WAFIPGroupInput{ - Name: "custom status code blacklist", - Type: WAFIPGroupTypeAutomatic, - Enabled: true, - AutoConfig: json.RawMessage(`{ - "lookback_minutes": 60, - "rules": [ - {"name":"高频 403 探测","expr":"StatusCount(403) >= 3 && StatusRatio(403) >= 0.3"} - ] - }`), - }) - if err != nil { - t.Fatalf("CreateWAFIPGroup failed: %v", err) - } - result, err := SyncWAFIPGroup(group.ID) - if err != nil { - t.Fatalf("SyncWAFIPGroup failed: %v", err) - } - if result.IPCount != 1 || result.Group.IPList[0] != "203.0.113.50" { - t.Fatalf("expected 203.0.113.50 to be matched, got %#v", result) - } -} - -func seedWAFNodeAccessLogsWithStatus(t *testing.T, loggedAt time.Time, remoteAddr string, host string, count int, statusCode int) { - t.Helper() - for i := 0; i < count; i++ { - if err := model.DB.Create(&model.NodeAccessLog{ - NodeID: "node-waf-auto", - LoggedAt: loggedAt.Add(-time.Duration(i%30) * time.Second), - RemoteAddr: remoteAddr, - Host: host, - Path: "/probe", - StatusCode: statusCode, - }).Error; err != nil { - t.Fatalf("failed to seed access log: %v", err) - } - } -} diff --git a/openflare-server/internal/service/ws_hub.go b/openflare-server/internal/service/ws_hub.go deleted file mode 100644 index 7de529f6..00000000 --- a/openflare-server/internal/service/ws_hub.go +++ /dev/null @@ -1,223 +0,0 @@ -package service - -import ( - "log/slog" - "sync" - "time" -) - -type WSMessage struct { - Type string `json:"type"` - Payload any `json:"payload,omitempty"` -} - -type WSClient struct { - id string - send chan WSMessage - done chan struct{} - once sync.Once -} - -func (client *WSClient) ID() string { - if client == nil { - return "" - } - return client.id -} - -func (client *WSClient) Messages() <-chan WSMessage { - if client == nil { - return nil - } - return client.send -} - -func (client *WSClient) Done() <-chan struct{} { - if client == nil { - return nil - } - return client.done -} - -func (client *WSClient) Send(message WSMessage) bool { - if client == nil { - return false - } - select { - case <-client.done: - return false - case client.send <- message: - return true - default: - return false - } -} - -func (client *WSClient) Close() { - if client == nil { - return - } - client.once.Do(func() { - close(client.done) - }) -} - -type WSHub struct { - name string - mu sync.RWMutex - clients map[string]*WSClient - done chan struct{} -} - -func NewWSHub(name string) *WSHub { - h := &WSHub{ - name: name, - clients: make(map[string]*WSClient), - done: make(chan struct{}), - } - go h.startPingLoop() - return h -} - -func (h *WSHub) Close() { - close(h.done) -} - -func (h *WSHub) startPingLoop() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - for { - select { - case <-h.done: - return - case <-ticker.C: - h.mu.RLock() - if len(h.clients) == 0 { - h.mu.RUnlock() - continue - } - clients := make([]*WSClient, 0, len(h.clients)) - for _, client := range h.clients { - clients = append(clients, client) - } - h.mu.RUnlock() - - for _, client := range clients { - if !client.Send(WSMessage{ - Type: "ping", - }) { - slog.Warn("ws client send ping failed, queue full, disconnecting", "hub", h.name, "id", client.id) - h.Disconnect(client.id) - } - } - } - } -} - -func ShutdownWSHubs() { - DefaultAgentWSHub.Close() - DefaultFlaredWSHub.Close() - DefaultRelayWSHub.Close() -} - -func (h *WSHub) Register(id string) *WSClient { - client := &WSClient{ - id: id, - send: make(chan WSMessage, 16), - done: make(chan struct{}), - } - h.mu.Lock() - if existing := h.clients[id]; existing != nil { - slog.Debug("ws replacing existing connection", "hub", h.name, "id", id) - existing.Close() - } - h.clients[id] = client - count := len(h.clients) - h.mu.Unlock() - slog.Debug("ws connection registered", "hub", h.name, "id", id, "client_count", count) - return client -} - -func (h *WSHub) Unregister(client *WSClient) { - if client == nil { - return - } - h.mu.Lock() - if current := h.clients[client.id]; current == client { - delete(h.clients, client.id) - } - count := len(h.clients) - h.mu.Unlock() - client.Close() - slog.Debug("ws connection unregistered", "hub", h.name, "id", client.id, "client_count", count) -} - -func (h *WSHub) Disconnect(id string) { - h.mu.Lock() - client := h.clients[id] - if client != nil { - delete(h.clients, id) - } - count := len(h.clients) - h.mu.Unlock() - - if client != nil { - client.Close() - slog.Debug("ws connection forcefully disconnected", "hub", h.name, "id", id, "client_count", count) - } -} - -func (h *WSHub) IsConnected(id string) bool { - h.mu.RLock() - client := h.clients[id] - h.mu.RUnlock() - if client == nil { - return false - } - select { - case <-client.done: - return false - default: - return true - } -} - -func (h *WSHub) SendMessage(id string, message WSMessage) bool { - h.mu.RLock() - client := h.clients[id] - h.mu.RUnlock() - if client == nil { - return false - } - ok := client.Send(message) - if !ok { - slog.Debug("ws send queued message failed", "hub", h.name, "id", id, "type", message.Type) - } - return ok -} - -type WSBroadcastResult struct { - ClientCount int `json:"client_count"` - SuccessCount int `json:"success_count"` - FailedIDs []string `json:"failed_ids"` -} - -func (h *WSHub) Broadcast(message WSMessage) WSBroadcastResult { - h.mu.RLock() - clients := make([]*WSClient, 0, len(h.clients)) - for _, client := range h.clients { - clients = append(clients, client) - } - h.mu.RUnlock() - - var result WSBroadcastResult - result.ClientCount = len(clients) - for _, client := range clients { - if client.Send(message) { - result.SuccessCount++ - continue - } - result.FailedIDs = append(result.FailedIDs, client.ID()) - } - return result -} diff --git a/Wavelet/internal/storage/config.go b/openflare-server/internal/storage/config.go similarity index 100% rename from Wavelet/internal/storage/config.go rename to openflare-server/internal/storage/config.go diff --git a/Wavelet/internal/storage/http.go b/openflare-server/internal/storage/http.go similarity index 100% rename from Wavelet/internal/storage/http.go rename to openflare-server/internal/storage/http.go diff --git a/Wavelet/internal/storage/local.go b/openflare-server/internal/storage/local.go similarity index 100% rename from Wavelet/internal/storage/local.go rename to openflare-server/internal/storage/local.go diff --git a/Wavelet/internal/storage/local_test.go b/openflare-server/internal/storage/local_test.go similarity index 100% rename from Wavelet/internal/storage/local_test.go rename to openflare-server/internal/storage/local_test.go diff --git a/Wavelet/internal/storage/oss.go b/openflare-server/internal/storage/oss.go similarity index 100% rename from Wavelet/internal/storage/oss.go rename to openflare-server/internal/storage/oss.go diff --git a/Wavelet/internal/storage/s3.go b/openflare-server/internal/storage/s3.go similarity index 100% rename from Wavelet/internal/storage/s3.go rename to openflare-server/internal/storage/s3.go diff --git a/Wavelet/internal/storage/storage.go b/openflare-server/internal/storage/storage.go similarity index 100% rename from Wavelet/internal/storage/storage.go rename to openflare-server/internal/storage/storage.go diff --git a/Wavelet/internal/storage/storage_test.go b/openflare-server/internal/storage/storage_test.go similarity index 100% rename from Wavelet/internal/storage/storage_test.go rename to openflare-server/internal/storage/storage_test.go diff --git a/Wavelet/internal/storage/webdav.go b/openflare-server/internal/storage/webdav.go similarity index 100% rename from Wavelet/internal/storage/webdav.go rename to openflare-server/internal/storage/webdav.go diff --git a/Wavelet/internal/task/constants.go b/openflare-server/internal/task/constants.go similarity index 100% rename from Wavelet/internal/task/constants.go rename to openflare-server/internal/task/constants.go diff --git a/Wavelet/internal/task/errs.go b/openflare-server/internal/task/errs.go similarity index 100% rename from Wavelet/internal/task/errs.go rename to openflare-server/internal/task/errs.go diff --git a/Wavelet/internal/task/executor.go b/openflare-server/internal/task/executor.go similarity index 100% rename from Wavelet/internal/task/executor.go rename to openflare-server/internal/task/executor.go diff --git a/Wavelet/internal/task/executor_test.go b/openflare-server/internal/task/executor_test.go similarity index 100% rename from Wavelet/internal/task/executor_test.go rename to openflare-server/internal/task/executor_test.go diff --git a/Wavelet/internal/task/handler.go b/openflare-server/internal/task/handler.go similarity index 100% rename from Wavelet/internal/task/handler.go rename to openflare-server/internal/task/handler.go diff --git a/Wavelet/internal/task/handlers/register.go b/openflare-server/internal/task/handlers/register.go similarity index 100% rename from Wavelet/internal/task/handlers/register.go rename to openflare-server/internal/task/handlers/register.go diff --git a/Wavelet/internal/task/meta.go b/openflare-server/internal/task/meta.go similarity index 100% rename from Wavelet/internal/task/meta.go rename to openflare-server/internal/task/meta.go diff --git a/Wavelet/internal/task/meta_test.go b/openflare-server/internal/task/meta_test.go similarity index 100% rename from Wavelet/internal/task/meta_test.go rename to openflare-server/internal/task/meta_test.go diff --git a/Wavelet/internal/task/scheduler/errs.go b/openflare-server/internal/task/scheduler/errs.go similarity index 100% rename from Wavelet/internal/task/scheduler/errs.go rename to openflare-server/internal/task/scheduler/errs.go diff --git a/Wavelet/internal/task/scheduler/scheduler.go b/openflare-server/internal/task/scheduler/scheduler.go similarity index 100% rename from Wavelet/internal/task/scheduler/scheduler.go rename to openflare-server/internal/task/scheduler/scheduler.go diff --git a/Wavelet/internal/task/scheduler/scheduler_test.go b/openflare-server/internal/task/scheduler/scheduler_test.go similarity index 100% rename from Wavelet/internal/task/scheduler/scheduler_test.go rename to openflare-server/internal/task/scheduler/scheduler_test.go diff --git a/Wavelet/internal/task/utils.go b/openflare-server/internal/task/utils.go similarity index 100% rename from Wavelet/internal/task/utils.go rename to openflare-server/internal/task/utils.go diff --git a/Wavelet/internal/task/worker/middlewares.go b/openflare-server/internal/task/worker/middlewares.go similarity index 100% rename from Wavelet/internal/task/worker/middlewares.go rename to openflare-server/internal/task/worker/middlewares.go diff --git a/Wavelet/internal/task/worker/worker.go b/openflare-server/internal/task/worker/worker.go similarity index 100% rename from Wavelet/internal/task/worker/worker.go rename to openflare-server/internal/task/worker/worker.go diff --git a/Wavelet/internal/testhelper/cleanup.go b/openflare-server/internal/testhelper/cleanup.go similarity index 100% rename from Wavelet/internal/testhelper/cleanup.go rename to openflare-server/internal/testhelper/cleanup.go diff --git a/Wavelet/internal/testhelper/gin.go b/openflare-server/internal/testhelper/gin.go similarity index 100% rename from Wavelet/internal/testhelper/gin.go rename to openflare-server/internal/testhelper/gin.go diff --git a/Wavelet/internal/testhelper/test_helper.go b/openflare-server/internal/testhelper/test_helper.go similarity index 100% rename from Wavelet/internal/testhelper/test_helper.go rename to openflare-server/internal/testhelper/test_helper.go diff --git a/Wavelet/internal/util/custom_types.go b/openflare-server/internal/util/custom_types.go similarity index 100% rename from Wavelet/internal/util/custom_types.go rename to openflare-server/internal/util/custom_types.go diff --git a/Wavelet/internal/util/errs.go b/openflare-server/internal/util/errs.go similarity index 100% rename from Wavelet/internal/util/errs.go rename to openflare-server/internal/util/errs.go diff --git a/Wavelet/internal/util/http_clients.go b/openflare-server/internal/util/http_clients.go similarity index 100% rename from Wavelet/internal/util/http_clients.go rename to openflare-server/internal/util/http_clients.go diff --git a/openflare-server/internal/utils/acme/client.go b/openflare-server/internal/utils/acme/client.go deleted file mode 100644 index 736dafa3..00000000 --- a/openflare-server/internal/utils/acme/client.go +++ /dev/null @@ -1,256 +0,0 @@ -package acme - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "time" - - "github.com/go-acme/lego/v4/acme" - "github.com/go-acme/lego/v4/certcrypto" - "github.com/go-acme/lego/v4/certificate" - "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/lego" - "github.com/go-acme/lego/v4/providers/dns/cloudflare" - "github.com/go-acme/lego/v4/registration" -) - -type AcmeUser struct { - Email string - Registration *registration.Resource - key crypto.PrivateKey -} - -func (u *AcmeUser) GetEmail() string { - return u.Email -} - -func (u *AcmeUser) GetRegistration() *registration.Resource { - return u.Registration -} - -func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey { - return u.key -} - -type CertificateResult struct { - CertPEM string - KeyPEM string - NotBefore time.Time - NotAfter time.Time -} - -func parsePrivateKey(pemData string) (crypto.PrivateKey, error) { - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return nil, errors.New("failed to parse PEM block containing the key") - } - - if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return key, nil - } - return nil, errors.New("failed to parse private key") -} - -func encodePrivateKey(key crypto.PrivateKey) (string, error) { - var pemBlock *pem.Block - switch k := key.(type) { - case *rsa.PrivateKey: - pemBlock = &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} - case *ecdsa.PrivateKey: - b, err := x509.MarshalECPrivateKey(k) - if err != nil { - return "", err - } - pemBlock = &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} - default: - return "", errors.New("unsupported key type") - } - return string(pem.EncodeToMemory(pemBlock)), nil -} - -func GetOrCreateLegoClient(acmeEmail, privateKeyPEM, accountURL string, keyAlgorithm string) (*lego.Client, *AcmeUser, string, string, error) { - var privateKey crypto.PrivateKey - var err error - var newPrivateKeyPEM string - var newAccountURL string - - if privateKeyPEM == "" { - privateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, "", "", err - } - pemStr, err := encodePrivateKey(privateKey) - if err != nil { - return nil, nil, "", "", err - } - newPrivateKeyPEM = pemStr - } else { - privateKey, err = parsePrivateKey(privateKeyPEM) - if err != nil { - return nil, nil, "", "", err - } - } - - user := &AcmeUser{ - Email: acmeEmail, - key: privateKey, - } - - if accountURL != "" { - user.Registration = ®istration.Resource{ - Body: acme.Account{ - Status: "valid", - Contact: []string{"mailto:" + acmeEmail}, - }, - URI: accountURL, - } - } - - config := lego.NewConfig(user) - // Use Let's Encrypt production environment by default - config.CADirURL = lego.LEDirectoryProduction - - switch keyAlgorithm { - case "RSA2048": - config.Certificate.KeyType = certcrypto.RSA2048 - case "RSA4096": - config.Certificate.KeyType = certcrypto.RSA4096 - case "EC256": - config.Certificate.KeyType = certcrypto.EC256 - case "EC384": - config.Certificate.KeyType = certcrypto.EC384 - default: - config.Certificate.KeyType = certcrypto.RSA2048 - } - - client, err := lego.NewClient(config) - if err != nil { - return nil, nil, "", "", err - } - - if accountURL == "" { - reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) - if err != nil { - return nil, nil, "", "", err - } - user.Registration = reg - newAccountURL = reg.URI - } - - return client, user, newPrivateKeyPEM, newAccountURL, nil -} - -func SetupDNSProvider(client *lego.Client, dnsType, dnsAuth string, dns1, dns2 string, disableCNAME, skipDNS bool) error { - var provider challengeProvider - - switch dnsType { - case "cloudflare": - var creds map[string]string - if err := json.Unmarshal([]byte(dnsAuth), &creds); err != nil { - return fmt.Errorf("failed to parse cloudflare credentials: %v", err) - } - - config := cloudflare.NewDefaultConfig() - config.AuthToken = creds["api_token"] - - p, err := cloudflare.NewDNSProviderConfig(config) - if err != nil { - return err - } - provider = p - default: - return fmt.Errorf("unsupported DNS provider: %s", dnsType) - } - - var resolvers []string - if dns1 != "" { - resolvers = append(resolvers, dns1+":53") - } - if dns2 != "" { - resolvers = append(resolvers, dns2+":53") - } - - var opts []dns01.ChallengeOption - - if len(resolvers) > 0 { - opts = append(opts, dns01.AddRecursiveNameservers(resolvers)) - } - - if disableCNAME { - opts = append(opts, dns01.DisableCompletePropagationRequirement()) - } - - if skipDNS { - opts = append(opts, dns01.WrapPreCheck(func(domain, fqdn, value string, check dns01.PreCheckFunc) (bool, error) { - time.Sleep(20 * time.Second) - return true, nil - })) - } - - return client.Challenge.SetDNS01Provider(provider, opts...) -} - -type challengeProvider interface { - Present(domain, token, keyAuth string) error - CleanUp(domain, token, keyAuth string) error -} - -func ObtainSSL( - acmeEmail, acmePrivateKeyPEM, acmeURL string, - dnsType, dnsAuth string, - dns1, dns2 string, - disableCNAME, skipDNS bool, - keyAlgorithm string, - domains []string, -) (string, string, *CertificateResult, error) { - client, _, newPrivateKeyPEM, newAccountURL, err := GetOrCreateLegoClient(acmeEmail, acmePrivateKeyPEM, acmeURL, keyAlgorithm) - if err != nil { - return "", "", nil, fmt.Errorf("failed to create ACME client: %w", err) - } - - err = SetupDNSProvider(client, dnsType, dnsAuth, dns1, dns2, disableCNAME, skipDNS) - if err != nil { - return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to setup DNS provider: %w", err) - } - - request := certificate.ObtainRequest{ - Domains: domains, - Bundle: true, - } - - certificates, err := client.Certificate.Obtain(request) - if err != nil { - return newAccountURL, newPrivateKeyPEM, nil, fmt.Errorf("failed to obtain certificate: %w", err) - } - - result := &CertificateResult{ - CertPEM: string(certificates.Certificate), - KeyPEM: string(certificates.PrivateKey), - } - - // Parse validity dates - certBlock, _ := pem.Decode(certificates.Certificate) - if certBlock != nil { - parsedCert, err := x509.ParseCertificate(certBlock.Bytes) - if err == nil { - result.NotBefore = parsedCert.NotBefore - result.NotAfter = parsedCert.NotAfter - } - } - - return newAccountURL, newPrivateKeyPEM, result, nil -} diff --git a/openflare-server/internal/utils/cap/cap.go b/openflare-server/internal/utils/cap/cap.go deleted file mode 100644 index a261b0c6..00000000 --- a/openflare-server/internal/utils/cap/cap.go +++ /dev/null @@ -1,221 +0,0 @@ -package cap - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "errors" - "strconv" - "strings" - "time" -) - -const jwtHeaderB64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" - -// ChallengeConfig holds parameters for the PoW challenge -type ChallengeConfig struct { - Count int // Number of puzzles (c) - Size int // Salt length (s) - Difficulty int // Difficulty prefix length (d) - ExpiresMs time.Duration // Challenge TTL -} - -// ChallengeResponse is returned to the client -type ChallengeResponse struct { - Challenge struct { - C int `json:"c"` - S int `json:"s"` - D int `json:"d"` - } `json:"challenge"` - Token string `json:"token"` - Expires int64 `json:"expires"` // ms timestamp -} - -// ChallengePayload represents the signed JWT payload -type ChallengePayload struct { - Nonce string `json:"n"` - Count int `json:"c"` - Size int `json:"s"` - Difficulty int `json:"d"` - Expires int64 `json:"exp"` // ms timestamp - IssuedAt int64 `json:"iat"` // ms timestamp - Scope string `json:"sk,omitempty"` -} - -// RedeemRequest payload sent by client -type RedeemRequest struct { - Token string `json:"token"` - Solutions []int `json:"solutions"` -} - -// RedeemResponse returned to client after verification -type RedeemResponse struct { - Success bool `json:"success"` - Token string `json:"token,omitempty"` - Expires int64 `json:"expires,omitempty"` - Error string `json:"error,omitempty"` -} - -func b64urlEncode(data []byte) string { - return base64.RawURLEncoding.EncodeToString(data) -} - -func b64urlDecode(str string) ([]byte, error) { - return base64.RawURLEncoding.DecodeString(str) -} - -func randomHex(byteLen int) string { - bytes := make([]byte, byteLen) - if _, err := rand.Read(bytes); err != nil { - panic(err) - } - return hex.EncodeToString(bytes) -} - -func jwtSign(payload []byte, secret []byte) string { - body := b64urlEncode(payload) - sigInput := jwtHeaderB64 + "." + body - - mac := hmac.New(sha256.New, secret) - mac.Write([]byte(sigInput)) - sig := mac.Sum(nil) - - return sigInput + "." + b64urlEncode(sig) -} - -func jwtVerify(token string, secret []byte) ([]byte, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return nil, errors.New("invalid token format") - } - if parts[0] != jwtHeaderB64 { - return nil, errors.New("invalid header") - } - - sigInput := parts[0] + "." + parts[1] - mac := hmac.New(sha256.New, secret) - mac.Write([]byte(sigInput)) - expectedSig := mac.Sum(nil) - - actualSig, err := b64urlDecode(parts[2]) - if err != nil { - return nil, err - } - - if !hmac.Equal(expectedSig, actualSig) { - return nil, errors.New("signature mismatch") - } - - payload, err := b64urlDecode(parts[1]) - if err != nil { - return nil, err - } - - return payload, nil -} - -func jwtSigHex(token string) string { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return "" - } - sigBytes, err := b64urlDecode(parts[2]) - if err != nil { - return "" - } - return hex.EncodeToString(sigBytes) -} - -// GenerateChallenge produces a new challenge and signed token -func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*ChallengeResponse, error) { - if conf.Count <= 0 { - conf.Count = 50 - } - if conf.Size <= 0 { - conf.Size = 32 - } - if conf.Difficulty <= 0 { - conf.Difficulty = 4 - } - if conf.ExpiresMs <= 0 { - conf.ExpiresMs = 10 * time.Minute - } - - now := time.Now().UnixNano() / int64(time.Millisecond) - expires := now + int64(conf.ExpiresMs/time.Millisecond) - - payload := ChallengePayload{ - Nonce: randomHex(25), - Count: conf.Count, - Size: conf.Size, - Difficulty: conf.Difficulty, - Expires: expires, - IssuedAt: now, - Scope: scope, - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - return nil, err - } - - token := jwtSign(payloadBytes, secret) - - resp := &ChallengeResponse{ - Token: token, - Expires: expires, - } - resp.Challenge.C = conf.Count - resp.Challenge.S = conf.Size - resp.Challenge.D = conf.Difficulty - - return resp, nil -} - -// VerifyChallengeSolutions verifies client submitted solutions -func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) { - payloadBytes, err := jwtVerify(token, secret) - if err != nil { - return nil, errors.New("invalid_token") - } - - var payload ChallengePayload - if err := json.Unmarshal(payloadBytes, &payload); err != nil { - return nil, errors.New("invalid_token") - } - - if expectedScope != "" && payload.Scope != expectedScope { - return nil, errors.New("scope_mismatch") - } - - now := time.Now().UnixNano() / int64(time.Millisecond) - if payload.Expires < now { - return nil, errors.New("expired") - } - - if len(solutions) != payload.Count { - return nil, errors.New("invalid_solutions") - } - - tokenFnv := fnv1a(token) - for i := 0; i < payload.Count; i++ { - idxStr := strconv.Itoa(i + 1) - saltSeed := fnv1aResume(tokenFnv, idxStr) - targetSeed := fnv1aResume(saltSeed, "d") - salt := prngFromHash(saltSeed, payload.Size) - target := prngFromHash(targetSeed, payload.Difficulty) - - hashInput := salt + strconv.Itoa(solutions[i]) - hashBytes := sha256.Sum256([]byte(hashInput)) - hashHex := hex.EncodeToString(hashBytes[:]) - - if !strings.HasPrefix(hashHex, target) { - return nil, errors.New("invalid_solution") - } - } - - return &payload, nil -} diff --git a/openflare-server/internal/utils/cap/cap_test.go b/openflare-server/internal/utils/cap/cap_test.go deleted file mode 100644 index c8cee314..00000000 --- a/openflare-server/internal/utils/cap/cap_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package cap - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "strconv" - "strings" - "testing" - "time" -) - -func TestCapFullFlow(t *testing.T) { - secret := []byte("a-very-long-secret-key-at-least-16-bytes") - store := NewMemoryStore(1 * time.Minute) - - manager := NewManager(Config{ - Secret: secret, - ChallengeCount: 3, // small count for fast test - ChallengeSize: 32, - ChallengeDifficulty: 3, // small difficulty for fast test - ChallengeTTL: 5 * time.Second, - TokenTTL: 10 * time.Second, - }, store) - - scope := "test-scope" - resp, err := manager.Generate(scope) - if err != nil { - t.Fatalf("Generate failed: %v", err) - } - - if resp.Challenge.C != 3 { - t.Errorf("Expected count 3, got %d", resp.Challenge.C) - } - - // Solve the challenge (acting as client) - solutions := make([]int, resp.Challenge.C) - tokenFnv := fnv1a(resp.Token) - for i := 0; i < resp.Challenge.C; i++ { - idxStr := strconv.Itoa(i + 1) - saltSeed := fnv1aResume(tokenFnv, idxStr) - targetSeed := fnv1aResume(saltSeed, "d") - salt := prngFromHash(saltSeed, resp.Challenge.S) - target := prngFromHash(targetSeed, resp.Challenge.D) - - // Brute force the PoW solution - var found bool - for nonce := 0; nonce < 1000000; nonce++ { - hashInput := salt + strconv.Itoa(nonce) - hashBytes := sha256.Sum256([]byte(hashInput)) - hashHex := hex.EncodeToString(hashBytes[:]) - if strings.HasPrefix(hashHex, target) { - solutions[i] = nonce - found = true - break - } - } - if !found { - t.Fatalf("Failed to solve puzzle %d", i) - } - } - - // Redeem - ctx := context.Background() - redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope) - if err != nil { - t.Fatalf("Redeem failed: %v", err) - } - if !redeemResp.Success { - t.Fatalf("Redeem returned success=false: %s", redeemResp.Error) - } - if redeemResp.Token == "" { - t.Fatalf("Expected token, got empty") - } - - // Verify the token - valid, err := manager.VerifyToken(ctx, redeemResp.Token, scope) - if err != nil { - t.Fatalf("VerifyToken failed: %v", err) - } - if !valid { - t.Fatalf("Expected redeem token to be valid") - } - - // Verify token is one-time use - validAgain, err := manager.VerifyToken(ctx, redeemResp.Token, scope) - if err != nil { - t.Fatalf("VerifyToken second call failed: %v", err) - } - if validAgain { - t.Fatalf("Expected redeem token to be single-use (invalidated after verification)") - } -} diff --git a/openflare-server/internal/utils/cap/manager.go b/openflare-server/internal/utils/cap/manager.go deleted file mode 100644 index af3e5621..00000000 --- a/openflare-server/internal/utils/cap/manager.go +++ /dev/null @@ -1,165 +0,0 @@ -package cap - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "strconv" - "strings" - "time" -) - -// Config holds settings for the CAPTCHA manager -type Config struct { - Secret []byte // HMAC signing key - ChallengeCount int // Number of PoW puzzles - ChallengeSize int // Size of the salt string - ChallengeDifficulty int // Length of difficulty target prefix - ChallengeTTL time.Duration // Lifespan of the challenge JWT - TokenTTL time.Duration // Lifespan of the redeem token -} - -// Manager orchestrates challenge generation and solution validation -type Manager struct { - conf Config - store Store -} - -// NewManager creates a new CAPTCHA Manager -func NewManager(conf Config, store Store) *Manager { - if conf.ChallengeCount <= 0 { - conf.ChallengeCount = 50 - } - if conf.ChallengeSize <= 0 { - conf.ChallengeSize = 32 - } - if conf.ChallengeDifficulty <= 0 { - conf.ChallengeDifficulty = 4 - } - if conf.ChallengeTTL <= 0 { - conf.ChallengeTTL = 10 * time.Minute - } - if conf.TokenTTL <= 0 { - conf.TokenTTL = 20 * time.Minute - } - return &Manager{ - conf: conf, - store: store, - } -} - -// Generate creates a challenge response -func (m *Manager) Generate(scope string) (*ChallengeResponse, error) { - c := ChallengeConfig{ - Count: m.conf.ChallengeCount, - Size: m.conf.ChallengeSize, - Difficulty: m.conf.ChallengeDifficulty, - ExpiresMs: m.conf.ChallengeTTL, - } - return GenerateChallenge(m.conf.Secret, c, scope) -} - -// Redeem verifies PoW solutions and returns a one-time redeem token -func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) { - sigHex := jwtSigHex(token) - if sigHex == "" { - return &RedeemResponse{Success: false, Error: "invalid_token"}, nil - } - - // Replay prevention: check if this JWT signature has already been used - nonceKey := "cap:nonce:" + sigHex - _, exists, err := m.store.Get(ctx, nonceKey) - if err != nil { - return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err - } - if exists { - return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil - } - - payload, err := VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope) - if err != nil { - return &RedeemResponse{Success: false, Error: err.Error()}, nil - } - - // Verification succeeded. Consume the nonce. - now := time.Now().UnixNano() / int64(time.Millisecond) - ttlMs := time.Duration(payload.Expires-now) * time.Millisecond - if ttlMs < time.Second { - ttlMs = time.Second - } - if err := m.store.Set(ctx, nonceKey, "1", ttlMs); err != nil { - return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err - } - - // Generate a redeem token formatted as "id:verToken" - id := randomHex(8) - verToken := randomHex(15) - verHashBytes := sha256.Sum256([]byte(verToken)) - verHashHex := hex.EncodeToString(verHashBytes[:]) - - tokenKey := "cap:token:" + id + ":" + verHashHex - tokenExpires := time.Now().Add(m.conf.TokenTTL) - - // Value stored is "expiresNano|scope" - storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope - - if err := m.store.Set(ctx, tokenKey, storeVal, m.conf.TokenTTL); err != nil { - return &RedeemResponse{Success: false, Error: "token_store_error"}, err - } - - return &RedeemResponse{ - Success: true, - Token: id + ":" + verToken, - Expires: tokenExpires.UnixNano() / int64(time.Millisecond), - }, nil -} - -// VerifyToken validates and consumes the redeem token (single-use) -func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope string) (bool, error) { - if token == "" { - return false, nil - } - parts := strings.Split(token, ":") - if len(parts) != 2 { - return false, nil - } - id := parts[0] - verToken := parts[1] - - verHashBytes := sha256.Sum256([]byte(verToken)) - verHashHex := hex.EncodeToString(verHashBytes[:]) - - tokenKey := "cap:token:" + id + ":" + verHashHex - - val, exists, err := m.store.Get(ctx, tokenKey) - if err != nil { - return false, err - } - if !exists { - return false, nil - } - - // Single-use: consume/delete the token immediately - _ = m.store.Delete(ctx, tokenKey) - - valParts := strings.Split(val, "|") - if len(valParts) != 2 { - return false, nil - } - - expNano, err := strconv.ParseInt(valParts[0], 10, 64) - if err != nil { - return false, nil - } - tokenScope := valParts[1] - - if expectedScope != "" && tokenScope != expectedScope { - return false, nil - } - - if time.Now().UnixNano() > expNano { - return false, nil // Expired - } - - return true, nil -} diff --git a/openflare-server/internal/utils/cap/middleware.go b/openflare-server/internal/utils/cap/middleware.go deleted file mode 100644 index 6544c137..00000000 --- a/openflare-server/internal/utils/cap/middleware.go +++ /dev/null @@ -1,40 +0,0 @@ -package cap - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header. -// enabledFunc is an optional callback allowing dynamic check of whether captcha protection is turned on. -func (m *Manager) VerifyMiddleware(scope string, enabledFunc func() bool) gin.HandlerFunc { - return func(c *gin.Context) { - if enabledFunc != nil && !enabledFunc() { - c.Next() - return - } - - token := c.GetHeader("X-Cap-Token") - if token == "" { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": "验证码验证失败,缺少验证码凭证", - }) - c.Abort() - return - } - - valid, err := m.VerifyToken(c.Request.Context(), token, scope) - if err != nil || !valid { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": "验证码校验失败或已过期,请重试", - }) - c.Abort() - return - } - - c.Next() - } -} diff --git a/openflare-server/internal/utils/cap/prng.go b/openflare-server/internal/utils/cap/prng.go deleted file mode 100644 index f85bbd4e..00000000 --- a/openflare-server/internal/utils/cap/prng.go +++ /dev/null @@ -1,45 +0,0 @@ -package cap - -import ( - "fmt" - "strings" -) - -// fnv1a returns the 32-bit FNV-1a hash of a string -func fnv1a(str string) uint32 { - var hash uint32 = 2166136261 - for i := 0; i < len(str); i++ { - hash ^= uint32(str[i]) - hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24) - } - return hash -} - -// fnv1aResume resumes FNV-1a hashing from a given state -func fnv1aResume(state uint32, str string) uint32 { - h := state - for i := 0; i < len(str); i++ { - h ^= uint32(str[i]) - h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24) - } - return h -} - -// prng generates a hex string of specified length using a seed -func prng(seed string, length int) string { - return prngFromHash(fnv1a(seed), length) -} - -// prngFromHash generates a hex string of specified length using an initial hash state -func prngFromHash(initialHash uint32, length int) string { - state := initialHash - var result strings.Builder - for result.Len() < length { - state ^= state << 13 - state ^= state >> 17 - state ^= state << 5 - hexStr := fmt.Sprintf("%08x", state) - result.WriteString(hexStr) - } - return result.String()[:length] -} diff --git a/openflare-server/internal/utils/cap/store.go b/openflare-server/internal/utils/cap/store.go deleted file mode 100644 index f77f42dc..00000000 --- a/openflare-server/internal/utils/cap/store.go +++ /dev/null @@ -1,90 +0,0 @@ -package cap - -import ( - "context" - "sync" - "time" -) - -// Store defines the storage interface for challenge nonces and verification tokens -type Store interface { - Get(ctx context.Context, key string) (string, bool, error) - Set(ctx context.Context, key string, val string, ttl time.Duration) error - Delete(ctx context.Context, key string) error -} - -type memoryItem struct { - value string - expiresAt time.Time -} - -// MemoryStore is a thread-safe in-memory implementation of Store -type MemoryStore struct { - items map[string]memoryItem - mu sync.RWMutex -} - -// NewMemoryStore creates and initializes a new MemoryStore -func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore { - store := &MemoryStore{ - items: make(map[string]memoryItem), - } - if cleanupInterval > 0 { - go store.startCleanupLoop(cleanupInterval) - } - return store -} - -func (s *MemoryStore) Get(ctx context.Context, key string) (string, bool, error) { - s.mu.RLock() - item, found := s.items[key] - s.mu.RUnlock() - - if !found { - return "", false, nil - } - - if time.Now().After(item.expiresAt) { - s.mu.Lock() - delete(s.items, key) - s.mu.Unlock() - return "", false, nil - } - - return item.value, true, nil -} - -func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error { - s.mu.Lock() - defer s.mu.Unlock() - s.items[key] = memoryItem{ - value: val, - expiresAt: time.Now().Add(ttl), - } - return nil -} - -func (s *MemoryStore) Delete(ctx context.Context, key string) error { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.items, key) - return nil -} - -func (s *MemoryStore) startCleanupLoop(interval time.Duration) { - ticker := time.NewTicker(interval) - for range ticker.C { - s.cleanupExpired() - } -} - -func (s *MemoryStore) cleanupExpired() { - now := time.Now() - s.mu.Lock() - defer s.mu.Unlock() - for k, v := range s.items { - if now.After(v.expiresAt) { - delete(s.items, k) - } - } -} diff --git a/openflare-server/internal/utils/embedfs/static.go b/openflare-server/internal/utils/embedfs/static.go deleted file mode 100644 index 2ac93481..00000000 --- a/openflare-server/internal/utils/embedfs/static.go +++ /dev/null @@ -1,40 +0,0 @@ -package embedfs - -import ( - "embed" - "io/fs" - "net/http" - "strings" - - "github.com/gin-contrib/static" -) - -// Credit: https://github.com/gin-contrib/static/issues/19 - -type fileSystem struct { - http.FileSystem -} - -func (e fileSystem) Exists(prefix string, path string) bool { - cleanPath := strings.TrimPrefix(path, prefix) - cleanPath = strings.TrimPrefix(cleanPath, "/") - if cleanPath == "" { - return false - } - - _, err := e.Open(cleanPath) - if err != nil { - return false - } - return true -} - -func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem { - efs, err := fs.Sub(fsEmbed, targetPath) - if err != nil { - panic(err) - } - return fileSystem{ - FileSystem: http.FS(efs), - } -} diff --git a/openflare-server/internal/utils/mail/email.go b/openflare-server/internal/utils/mail/email.go deleted file mode 100644 index 8ae0dc5a..00000000 --- a/openflare-server/internal/utils/mail/email.go +++ /dev/null @@ -1,74 +0,0 @@ -package mail - -import ( - "crypto/tls" - "encoding/base64" - "fmt" - "net/smtp" - "strings" -) - -// SMTPConfig holds all the configuration parameters required to send an email. -type SMTPConfig struct { - Server string - Port int - Account string - Token string - SystemName string -} - -// SendEmail sends an HTML email to the receiver using the provided SMTP configuration. -func SendEmail(config SMTPConfig, subject string, receiver string, content string) error { - encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject))) - mail := []byte(fmt.Sprintf("To: %s\r\n"+ - "From: %s<%s>\r\n"+ - "Subject: %s\r\n"+ - "Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n", - receiver, config.SystemName, config.Account, encodedSubject, content)) - auth := smtp.PlainAuth("", config.Account, config.Token, config.Server) - addr := fmt.Sprintf("%s:%d", config.Server, config.Port) - to := strings.Split(receiver, ";") - var err error - if config.Port == 465 { - tlsConfig := &tls.Config{ - InsecureSkipVerify: true, - ServerName: config.Server, - } - conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", config.Server, config.Port), tlsConfig) - if err != nil { - return err - } - client, err := smtp.NewClient(conn, config.Server) - if err != nil { - return err - } - defer client.Close() - if err = client.Auth(auth); err != nil { - return err - } - if err = client.Mail(config.Account); err != nil { - return err - } - receiverEmails := strings.Split(receiver, ";") - for _, r := range receiverEmails { - if err = client.Rcpt(r); err != nil { - return err - } - } - w, err := client.Data() - if err != nil { - return err - } - _, err = w.Write(mail) - if err != nil { - return err - } - err = w.Close() - if err != nil { - return err - } - } else { - err = smtp.SendMail(addr, auth, config.Account, to, mail) - } - return err -} diff --git a/openflare-server/internal/utils/ratelimit/in_memory.go b/openflare-server/internal/utils/ratelimit/in_memory.go deleted file mode 100644 index a6334757..00000000 --- a/openflare-server/internal/utils/ratelimit/in_memory.go +++ /dev/null @@ -1,67 +0,0 @@ -package ratelimit - -import ( - "sync" - "time" -) - -type InMemoryRateLimiter struct { - store map[string]*[]int64 - mutex sync.Mutex - expirationDuration time.Duration -} - -func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) { - if l.store == nil { - l.mutex.Lock() - if l.store == nil { - l.store = make(map[string]*[]int64) - l.expirationDuration = expirationDuration - if expirationDuration > 0 { - go l.clearExpiredItems() - } - } - l.mutex.Unlock() - } -} - -func (l *InMemoryRateLimiter) clearExpiredItems() { - for { - time.Sleep(l.expirationDuration) - l.mutex.Lock() - now := time.Now().Unix() - for key := range l.store { - queue := l.store[key] - size := len(*queue) - if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) { - delete(l.store, key) - } - } - l.mutex.Unlock() - } -} - -// Request parameter duration's unit is seconds -func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool { - l.mutex.Lock() - defer l.mutex.Unlock() - // [old <-- new] - queue, ok := l.store[key] - now := time.Now().Unix() - if ok { - if len(*queue) < maxRequestNum { - *queue = append(*queue, now) - return true - } - if now-(*queue)[0] >= duration { - *queue = (*queue)[1:] - *queue = append(*queue, now) - return true - } - return false - } - s := make([]int64, 0, maxRequestNum) - l.store[key] = &s - *(l.store[key]) = append(*(l.store[key]), now) - return true -} diff --git a/openflare-server/internal/utils/security/password.go b/openflare-server/internal/utils/security/password.go deleted file mode 100644 index 85cff019..00000000 --- a/openflare-server/internal/utils/security/password.go +++ /dev/null @@ -1,14 +0,0 @@ -package security - -import "golang.org/x/crypto/bcrypt" - -func Password2Hash(password string) (string, error) { - passwordBytes := []byte(password) - hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) - return string(hashedPassword), err -} - -func ValidatePasswordAndHash(password string, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} diff --git a/openflare-server/internal/utils/security/random.go b/openflare-server/internal/utils/security/random.go deleted file mode 100644 index ade3d58b..00000000 --- a/openflare-server/internal/utils/security/random.go +++ /dev/null @@ -1,37 +0,0 @@ -package security - -import "crypto/rand" - -func GenerateRandomString(length int) string { - if length <= 0 { - return "" - } - const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - const n = byte(len(charset)) - const threshold = byte(256 - (256 % len(charset))) - - out := make([]byte, 0, length) - buf := make([]byte, length) - for len(out) < length { - if _, err := rand.Read(buf); err != nil { - return "" - } - for _, b := range buf { - if b < threshold { - out = append(out, charset[int(b%n)]) - if len(out) == length { - break - } - } - } - } - return string(out) -} - -func GeneratePassword() string { - return GenerateRandomString(12) -} - -func GenerateToken() string { - return GenerateRandomString(22) -} diff --git a/openflare-server/internal/utils/security/verification.go b/openflare-server/internal/utils/security/verification.go deleted file mode 100644 index e5eed22e..00000000 --- a/openflare-server/internal/utils/security/verification.go +++ /dev/null @@ -1,78 +0,0 @@ -package security - -import ( - "strings" - "sync" - "time" - - "github.com/google/uuid" -) - -type verificationValue struct { - code string - time time.Time -} - -const ( - EmailVerificationPurpose = "v" - PasswordResetPurpose = "r" -) - -var verificationMutex sync.Mutex -var verificationMap map[string]verificationValue -var verificationMapMaxSize = 10 -var VerificationValidMinutes = 10 - -func GenerateVerificationCode(length int) string { - code := uuid.New().String() - code = strings.Replace(code, "-", "", -1) - if length == 0 { - return code - } - return code[:length] -} - -func RegisterVerificationCodeWithKey(key string, code string, purpose string) { - verificationMutex.Lock() - defer verificationMutex.Unlock() - verificationMap[purpose+key] = verificationValue{ - code: code, - time: time.Now(), - } - if len(verificationMap) > verificationMapMaxSize { - removeExpiredPairs() - } -} - -func VerifyCodeWithKey(key string, code string, purpose string) bool { - verificationMutex.Lock() - defer verificationMutex.Unlock() - value, okay := verificationMap[purpose+key] - now := time.Now() - if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 { - return false - } - return code == value.code -} - -func DeleteKey(key string, purpose string) { - verificationMutex.Lock() - defer verificationMutex.Unlock() - delete(verificationMap, purpose+key) -} - -// no lock inside, so the caller must lock the verificationMap before calling! -func removeExpiredPairs() { - now := time.Now() - for key := range verificationMap { - if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 { - delete(verificationMap, key) - } - } -} - -func init() { - verificationMutex.Lock() - defer verificationMutex.Unlock() - verificationMap = make(map[string]verificationValue) -} diff --git a/openflare-server/internal/utils/uptimekuma/client.go b/openflare-server/internal/utils/uptimekuma/client.go deleted file mode 100644 index b591f3ad..00000000 --- a/openflare-server/internal/utils/uptimekuma/client.go +++ /dev/null @@ -1,373 +0,0 @@ -package uptimekuma - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "strconv" - "strings" - "sync" - "time" -) - -type UptimeKumaMonitor struct { - ID int `json:"id"` - Name string `json:"name"` - Url string `json:"url"` - Type string `json:"type"` - Interval int `json:"interval"` - MaxRetries int `json:"maxretries"` - RetryInterval int `json:"retryInterval"` - Timeout int `json:"timeout"` - Tags []UptimeKumaTag `json:"tags"` -} - -type UptimeKumaTag struct { - ID int `json:"tag_id"` - Name string `json:"name"` - Color string `json:"color"` -} - -type UptimeKumaTagItem struct { - ID int `json:"id"` - Name string `json:"name"` - Color string `json:"color"` -} - -type SocketIOClient struct { - baseURL string - httpClient *http.Client - sid string - ackMutex sync.Mutex - ackID int - ackChanMap map[int]chan string - doneChan chan struct{} - closeOnce sync.Once - - monitorListMutex sync.RWMutex - monitorList map[string]UptimeKumaMonitor - monitorListChan chan struct{} - monitorListOnce sync.Once - - ctx context.Context - cancel context.CancelFunc - - err error -} - -func NewSocketIOClient(baseURL string) *SocketIOClient { - ctx, cancel := context.WithCancel(context.Background()) - return &SocketIOClient{ - baseURL: strings.TrimSuffix(baseURL, "/"), - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, - ackChanMap: make(map[int]chan string), - doneChan: make(chan struct{}), - monitorListChan: make(chan struct{}), - monitorList: make(map[string]UptimeKumaMonitor), - ctx: ctx, - cancel: cancel, - } -} - -func (c *SocketIOClient) Connect() error { - slog.Debug("Uptime Kuma client starting handshake", "baseURL", c.baseURL) - // 1. Handshake - u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling", c.baseURL) - reqHandshake, err := http.NewRequestWithContext(c.ctx, "GET", u, nil) - if err != nil { - return fmt.Errorf("create handshake request failed: %w", err) - } - resp, err := c.httpClient.Do(reqHandshake) - if err != nil { - slog.Error("Uptime Kuma handshake connection failed", "url", u, "error", err) - return fmt.Errorf("handshake request failed: %w", err) - } - defer resp.Body.Close() - - bs, err := io.ReadAll(resp.Body) - if err != nil { - slog.Error("Failed to read Uptime Kuma handshake response body", "error", err) - return fmt.Errorf("read handshake body failed: %w", err) - } - - bodyStr := string(bs) - slog.Debug("Received handshake response from Uptime Kuma", "body", bodyStr) - if len(bodyStr) == 0 || bodyStr[0] != '0' { - return fmt.Errorf("invalid handshake response format: %s", bodyStr) - } - - var hs struct { - Sid string `json:"sid"` - } - if err := json.Unmarshal([]byte(bodyStr[1:]), &hs); err != nil { - return fmt.Errorf("unmarshal handshake sid failed: %w", err) - } - c.sid = hs.Sid - slog.Debug("Uptime Kuma handshake success", "sid", c.sid) - - // 2. Namespace Connect - slog.Debug("Sending namespace connect request to Uptime Kuma", "sid", c.sid) - connectURL := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid) - req, err := http.NewRequestWithContext(c.ctx, "POST", connectURL, strings.NewReader("40")) - if err != nil { - return fmt.Errorf("create connect request failed: %w", err) - } - req.Header.Set("Content-Type", "text/plain;charset=UTF-8") - respConnect, err := c.httpClient.Do(req) - if err != nil { - slog.Error("Uptime Kuma namespace connect request failed", "sid", c.sid, "error", err) - return fmt.Errorf("namespace connect failed: %w", err) - } - respConnect.Body.Close() - slog.Debug("Namespace connected successfully to Uptime Kuma", "sid", c.sid) - - // 3. Start Polling Loop - go c.pollLoop() - - return nil -} - -func (c *SocketIOClient) pollLoop() { - slog.Debug("Uptime Kuma polling loop started", "sid", c.sid) - defer c.Close() - for { - select { - case <-c.doneChan: - slog.Debug("Uptime Kuma polling loop stopped (doneChan closed)", "sid", c.sid) - return - default: - } - - u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid) - reqPoll, err := http.NewRequestWithContext(c.ctx, "GET", u, nil) - if err != nil { - slog.Error("Failed to create Uptime Kuma polling request", "sid", c.sid, "error", err) - c.err = err - return - } - resp, err := c.httpClient.Do(reqPoll) - if err != nil { - slog.Error("Uptime Kuma polling request failed", "sid", c.sid, "error", err) - c.err = err - return - } - - bs, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - slog.Error("Failed to read Uptime Kuma polling body", "sid", c.sid, "error", err) - c.err = err - return - } - - bodyStr := string(bs) - if len(bodyStr) == 0 { - continue - } - - slog.Debug("Received polling payload from Uptime Kuma", "length", len(bodyStr)) - packets := strings.Split(bodyStr, "\x1e") - for _, pkt := range packets { - if len(pkt) == 0 { - continue - } - engineIOType := pkt[0] - payload := pkt[1:] - - slog.Debug("Parsing engine.io packet", "type", string(engineIOType), "payload_len", len(payload)) - switch engineIOType { - case '2': // Ping - slog.Debug("Received engine.io ping, responding with pong", "sid", c.sid) - c.sendPong() - case '4': // Message - if len(payload) == 0 { - continue - } - socketIOType := payload[0] - socketIOPayload := payload[1:] - - slog.Debug("Parsing socket.io packet", "type", string(socketIOType), "payload", socketIOPayload) - switch socketIOType { - case '2': // Event - c.handleEvent(socketIOPayload) - case '3': // Ack - c.handleAck(socketIOPayload) - } - } - } - } -} - -func (c *SocketIOClient) sendPong() { - u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid) - req, err := http.NewRequestWithContext(c.ctx, "POST", u, strings.NewReader("3")) - if err != nil { - return - } - req.Header.Set("Content-Type", "text/plain;charset=UTF-8") - resp, err := c.httpClient.Do(req) - if err == nil { - resp.Body.Close() - } -} - -func (c *SocketIOClient) handleEvent(payload string) { - var arr []json.RawMessage - if err := json.Unmarshal([]byte(payload), &arr); err != nil || len(arr) < 2 { - return - } - var eventName string - if err := json.Unmarshal(arr[0], &eventName); err != nil { - return - } - if eventName == "monitorList" { - var list map[string]UptimeKumaMonitor - if err := json.Unmarshal(arr[1], &list); err == nil { - c.monitorListMutex.Lock() - c.monitorList = list - c.monitorListMutex.Unlock() - c.monitorListOnce.Do(func() { - close(c.monitorListChan) - }) - } - } -} - -func (c *SocketIOClient) handleAck(payload string) { - idx := strings.IndexByte(payload, '[') - if idx == -1 { - return - } - ackIDStr := payload[:idx] - ackID, err := strconv.Atoi(ackIDStr) - if err != nil { - return - } - c.ackMutex.Lock() - ch, ok := c.ackChanMap[ackID] - if ok { - delete(c.ackChanMap, ackID) - c.ackMutex.Unlock() - select { - case ch <- payload[idx:]: - default: - } - } else { - c.ackMutex.Unlock() - } -} - -func (c *SocketIOClient) Emit(event string, args ...any) (string, error) { - c.ackMutex.Lock() - id := c.ackID - c.ackID++ - ch := make(chan string, 1) - c.ackChanMap[id] = ch - c.ackMutex.Unlock() - - payloadArr := []any{event} - payloadArr = append(payloadArr, args...) - bs, err := json.Marshal(payloadArr) - if err != nil { - c.ackMutex.Lock() - delete(c.ackChanMap, id) - c.ackMutex.Unlock() - slog.Error("Failed to marshal event payload", "event", event, "error", err) - return "", err - } - - body := fmt.Sprintf("42%d%s", id, string(bs)) - slog.Debug("Emitting Socket.IO event", "event", event, "ackID", id, "payload", string(bs)) - - u := fmt.Sprintf("%s/socket.io/?EIO=4&transport=polling&sid=%s", c.baseURL, c.sid) - req, err := http.NewRequestWithContext(c.ctx, "POST", u, strings.NewReader(body)) - if err != nil { - c.ackMutex.Lock() - delete(c.ackChanMap, id) - c.ackMutex.Unlock() - return "", err - } - req.Header.Set("Content-Type", "text/plain;charset=UTF-8") - - resp, err := c.httpClient.Do(req) - if err != nil { - c.ackMutex.Lock() - delete(c.ackChanMap, id) - c.ackMutex.Unlock() - slog.Error("Failed to send Emit request", "event", event, "ackID", id, "error", err) - return "", err - } - resp.Body.Close() - - select { - case result := <-ch: - slog.Debug("Received Ack for event", "event", event, "ackID", id, "response", result) - return result, nil - case <-time.After(10 * time.Second): - c.ackMutex.Lock() - delete(c.ackChanMap, id) - c.ackMutex.Unlock() - slog.Error("Timeout waiting for event Ack", "event", event, "ackID", id) - return "", fmt.Errorf("timeout waiting for ack for event: %s", event) - case <-c.doneChan: - c.ackMutex.Lock() - delete(c.ackChanMap, id) - c.ackMutex.Unlock() - slog.Error("Client closed while waiting for event Ack", "event", event, "ackID", id) - return "", fmt.Errorf("client closed while waiting for event ack: %s", event) - } -} - -func (c *SocketIOClient) Close() { - c.closeOnce.Do(func() { - c.cancel() - close(c.doneChan) - }) -} - -func (c *SocketIOClient) GetMonitorListChan() <-chan struct{} { - return c.monitorListChan -} - -func (c *SocketIOClient) GetMonitorList() map[string]UptimeKumaMonitor { - c.monitorListMutex.RLock() - defer c.monitorListMutex.RUnlock() - - // Return a copy to prevent concurrent map read/write access - m := make(map[string]UptimeKumaMonitor, len(c.monitorList)) - for k, v := range c.monitorList { - m[k] = v - } - return m -} - -func ParseAckResponse(response string, target any) error { - var arr []json.RawMessage - if err := json.Unmarshal([]byte(response), &arr); err != nil || len(arr) == 0 { - return fmt.Errorf("invalid ack response format: %s", response) - } - - var status struct { - Ok bool `json:"ok"` - Msg string `json:"msg"` - } - if err := json.Unmarshal(arr[0], &status); err == nil { - if !status.Ok { - errMsg := status.Msg - if errMsg == "" { - errMsg = "unknown error from Uptime Kuma" - } - return fmt.Errorf("Uptime Kuma error response: %s", errMsg) - } - } - - if target != nil { - return json.Unmarshal(arr[0], target) - } - return nil -} diff --git a/openflare-server/internal/utils/validation/validator.go b/openflare-server/internal/utils/validation/validator.go deleted file mode 100644 index 3d08ff1c..00000000 --- a/openflare-server/internal/utils/validation/validator.go +++ /dev/null @@ -1,9 +0,0 @@ -package validation - -import "github.com/go-playground/validator/v10" - -var Validate *validator.Validate - -func init() { - Validate = validator.New() -} diff --git a/openflare-server/main.go b/openflare-server/main.go index 71fe6c85..deddacd5 100644 --- a/openflare-server/main.go +++ b/openflare-server/main.go @@ -1,124 +1,22 @@ -package server +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 -import ( - "context" - "embed" - "fmt" - "log/slog" - "os" - "strconv" +// Package main 是 OpenFlare 平台的程序入口 +package main - _ "github.com/rain-kl/openflare/openflare-server/docs" - "github.com/rain-kl/openflare/openflare-server/internal/common" - "github.com/rain-kl/openflare/openflare-server/internal/job" - "github.com/rain-kl/openflare/openflare-server/internal/middleware" - "github.com/rain-kl/openflare/openflare-server/internal/model" - "github.com/rain-kl/openflare/openflare-server/internal/router" - "github.com/rain-kl/openflare/openflare-server/internal/service" - "github.com/rain-kl/openflare/pkg/geoip" +import "github.com/Rain-kl/Wavelet/internal/cmd" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-contrib/sessions/redis" - "github.com/gin-gonic/gin" -) - -//go:embed all:web/build -var buildFS embed.FS - -//go:embed web/build/index.html -var indexPage []byte - -// @title OpenFlare Server API -// @version 3.0 -// @description OpenFlare Server 管理端与 Agent API 文档。 +// @title OpenFlare API +// @version 1.0.0 +// @description OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。 +// @contact.name OpenFlare +// @contact.url https://github.com/Rain-kl/OpenFlare +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html // @BasePath / -// @schemes http https -// @securityDefinitions.apikey OpenFlareTokenAuth -// @in header -// @name OpenFlare-Token -// @description 管理端 API 使用登录后返回的用户 Token -// @securityDefinitions.apikey AccessTokenAuth -// @in header -// @name X-Agent-Token -// @description Agent API 使用节点专属 Agent Token 或全局 Discovery Token -func Run() { - common.ParseFlags() - common.SetupGinLog() - slog.Info("OpenFlare started", "version", common.Version) - if os.Getenv("GIN_MODE") != "debug" { - gin.SetMode(gin.ReleaseMode) - } - // Initialize SQL Database - defer service.ShutdownWSHubs() - - err := model.InitDB() - if err != nil { - slog.Error("initialize database failed", "error", err) - os.Exit(1) - } - defer func() { - err := model.CloseDB() - if err != nil { - slog.Error("close database failed", "error", err) - os.Exit(1) - } - }() - - // Initialize Redis - err = common.InitRedisClient() - if err != nil { - slog.Error("initialize redis failed", "error", err) - os.Exit(1) - } - - // Initialize options - model.InitOptionMap() - service.InitCap() - middleware.InitJWTMiddleware() - geoip.InitGeoIP(common.GeoIPProvider) - backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background()) - defer cancelBackgroundTasks() - service.StartDatabaseAutoCleanupScheduler(backgroundCtx) - - job.InitCronJobs() - defer job.StopCronJobs() - - // Initialize HTTP server - server := gin.Default() - //server.Use(gzip.Gzip(gzip.DefaultCompression)) - server.Use(middleware.CORS()) - - // Initialize session store - if common.RedisEnabled { - opt := common.ParseRedisOption() - store, _ := redis.NewStore(opt.MinIdleConns, opt.Network, opt.Addr, opt.Password, []byte(common.SessionSecret)) - server.Use(sessions.Sessions("session", store)) - } else { - store := cookie.NewStore([]byte(common.SessionSecret)) - server.Use(sessions.Sessions("session", store)) - } - - router.SetRouter(server, buildFS, indexPage) - var port = os.Getenv("PORT") - if port == "" { - port = strconv.Itoa(*common.Port) - } - dbBackend := "sqlite" - if common.SQLDSN != "" { - dbBackend = "postgres" - } - slog.Info("server config", "port", port, "gin_mode", gin.Mode(), "log_level", common.GetLogLevel(), "db_backend", dbBackend, "sqlite_path", common.SQLitePath, "redis_enabled", common.RedisEnabled, "log_dir", valueOrDefault(*common.LogDir, "stdout"), "access_token_configured", common.AccessToken != "", "node_offline_threshold", common.NodeOfflineThreshold) - slog.Info("server listening", "address", fmt.Sprintf(":%s", port)) - err = server.Run(":" + port) - if err != nil { - slog.Error("server run failed", "error", err) - } -} - -func valueOrDefault(value string, fallback string) string { - if value == "" { - return fallback - } - return value +// @securityDefinitions.apikey SessionCookie +// @in cookie +// @name session +func main() { + cmd.Execute() } diff --git a/Wavelet/pkg/cache/disk/cache.go b/openflare-server/pkg/cache/disk/cache.go similarity index 100% rename from Wavelet/pkg/cache/disk/cache.go rename to openflare-server/pkg/cache/disk/cache.go diff --git a/Wavelet/pkg/cache/disk/cache_test.go b/openflare-server/pkg/cache/disk/cache_test.go similarity index 100% rename from Wavelet/pkg/cache/disk/cache_test.go rename to openflare-server/pkg/cache/disk/cache_test.go diff --git a/Wavelet/pkg/cache/ram/cache.go b/openflare-server/pkg/cache/ram/cache.go similarity index 100% rename from Wavelet/pkg/cache/ram/cache.go rename to openflare-server/pkg/cache/ram/cache.go diff --git a/Wavelet/pkg/cache/ram/cache_test.go b/openflare-server/pkg/cache/ram/cache_test.go similarity index 100% rename from Wavelet/pkg/cache/ram/cache_test.go rename to openflare-server/pkg/cache/ram/cache_test.go diff --git a/Wavelet/pkg/cap/cap.go b/openflare-server/pkg/cap/cap.go similarity index 100% rename from Wavelet/pkg/cap/cap.go rename to openflare-server/pkg/cap/cap.go diff --git a/Wavelet/pkg/cap/errs.go b/openflare-server/pkg/cap/errs.go similarity index 100% rename from Wavelet/pkg/cap/errs.go rename to openflare-server/pkg/cap/errs.go diff --git a/Wavelet/pkg/cap/prng.go b/openflare-server/pkg/cap/prng.go similarity index 100% rename from Wavelet/pkg/cap/prng.go rename to openflare-server/pkg/cap/prng.go diff --git a/Wavelet/pkg/cap/store.go b/openflare-server/pkg/cap/store.go similarity index 100% rename from Wavelet/pkg/cap/store.go rename to openflare-server/pkg/cap/store.go diff --git a/Wavelet/pkg/httppool/httppool.go b/openflare-server/pkg/httppool/httppool.go similarity index 100% rename from Wavelet/pkg/httppool/httppool.go rename to openflare-server/pkg/httppool/httppool.go diff --git a/Wavelet/pkg/httppool/httppool_test.go b/openflare-server/pkg/httppool/httppool_test.go similarity index 100% rename from Wavelet/pkg/httppool/httppool_test.go rename to openflare-server/pkg/httppool/httppool_test.go diff --git a/Wavelet/pkg/logger/errs.go b/openflare-server/pkg/logger/errs.go similarity index 100% rename from Wavelet/pkg/logger/errs.go rename to openflare-server/pkg/logger/errs.go diff --git a/Wavelet/pkg/logger/logger.go b/openflare-server/pkg/logger/logger.go similarity index 100% rename from Wavelet/pkg/logger/logger.go rename to openflare-server/pkg/logger/logger.go diff --git a/Wavelet/pkg/logger/ringbuffer.go b/openflare-server/pkg/logger/ringbuffer.go similarity index 100% rename from Wavelet/pkg/logger/ringbuffer.go rename to openflare-server/pkg/logger/ringbuffer.go diff --git a/Wavelet/pkg/logger/ringbuffer_test.go b/openflare-server/pkg/logger/ringbuffer_test.go similarity index 100% rename from Wavelet/pkg/logger/ringbuffer_test.go rename to openflare-server/pkg/logger/ringbuffer_test.go diff --git a/Wavelet/pkg/logger/utils.go b/openflare-server/pkg/logger/utils.go similarity index 100% rename from Wavelet/pkg/logger/utils.go rename to openflare-server/pkg/logger/utils.go diff --git a/Wavelet/pkg/mail/errs.go b/openflare-server/pkg/mail/errs.go similarity index 100% rename from Wavelet/pkg/mail/errs.go rename to openflare-server/pkg/mail/errs.go diff --git a/Wavelet/pkg/mail/mail.go b/openflare-server/pkg/mail/mail.go similarity index 100% rename from Wavelet/pkg/mail/mail.go rename to openflare-server/pkg/mail/mail.go diff --git a/Wavelet/pkg/mail/mail_test.go b/openflare-server/pkg/mail/mail_test.go similarity index 100% rename from Wavelet/pkg/mail/mail_test.go rename to openflare-server/pkg/mail/mail_test.go diff --git a/Wavelet/pkg/push/custom.go b/openflare-server/pkg/push/custom.go similarity index 100% rename from Wavelet/pkg/push/custom.go rename to openflare-server/pkg/push/custom.go diff --git a/Wavelet/pkg/push/email.go b/openflare-server/pkg/push/email.go similarity index 100% rename from Wavelet/pkg/push/email.go rename to openflare-server/pkg/push/email.go diff --git a/Wavelet/pkg/push/lark.go b/openflare-server/pkg/push/lark.go similarity index 100% rename from Wavelet/pkg/push/lark.go rename to openflare-server/pkg/push/lark.go diff --git a/Wavelet/pkg/push/push.go b/openflare-server/pkg/push/push.go similarity index 100% rename from Wavelet/pkg/push/push.go rename to openflare-server/pkg/push/push.go diff --git a/Wavelet/pkg/push/telegram.go b/openflare-server/pkg/push/telegram.go similarity index 100% rename from Wavelet/pkg/push/telegram.go rename to openflare-server/pkg/push/telegram.go diff --git a/Wavelet/pkg/push/telegram_test.go b/openflare-server/pkg/push/telegram_test.go similarity index 100% rename from Wavelet/pkg/push/telegram_test.go rename to openflare-server/pkg/push/telegram_test.go diff --git a/Wavelet/pkg/push/template.go b/openflare-server/pkg/push/template.go similarity index 100% rename from Wavelet/pkg/push/template.go rename to openflare-server/pkg/push/template.go diff --git a/Wavelet/pkg/push/template_test.go b/openflare-server/pkg/push/template_test.go similarity index 100% rename from Wavelet/pkg/push/template_test.go rename to openflare-server/pkg/push/template_test.go diff --git a/Wavelet/pkg/trace/propagator.go b/openflare-server/pkg/trace/propagator.go similarity index 100% rename from Wavelet/pkg/trace/propagator.go rename to openflare-server/pkg/trace/propagator.go diff --git a/Wavelet/pkg/trace/sampler.go b/openflare-server/pkg/trace/sampler.go similarity index 100% rename from Wavelet/pkg/trace/sampler.go rename to openflare-server/pkg/trace/sampler.go diff --git a/Wavelet/pkg/trace/trace.go b/openflare-server/pkg/trace/trace.go similarity index 100% rename from Wavelet/pkg/trace/trace.go rename to openflare-server/pkg/trace/trace.go diff --git a/Wavelet/pkg/trace/trace_provider.go b/openflare-server/pkg/trace/trace_provider.go similarity index 100% rename from Wavelet/pkg/trace/trace_provider.go rename to openflare-server/pkg/trace/trace_provider.go diff --git a/Wavelet/pkg/util/crypto.go b/openflare-server/pkg/util/crypto.go similarity index 100% rename from Wavelet/pkg/util/crypto.go rename to openflare-server/pkg/util/crypto.go diff --git a/Wavelet/pkg/util/password.go b/openflare-server/pkg/util/password.go similarity index 100% rename from Wavelet/pkg/util/password.go rename to openflare-server/pkg/util/password.go diff --git a/Wavelet/pkg/util/strings.go b/openflare-server/pkg/util/strings.go similarity index 100% rename from Wavelet/pkg/util/strings.go rename to openflare-server/pkg/util/strings.go diff --git a/Wavelet/pkg/util/uuid.go b/openflare-server/pkg/util/uuid.go similarity index 100% rename from Wavelet/pkg/util/uuid.go rename to openflare-server/pkg/util/uuid.go diff --git a/Wavelet/scripts/swagger.sh b/openflare-server/scripts/swagger.sh similarity index 100% rename from Wavelet/scripts/swagger.sh rename to openflare-server/scripts/swagger.sh diff --git a/Wavelet/scripts/tidy.sh b/openflare-server/scripts/tidy.sh similarity index 100% rename from Wavelet/scripts/tidy.sh rename to openflare-server/scripts/tidy.sh diff --git a/Wavelet/scripts/translate_commit.py b/openflare-server/scripts/translate_commit.py similarity index 100% rename from Wavelet/scripts/translate_commit.py rename to openflare-server/scripts/translate_commit.py diff --git a/Wavelet/scripts/update_go_license.sh b/openflare-server/scripts/update_go_license.sh similarity index 100% rename from Wavelet/scripts/update_go_license.sh rename to openflare-server/scripts/update_go_license.sh diff --git a/Wavelet/support-files/sql/create_clickhouse_risk.sql b/openflare-server/support-files/sql/create_clickhouse_risk.sql similarity index 100% rename from Wavelet/support-files/sql/create_clickhouse_risk.sql rename to openflare-server/support-files/sql/create_clickhouse_risk.sql diff --git a/openflare-server/web/.eslintrc.json b/openflare-server/web/.eslintrc.json deleted file mode 100644 index 5cce9572..00000000 --- a/openflare-server/web/.eslintrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"], - "ignorePatterns": [ - ".next", - "build", - "out", - "coverage", - "playwright-report", - "test-results", - "src" - ] -} diff --git a/openflare-server/web/.gitignore b/openflare-server/web/.gitignore deleted file mode 100644 index 74841763..00000000 --- a/openflare-server/web/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules - -# build outputs -/.next -/out -/build -/coverage -/playwright-report -/test-results - -# local env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# misc -.DS_Store -.idea -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -package-lock.json -yarn.lock - -*.db -/tsconfig.tsbuildinfo diff --git a/openflare-server/web/README.md b/openflare-server/web/README.md deleted file mode 100644 index 5b3eb455..00000000 --- a/openflare-server/web/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# OpenFlare Web - -OpenFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。 - -## 常用命令 - -```shell -corepack enable -pnpm install - -# 本地开发(3001 前端同源代理后端 HTTP/WS) -pnpm dev - -# 生产模式启动 -pnpm start - -# 类型检查 -pnpm typecheck - -# 代码检查 -pnpm lint - -# 单元测试 -pnpm test - -# 生成静态构建产物到 build/ -pnpm build -``` - -## 构建说明 - -- 构建采用 Next.js 静态导出模式。 -- `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。 -- 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。 -- 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。 -- `pnpm dev` 会以开发模式启动 `http://127.0.0.1:3001`,并通过同源代理把 `/api/*` 的 HTTP 与 WebSocket 请求统一转发到 `NEXT_DEV_BACKEND_URL`,默认是 `http://127.0.0.1:3000`。 -- `pnpm start` 用于启动已构建产物,对应 Next 生产模式。 - -## 开源协议 - -`openflare-server/web` 跟随仓库根目录协议发布,当前采用 Apache License 2.0。 - -## 目录约定 - -- `app/`:路由与布局 -- `features/`:业务模块 -- `components/`:复用组件 -- `lib/`:请求、环境变量、工具与常量 -- `tests/`:测试代码 diff --git a/openflare-server/web/app/(dashboard)/access-log/page.tsx b/openflare-server/web/app/(dashboard)/access-log/page.tsx deleted file mode 100644 index e019fc76..00000000 --- a/openflare-server/web/app/(dashboard)/access-log/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {AccessLogsPage} from '@/features/access-logs/components/access-logs-page'; - -export default function AccessLogsRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/apply-log/page.tsx b/openflare-server/web/app/(dashboard)/apply-log/page.tsx deleted file mode 100644 index acfe609b..00000000 --- a/openflare-server/web/app/(dashboard)/apply-log/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {ApplyLogsPage as ApplyLogsFeaturePage} from '@/features/apply-logs/components/apply-logs-page'; - -export default function ApplyLogsPage() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/config-version/page.tsx b/openflare-server/web/app/(dashboard)/config-version/page.tsx deleted file mode 100644 index 039b2f8e..00000000 --- a/openflare-server/web/app/(dashboard)/config-version/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {ConfigVersionsPage} from '@/features/config-versions/components/config-versions-page'; - -export default function ConfigVersionsRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/layout.tsx b/openflare-server/web/app/(dashboard)/layout.tsx deleted file mode 100644 index b5ee90ae..00000000 --- a/openflare-server/web/app/(dashboard)/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type {ReactNode} from 'react'; - -import {DashboardShell} from '@/components/layout/dashboard-shell'; -import {DashboardAuthGuard} from '@/features/auth/components/dashboard-auth-guard'; - -interface DashboardLayoutProps { - children: ReactNode; -} - -export default function DashboardLayout({ children }: DashboardLayoutProps) { - return ( - - {children} - - ); -} diff --git a/openflare-server/web/app/(dashboard)/node/detail/page.tsx b/openflare-server/web/app/(dashboard)/node/detail/page.tsx deleted file mode 100644 index b76c2f95..00000000 --- a/openflare-server/web/app/(dashboard)/node/detail/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -import {useSearchParams} from 'next/navigation'; - -import {NodeDetailPage} from '@/features/nodes/components/node-detail-page'; - -export default function NodeDetailRoute() { - const searchParams = useSearchParams(); - - return ; -} diff --git a/openflare-server/web/app/(dashboard)/node/page.tsx b/openflare-server/web/app/(dashboard)/node/page.tsx deleted file mode 100644 index 8e95bf51..00000000 --- a/openflare-server/web/app/(dashboard)/node/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {NodesPage} from '@/features/nodes/components/nodes-page'; - -export default function NodesRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/origin/detail/page.tsx b/openflare-server/web/app/(dashboard)/origin/detail/page.tsx deleted file mode 100644 index e89033f8..00000000 --- a/openflare-server/web/app/(dashboard)/origin/detail/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -import {useSearchParams} from 'next/navigation'; - -import {OriginDetailPage} from '@/features/origins/components/origin-detail-page'; - -export default function OriginDetailRoute() { - const searchParams = useSearchParams(); - - return ; -} diff --git a/openflare-server/web/app/(dashboard)/origin/page.tsx b/openflare-server/web/app/(dashboard)/origin/page.tsx deleted file mode 100644 index 4a207791..00000000 --- a/openflare-server/web/app/(dashboard)/origin/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {OriginsPage} from '@/features/origins/components/origins-page'; - -export default function OriginRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/page.tsx b/openflare-server/web/app/(dashboard)/page.tsx deleted file mode 100644 index 9ce6e790..00000000 --- a/openflare-server/web/app/(dashboard)/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import {DashboardOverview} from '@/features/dashboard/components/dashboard-overview'; - -export default function DashboardPage() { - return ( -
- -
- ); -} diff --git a/openflare-server/web/app/(dashboard)/pages/detail/page.tsx b/openflare-server/web/app/(dashboard)/pages/detail/page.tsx deleted file mode 100644 index ac4792a6..00000000 --- a/openflare-server/web/app/(dashboard)/pages/detail/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -import {useSearchParams} from 'next/navigation'; - -import {PagesProjectDetailPage} from '@/features/pages/components/pages-page'; - -export default function PagesProjectDetailRoute() { - const searchParams = useSearchParams(); - - return ; -} diff --git a/openflare-server/web/app/(dashboard)/pages/page.tsx b/openflare-server/web/app/(dashboard)/pages/page.tsx deleted file mode 100644 index ed659cb0..00000000 --- a/openflare-server/web/app/(dashboard)/pages/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {PagesPage} from '@/features/pages/components/pages-page'; - -export default function Page() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/performance/page.tsx b/openflare-server/web/app/(dashboard)/performance/page.tsx deleted file mode 100644 index 34e0ec38..00000000 --- a/openflare-server/web/app/(dashboard)/performance/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {PerformancePage} from '@/features/performance/components/performance-page'; - -export default function PerformanceRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/proxy-route/detail/page.tsx b/openflare-server/web/app/(dashboard)/proxy-route/detail/page.tsx deleted file mode 100644 index ff53591d..00000000 --- a/openflare-server/web/app/(dashboard)/proxy-route/detail/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'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 ( - - ); -} diff --git a/openflare-server/web/app/(dashboard)/proxy-route/page.tsx b/openflare-server/web/app/(dashboard)/proxy-route/page.tsx deleted file mode 100644 index f60130b0..00000000 --- a/openflare-server/web/app/(dashboard)/proxy-route/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {ProxyRoutesPage} from '@/features/proxy-routes/components/proxy-routes-page'; - -export default function ProxyRoutesRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/setting/page.tsx b/openflare-server/web/app/(dashboard)/setting/page.tsx deleted file mode 100644 index 220eeef7..00000000 --- a/openflare-server/web/app/(dashboard)/setting/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {SettingsPage} from '@/features/settings/components/settings-page'; - -export default function SettingsRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/user/page.tsx b/openflare-server/web/app/(dashboard)/user/page.tsx deleted file mode 100644 index e99a152c..00000000 --- a/openflare-server/web/app/(dashboard)/user/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {UsersPage} from '@/features/users/components/users-page'; - -export default function UsersRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/waf/ip-groups/page.tsx b/openflare-server/web/app/(dashboard)/waf/ip-groups/page.tsx deleted file mode 100644 index 95d416c4..00000000 --- a/openflare-server/web/app/(dashboard)/waf/ip-groups/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {WAFIPGroupsPage} from '@/features/waf/components/ip-groups-page'; - -export default function WAFIPGroupsRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/waf/page.tsx b/openflare-server/web/app/(dashboard)/waf/page.tsx deleted file mode 100644 index e08cc74d..00000000 --- a/openflare-server/web/app/(dashboard)/waf/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {WAFPage} from '@/features/waf/components/waf-page'; - -export default function WAFRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/website/certificate/page.tsx b/openflare-server/web/app/(dashboard)/website/certificate/page.tsx deleted file mode 100644 index 2217288d..00000000 --- a/openflare-server/web/app/(dashboard)/website/certificate/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {TlsCertificatesPage} from '@/features/tls-certificates/components/tls-certificates-page'; - -export default function WebsiteCertificateRoute() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/website/detail/page.tsx b/openflare-server/web/app/(dashboard)/website/detail/page.tsx deleted file mode 100644 index 7f7d8ad6..00000000 --- a/openflare-server/web/app/(dashboard)/website/detail/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -import {useSearchParams} from 'next/navigation'; - -import {WebsiteDetailPage} from '@/features/websites/components/website-detail-page'; - -export default function WebsiteDetailRoute() { - const searchParams = useSearchParams(); - - return ; -} diff --git a/openflare-server/web/app/(dashboard)/website/dns-account/page.tsx b/openflare-server/web/app/(dashboard)/website/dns-account/page.tsx deleted file mode 100644 index 94018047..00000000 --- a/openflare-server/web/app/(dashboard)/website/dns-account/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {DnsAccountsPage} from '@/features/dns-accounts/components/dns-accounts-page'; - -export default function Page() { - return ; -} diff --git a/openflare-server/web/app/(dashboard)/website/page.tsx b/openflare-server/web/app/(dashboard)/website/page.tsx deleted file mode 100644 index 424d5699..00000000 --- a/openflare-server/web/app/(dashboard)/website/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {WebsitesPage} from '@/features/websites/components/websites-page'; - -export default function WebsiteRoute() { - return ; -} diff --git a/openflare-server/web/app/(public)/about/page.tsx b/openflare-server/web/app/(public)/about/page.tsx deleted file mode 100644 index 5dec97c8..00000000 --- a/openflare-server/web/app/(public)/about/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {AboutPage} from '@/features/about/components/about-page'; - -export default function AboutRoute() { - return ; -} diff --git a/openflare-server/web/app/(public)/layout.tsx b/openflare-server/web/app/(public)/layout.tsx deleted file mode 100644 index dff04a53..00000000 --- a/openflare-server/web/app/(public)/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type {ReactNode} from 'react'; - -import {PublicShell} from '@/components/layout/public-shell'; - -interface PublicLayoutProps { - children: ReactNode; -} - -export default function PublicLayout({ children }: PublicLayoutProps) { - return {children}; -} diff --git a/openflare-server/web/app/(public)/login/page.tsx b/openflare-server/web/app/(public)/login/page.tsx deleted file mode 100644 index d5945ff5..00000000 --- a/openflare-server/web/app/(public)/login/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {Suspense} from 'react'; - -import {LoadingState} from '@/components/feedback/loading-state'; -import {LoginForm} from '@/features/auth/components/login-form'; - -export default function LoginPage() { - return ( - }> - - - ); -} diff --git a/openflare-server/web/app/(public)/oauth/callback/page.tsx b/openflare-server/web/app/(public)/oauth/callback/page.tsx deleted file mode 100644 index a3e3f79b..00000000 --- a/openflare-server/web/app/(public)/oauth/callback/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {Suspense} from 'react'; - -import {LoadingState} from '@/components/feedback/loading-state'; -import {OAuthCallback} from '@/features/auth/components/oauth-callback'; - -export default function OAuthSourceCallbackPage() { - return ( - }> - - - ); -} diff --git a/openflare-server/web/app/(public)/oauth/github/page.tsx b/openflare-server/web/app/(public)/oauth/github/page.tsx deleted file mode 100644 index 86097599..00000000 --- a/openflare-server/web/app/(public)/oauth/github/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {Suspense} from 'react'; - -import {LoadingState} from '@/components/feedback/loading-state'; -import {GitHubOAuthCallback} from '@/features/auth/components/github-oauth-callback'; - -export default function GithubOAuthPage() { - return ( - }> - - - ); -} diff --git a/openflare-server/web/app/(public)/oauth/link/page.tsx b/openflare-server/web/app/(public)/oauth/link/page.tsx deleted file mode 100644 index b5547c64..00000000 --- a/openflare-server/web/app/(public)/oauth/link/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import {OAuthLinkExistingForm} from '@/features/auth/components/oauth-link-existing-form'; - -export default function OAuthLinkPage() { - return ; -} diff --git a/openflare-server/web/app/(public)/reset/page.tsx b/openflare-server/web/app/(public)/reset/page.tsx deleted file mode 100644 index 3114914b..00000000 --- a/openflare-server/web/app/(public)/reset/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {Suspense} from 'react'; - -import {LoadingState} from '@/components/feedback/loading-state'; -import {PasswordResetFlow} from '@/features/auth/components/password-reset-flow'; - -export default function ResetPasswordPage() { - return ( - }> - - - ); -} diff --git a/openflare-server/web/app/(public)/user/reset/page.tsx b/openflare-server/web/app/(public)/user/reset/page.tsx deleted file mode 100644 index 07736657..00000000 --- a/openflare-server/web/app/(public)/user/reset/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {Suspense} from 'react'; - -import {LoadingState} from '@/components/feedback/loading-state'; -import {PasswordResetFlow} from '@/features/auth/components/password-reset-flow'; - -export default function LegacyResetPasswordPage() { - return ( - }> - - - ); -} diff --git a/openflare-server/web/app/globals.css b/openflare-server/web/app/globals.css deleted file mode 100644 index 2b4ae997..00000000 --- a/openflare-server/web/app/globals.css +++ /dev/null @@ -1,189 +0,0 @@ -@import '../styles/tokens.css'; -@import 'tailwindcss'; -@import '@heroui/styles'; -@import 'tw-animate-css'; -@import 'shadcn/tailwind.css'; - -@custom-variant dark (&:is(.dark *)); - -* { - box-sizing: border-box; -} - -html { - background: var(--surface-canvas); -} - -html[data-theme='light'] { - color-scheme: light; -} - -html[data-theme='dark'] { - color-scheme: dark; -} - -body { - min-height: 100vh; - background: var(--body-background); - color: var(--foreground-primary); - font-family: - Inter, - ui-sans-serif, - system-ui, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; - transition: - background 0.2s ease, - color 0.2s ease; -} - -a { - color: inherit; - text-decoration: none; -} - -button { - cursor: pointer; -} - -::selection { - background: var(--selection-background); - color: var(--selection-foreground); -} - -@theme inline { - --font-heading: var(--font-sans); - --font-sans: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); -} - -:root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - html { - @apply font-sans; - } -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -.no-scrollbar::-webkit-scrollbar { - display: none; -} - -/* Hide scrollbar for IE, Edge and Firefox */ -.no-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} diff --git a/openflare-server/web/app/layout.tsx b/openflare-server/web/app/layout.tsx deleted file mode 100644 index cda259e2..00000000 --- a/openflare-server/web/app/layout.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type {Metadata} from 'next'; -import Script from 'next/script'; -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' }); - -export const metadata: Metadata = { - title: { - default: 'OpenFlare 控制台', - template: '%s | OpenFlare', - }, - description: 'OpenFlare 管理端', - applicationName: 'OpenFlare', -}; - -interface RootLayoutProps { - children: ReactNode; -} - -export default function RootLayout({ children }: RootLayoutProps) { - return ( - - - - {children} - - - ); -} diff --git a/openflare-server/web/app/not-found.tsx b/openflare-server/web/app/not-found.tsx deleted file mode 100644 index 825bc501..00000000 --- a/openflare-server/web/app/not-found.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Link from 'next/link'; - -import {AppCard} from '@/components/ui/app-card'; - -export default function NotFound() { - return ( -
- -
-

请返回总览页,或继续通过侧边导航访问已初始化的模块入口。

- - 返回总览 - -
-
-
- ); -} diff --git a/openflare-server/web/components.json b/openflare-server/web/components.json deleted file mode 100644 index 02e61e07..00000000 --- a/openflare-server/web/components.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "radix-nova", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} -} diff --git a/openflare-server/web/components/data/rank-chart.tsx b/openflare-server/web/components/data/rank-chart.tsx deleted file mode 100644 index e1bd75ad..00000000 --- a/openflare-server/web/components/data/rank-chart.tsx +++ /dev/null @@ -1,132 +0,0 @@ -'use client'; - -import {useMemo} from 'react'; -import type {EChartsOption} from 'echarts'; -import ReactECharts from 'echarts-for-react'; - -type RankChartItem = { - label: string; - value: number; -}; - -type RankChartProps = { - items: RankChartItem[]; - color: string; - valueFormatter?: (value: number) => string; - emptyMessage?: string; -}; - -const defaultFormatter = (value: number) => value.toLocaleString('zh-CN'); - -function getChartValue(params: unknown) { - if (typeof params !== 'object' || params === null || !('value' in params)) { - return 0; - } - const rawValue = (params as { value?: unknown }).value; - if (Array.isArray(rawValue)) { - const candidate = rawValue[0]; - return typeof candidate === 'number' ? candidate : 0; - } - return typeof rawValue === 'number' ? rawValue : 0; -} - -export function RankChart({ - items, - color, - valueFormatter = defaultFormatter, - emptyMessage = '暂无分布数据', -}: RankChartProps) { - const option = useMemo( - () => ({ - animationDuration: 400, - grid: { - left: 16, - right: 24, - top: 12, - bottom: 12, - containLabel: true, - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'shadow', - }, - backgroundColor: 'rgba(15, 23, 42, 0.92)', - borderWidth: 0, - textStyle: { - color: '#e2e8f0', - fontSize: 12, - }, - formatter: (params: unknown) => { - const item = Array.isArray(params) ? params[0] : params; - const data = item as { name?: string; value?: number }; - return `${data.name ?? ''}
${valueFormatter(data.value ?? 0)}`; - }, - }, - xAxis: { - type: 'value', - axisLabel: { - color: '#94a3b8', - }, - splitLine: { - lineStyle: { - color: 'rgba(148, 163, 184, 0.16)', - type: 'dashed', - }, - }, - }, - yAxis: { - type: 'category', - data: items.map((item) => item.label), - axisTick: { show: false }, - axisLine: { show: false }, - axisLabel: { - color: '#cbd5e1', - width: 120, - overflow: 'truncate', - }, - }, - series: [ - { - type: 'bar', - data: items.map((item) => item.value), - barWidth: 12, - showBackground: true, - backgroundStyle: { - color: 'rgba(148, 163, 184, 0.12)', - borderRadius: 999, - }, - itemStyle: { - color, - borderRadius: 999, - }, - label: { - show: true, - position: 'right', - color: '#e2e8f0', - formatter: (params: unknown) => - valueFormatter(getChartValue(params)), - }, - }, - ], - }), - [color, items, valueFormatter], - ); - - if (items.length === 0) { - return ( -
- {emptyMessage} -
- ); - } - - return ( - - ); -} diff --git a/openflare-server/web/components/data/trend-chart.tsx b/openflare-server/web/components/data/trend-chart.tsx deleted file mode 100644 index 4c4345cd..00000000 --- a/openflare-server/web/components/data/trend-chart.tsx +++ /dev/null @@ -1,208 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import type { EChartsOption } from 'echarts'; -import ReactECharts from 'echarts-for-react'; - -import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics'; - -type TrendChartSeries = { - label: string; - color: string; - fillColor?: string; - values: number[]; - variant?: 'line' | 'area'; - valueFormatter?: (value: number) => string; -}; - -type TrendChartProps = { - labels: string[]; - series: TrendChartSeries[]; - height?: number; - yAxisValueFormatter?: (value: number) => string; -}; - -type TooltipParam = { - axisValueLabel?: string; - color?: string; - seriesName?: string; - value?: number | string | Array; -}; - -const defaultFormatter = (value: number) => formatCompactNumber(value); - -export function TrendChart({ - labels, - series, - height = 220, - yAxisValueFormatter, -}: TrendChartProps) { - const option = useMemo(() => { - const axisFormatter = yAxisValueFormatter ?? defaultFormatter; - const maxValue = calculateNiceAxisMax( - series.flatMap((item) => item.values), - ); - - return { - animationDuration: 500, - animationEasing: 'cubicOut', - grid: { - left: 16, - right: 16, - top: 20, - bottom: 20, - containLabel: true, - }, - tooltip: { - trigger: 'axis', - backgroundColor: 'rgba(15, 23, 42, 0.92)', - borderWidth: 0, - textStyle: { - color: '#e2e8f0', - fontSize: 12, - }, - formatter: (params: unknown) => { - const items = Array.isArray(params) ? (params as TooltipParam[]) : []; - if (items.length === 0) { - return ''; - } - - const header = items[0]?.axisValueLabel ?? ''; - const rows = items.map((item) => { - const matchedSeries = series.find( - (seriesItem) => seriesItem.label === item.seriesName, - ); - const formatter = - matchedSeries?.valueFormatter ?? - yAxisValueFormatter ?? - defaultFormatter; - const rawValue = Array.isArray(item.value) - ? item.value[1] - : item.value; - const numericValue = - typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0); - - return [ - '', - ``, - `${item.seriesName ?? ''}`, - `${formatter(numericValue)}`, - '', - ].join(''); - }); - - return [header, ...rows].join('
'); - }, - }, - legend: { - show: false, - }, - xAxis: { - type: 'category', - boundaryGap: false, - data: labels, - axisLine: { - lineStyle: { - color: 'rgba(148, 163, 184, 0.24)', - }, - }, - axisTick: { - show: false, - }, - axisLabel: { - color: '#94a3b8', - margin: 14, - }, - }, - yAxis: { - type: 'value', - min: 0, - max: maxValue, - splitNumber: 4, - axisLabel: { - color: '#94a3b8', - formatter: (value: number) => axisFormatter(value), - }, - splitLine: { - lineStyle: { - color: 'rgba(148, 163, 184, 0.16)', - type: 'dashed', - }, - }, - }, - series: series.map((item) => ({ - name: item.label, - type: 'line', - smooth: true, - showSymbol: false, - symbol: 'circle', - symbolSize: 8, - lineStyle: { - color: item.color, - width: 3, - }, - itemStyle: { - color: item.color, - }, - areaStyle: - item.variant === 'area' - ? { - color: item.fillColor ?? `${item.color}33`, - } - : undefined, - emphasis: { - focus: 'series', - scale: true, - }, - data: item.values, - })), - }; - }, [labels, series, yAxisValueFormatter]); - - if (labels.length === 0 || series.length === 0) { - return ( -
- 暂无趋势数据 -
- ); - } - - return ( -
-
- {series.map((item) => { - const latestValue = item.values[item.values.length - 1] ?? 0; - const formatter = item.valueFormatter ?? defaultFormatter; - return ( -
-
- -

- {item.label} -

-
-

- {formatter(latestValue)} -

-
- ); - })} -
- -
- -
-
- ); -} diff --git a/openflare-server/web/components/feedback/empty-state.tsx b/openflare-server/web/components/feedback/empty-state.tsx deleted file mode 100644 index e3799f2f..00000000 --- a/openflare-server/web/components/feedback/empty-state.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type {ReactNode} from 'react'; - -interface EmptyStateProps { - title: string; - description?: string; - children?: ReactNode; -} - -export function EmptyState({ title, description, children }: EmptyStateProps) { - return ( -
-

- {title} -

- {description ? ( -

- {description} -

- ) : null} - {children ?
{children}
: null} -
- ); -} diff --git a/openflare-server/web/components/feedback/error-state.tsx b/openflare-server/web/components/feedback/error-state.tsx deleted file mode 100644 index 196aad49..00000000 --- a/openflare-server/web/components/feedback/error-state.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface ErrorStateProps { - title: string; - description: string; -} - -export function ErrorState({ title, description }: ErrorStateProps) { - return ( -
-

- {title} -

-

- {description} -

-
- ); -} diff --git a/openflare-server/web/components/feedback/feature-placeholder.tsx b/openflare-server/web/components/feedback/feature-placeholder.tsx deleted file mode 100644 index 64cb1560..00000000 --- a/openflare-server/web/components/feedback/feature-placeholder.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import {AppCard} from '@/components/ui/app-card'; -import {StatusBadge} from '@/components/ui/status-badge'; - -interface FeaturePlaceholderProps { - title: string; - description: string; - milestones: string[]; -} - -export function FeaturePlaceholder({ - title, - description, - milestones, -}: FeaturePlaceholderProps) { - return ( - } - > -
    - {milestones.map((item) => ( -
  • - - {item} -
  • - ))} -
-
- ); -} diff --git a/openflare-server/web/components/feedback/inline-message.tsx b/openflare-server/web/components/feedback/inline-message.tsx deleted file mode 100644 index 76a49bf6..00000000 --- a/openflare-server/web/components/feedback/inline-message.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client'; - -import {useEffect, useRef} from 'react'; -import {toast} from 'sonner'; -import {cn} from '@/lib/utils/cn'; - -type InlineMessageTone = 'info' | 'success' | 'danger'; - -const toneClasses: Record = { - info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]', - success: - 'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]', - danger: - 'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]', -}; - -interface InlineMessageProps { - tone?: InlineMessageTone; - message: string; - className?: string; - onClear?: () => void; -} - -export function InlineMessage({ - tone = 'info', - message, - className, - onClear, -}: InlineMessageProps) { - const onClearRef = useRef(onClear); - - useEffect(() => { - onClearRef.current = onClear; - }, [onClear]); - - useEffect(() => { - // Only trigger toast if onClear is provided (dynamic feedback) - if (!onClearRef.current || !message) return; - - const options = { - position: 'bottom-right' as const, - }; - - if (tone === 'success') { - toast.success(message, options); - } else if (tone === 'danger') { - toast.error(message, options); - } else { - toast(message, options); - } - - const timer = setTimeout(() => { - onClearRef.current?.(); - }, 0); - return () => clearTimeout(timer); - }, [tone, message]); - - // If onClear is provided, this is a toast feedback notice, so render nothing inline - if (onClear) { - return null; - } - - // Otherwise, render as a static/persistent inline alert banner (original behavior) - return ( -
- {message} -
- ); -} diff --git a/openflare-server/web/components/feedback/loading-state.tsx b/openflare-server/web/components/feedback/loading-state.tsx deleted file mode 100644 index 74bd7eab..00000000 --- a/openflare-server/web/components/feedback/loading-state.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import {cn} from '@/lib/utils/cn'; - -interface LoadingStateProps { - className?: string; -} - -export function LoadingState({ className }: LoadingStateProps) { - return ( -
-
-
-
-
- ); -} diff --git a/openflare-server/web/components/layout/dashboard-shell.tsx b/openflare-server/web/components/layout/dashboard-shell.tsx deleted file mode 100644 index bdb63530..00000000 --- a/openflare-server/web/components/layout/dashboard-shell.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ReactNode } from 'react'; - -import { DashboardSidebar } from '@/components/layout/dashboard-sidebar'; -import { DashboardTopbar } from '@/components/layout/dashboard-topbar'; - -interface DashboardShellProps { - children: ReactNode; -} - -export function DashboardShell({ children }: DashboardShellProps) { - return ( -
- -
- -
{children}
-
-
- ); -} diff --git a/openflare-server/web/components/layout/dashboard-sidebar.tsx b/openflare-server/web/components/layout/dashboard-sidebar.tsx deleted file mode 100644 index 94216c88..00000000 --- a/openflare-server/web/components/layout/dashboard-sidebar.tsx +++ /dev/null @@ -1,295 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { ShieldCheck } from 'lucide-react'; - -import { dashboardNavigation } from '@/lib/constants/navigation'; -import { cn } from '@/lib/utils/cn'; -import { isNavigationItemActive } from '@/lib/utils/navigation'; -import { useAppShellStore } from '@/store/app-shell'; -import type { NavigationIconKey, NavigationItem } from '@/types/navigation'; - -function SidebarIcon({ icon }: { icon: NavigationIconKey }) { - const commonProps = { - className: 'h-[18px] w-[18px]', - fill: 'none', - stroke: 'currentColor', - strokeWidth: 1.8, - strokeLinecap: 'round' as const, - strokeLinejoin: 'round' as const, - viewBox: '0 0 24 24', - }; - - switch (icon) { - case 'home': - return ( - - - - - - ); - case 'node': - return ( - - - - - - - ); - case 'website': - return ( - - - - - - ); - case 'origin': - return ( - - - - - ); - case 'domain': - return ( - - - - - - ); - case 'certificate': - return ( - - - - - - ); - case 'proxy': - return ( - - - - - - - ); - case 'pages': - return ( - - - - - - - ); - case 'waf': - return ; - case 'release': - return ( - - - - - - ); - case 'log': - return ( - - - - - - - ); - case 'performance': - return ( - - - - - - ); - case 'user': - return ( - - - - - ); - case 'setting': - return ( - - - - - ); - } -} - -function SidebarNavItem({ - item, - currentPath, - isSidebarCollapsed, - forceExpanded, - onNavigate, - depth = 0, -}: { - item: NavigationItem; - currentPath: string; - isSidebarCollapsed: boolean; - forceExpanded?: boolean; - onNavigate?: () => void; - depth?: number; -}) { - const active = isNavigationItemActive(currentPath, item); - const hasChildren = Boolean(item.children?.length); - const showLabel = forceExpanded || !isSidebarCollapsed; - - return ( -
- 0 && 'ml-3 rounded-xl', - active - ? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]' - : 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]', - )} - > - - - - {showLabel ? ( - - {item.label} - - ) : null} - - {showLabel && hasChildren ? ( -
- {item.children?.map((child) => ( - - ))} -
- ) : null} -
- ); -} - -function SidebarContent({ - currentPath, - isSidebarCollapsed, - forceExpanded = false, - onNavigate, -}: { - currentPath: string; - isSidebarCollapsed: boolean; - forceExpanded?: boolean; - onNavigate?: () => void; -}) { - const showLabel = forceExpanded || !isSidebarCollapsed; - - return ( -
-
-
- AF -
- {showLabel ? ( -
-

- OpenFlare -

-
- ) : null} -
- - -
- ); -} - -export function DashboardSidebar() { - const pathname = usePathname(); - const currentPath = pathname ?? '/'; - const isSidebarCollapsed = useAppShellStore( - (state) => state.isSidebarCollapsed, - ); - const isMobileSidebarOpen = useAppShellStore( - (state) => state.isMobileSidebarOpen, - ); - const setMobileSidebarOpen = useAppShellStore( - (state) => state.setMobileSidebarOpen, - ); - - useEffect(() => { - setMobileSidebarOpen(false); - }, [currentPath, setMobileSidebarOpen]); - - return ( - <> -
setMobileSidebarOpen(false)} - aria-hidden="true" - /> - - - - - - ); -} diff --git a/openflare-server/web/components/layout/dashboard-topbar.tsx b/openflare-server/web/components/layout/dashboard-topbar.tsx deleted file mode 100644 index 235d8ba9..00000000 --- a/openflare-server/web/components/layout/dashboard-topbar.tsx +++ /dev/null @@ -1,499 +0,0 @@ -'use client'; - -import { useMutation, useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; - -import { useAuth } from '@/components/providers/auth-provider'; -import { ThemeToggle } from '@/components/ui/theme-toggle'; -import { getPublicStatus } from '@/features/auth/api/public'; -import { - createUpgradeLogsWebSocket, - confirmManualServerUpgrade, - getLatestRelease, - parseUpgradeStreamSnapshot, - upgradeServer, - uploadServerBinary, -} from '@/features/update/api/update'; -import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal'; -import type { - LatestReleaseInfo, - ReleaseChannel, - UpgradeStreamSnapshot, - UploadedServerBinaryInfo, -} from '@/features/update/types'; -import { publicEnv } from '@/lib/env/public-env'; -import { useAppShellStore } from '@/store/app-shell'; - -export function DashboardTopbar() { - const router = useRouter(); - const { logout, user } = useAuth(); - const toggleSidebar = useAppShellStore((state) => state.toggleSidebar); - const isMobileSidebarOpen = useAppShellStore( - (state) => state.isMobileSidebarOpen, - ); - const setMobileSidebarOpen = useAppShellStore( - (state) => state.setMobileSidebarOpen, - ); - const [isLoggingOut, setIsLoggingOut] = useState(false); - const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - const [isVersionModalOpen, setIsVersionModalOpen] = useState(false); - const [selectedReleaseChannel, setSelectedReleaseChannel] = - useState('stable'); - const [versionFeedback, setVersionFeedback] = useState(null); - const [manualUpgradeStatus, setManualUpgradeStatus] = useState( - null, - ); - const [manualUpgradeError, setManualUpgradeError] = useState( - null, - ); - const [uploadedBinary, setUploadedBinary] = - useState(null); - const [uploadProgress, setUploadProgress] = useState(0); - const [upgradeStream, setUpgradeStream] = - useState(null); - const menuRef = useRef(null); - const upgradeRefreshPendingRef = useRef(false); - const upgradeReloadStartedRef = useRef(false); - const upgradeReloadTimerRef = useRef(null); - const isRoot = (user?.role ?? 0) >= 100; - const upgradeStatusPollInterval = 3000; - - const publicStatusQuery = useQuery({ - queryKey: ['public-status'], - queryFn: getPublicStatus, - }); - - const stableReleaseQuery = useQuery({ - queryKey: ['update', 'latest-release', 'stable'], - queryFn: () => getLatestRelease('stable'), - enabled: isRoot, - refetchInterval: (query) => { - const release = query.state.data; - if (isVersionModalOpen && release?.in_progress) { - return upgradeStatusPollInterval; - } - return 60 * 60 * 1000; - }, - }); - - const previewReleaseQuery = useQuery({ - queryKey: ['update', 'latest-release', 'preview'], - queryFn: () => getLatestRelease('preview'), - enabled: false, - refetchInterval: (query) => { - const release = query.state.data; - if (isVersionModalOpen && release?.in_progress) { - return upgradeStatusPollInterval; - } - return false; - }, - }); - - const upgradeMutation = useMutation({ - mutationFn: (channel: ReleaseChannel) => upgradeServer(channel), - onSuccess: (release) => { - upgradeRefreshPendingRef.current = true; - setUploadedBinary(null); - setManualUpgradeStatus(null); - setManualUpgradeError(null); - setVersionFeedback( - `服务升级任务已启动,目标版本 ${release.tag_name}(${release.channel === 'preview' ? '预览版' : '正式版'})。页面可能短暂不可用。`, - ); - void stableReleaseQuery.refetch(); - if (release.channel === 'preview') { - void previewReleaseQuery.refetch(); - } - }, - onError: (error) => { - setVersionFeedback( - error instanceof Error ? error.message : '升级失败,请稍后重试。', - ); - }, - }); - - const uploadBinaryMutation = useMutation({ - mutationFn: (binary: File) => - uploadServerBinary(binary, (progress) => { - setUploadProgress(progress); - }), - onSuccess: (candidate) => { - setUploadProgress(0); - setVersionFeedback(null); - setManualUpgradeError(null); - setUploadedBinary(candidate); - setManualUpgradeStatus(candidate.comparison_message); - }, - onError: (error) => { - setUploadProgress(0); - setUploadedBinary(null); - setManualUpgradeStatus(null); - setManualUpgradeError( - error instanceof Error ? error.message : '上传升级包失败,请稍后重试。', - ); - }, - }); - - const confirmManualUpgradeMutation = useMutation({ - mutationFn: confirmManualServerUpgrade, - onSuccess: (candidate) => { - upgradeRefreshPendingRef.current = true; - setVersionFeedback(null); - setManualUpgradeError(null); - setUploadedBinary(candidate); - setManualUpgradeStatus( - `手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`, - ); - void stableReleaseQuery.refetch(); - void previewReleaseQuery.refetch(); - }, - onError: (error) => { - setManualUpgradeStatus(null); - setManualUpgradeError( - error instanceof Error - ? error.message - : '确认手动升级失败,请稍后重试。', - ); - }, - }); - - const scheduleUpgradePageReload = useCallback(() => { - if (upgradeReloadStartedRef.current) { - return; - } - - upgradeReloadStartedRef.current = true; - setVersionFeedback('服务升级已进入重启阶段,页面将在服务恢复后自动刷新。'); - - const reloadWhenServerReady = async () => { - try { - await getPublicStatus(); - window.location.reload(); - } catch { - upgradeReloadTimerRef.current = window.setTimeout( - reloadWhenServerReady, - 1500, - ); - } - }; - - upgradeReloadTimerRef.current = window.setTimeout( - reloadWhenServerReady, - 1200, - ); - }, []); - - useEffect(() => { - return () => { - if (upgradeReloadTimerRef.current !== null) { - window.clearTimeout(upgradeReloadTimerRef.current); - } - }; - }, []); - - useEffect(() => { - if (!isVersionModalOpen || !isRoot) { - setUpgradeStream(null); - return; - } - - let closed = false; - let reconnectTimer: number | null = null; - let socket: WebSocket | null = null; - - const connect = () => { - if (closed) { - return; - } - - socket = createUpgradeLogsWebSocket(); - if (!socket) { - return; - } - - socket.onmessage = (event) => { - const snapshot = parseUpgradeStreamSnapshot(String(event.data)); - if (snapshot) { - if (snapshot.in_progress || snapshot.upgrade_status === 'succeeded') { - upgradeRefreshPendingRef.current = true; - } - if (snapshot.upgrade_status === 'failed') { - upgradeRefreshPendingRef.current = false; - } - setUpgradeStream(snapshot); - } - }; - - socket.onclose = () => { - if (closed) { - return; - } - if (upgradeRefreshPendingRef.current) { - scheduleUpgradePageReload(); - return; - } - reconnectTimer = window.setTimeout(connect, 1500); - }; - }; - - connect(); - - return () => { - closed = true; - if (reconnectTimer !== null) { - window.clearTimeout(reconnectTimer); - } - socket?.close(); - }; - }, [isRoot, isVersionModalOpen, scheduleUpgradePageReload]); - - useEffect(() => { - if (!isUserMenuOpen) { - return; - } - - const handlePointerDown = (event: MouseEvent) => { - if (!menuRef.current?.contains(event.target as Node)) { - setIsUserMenuOpen(false); - } - }; - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsUserMenuOpen(false); - } - }; - - window.addEventListener('mousedown', handlePointerDown); - window.addEventListener('keydown', handleEscape); - - return () => { - window.removeEventListener('mousedown', handlePointerDown); - window.removeEventListener('keydown', handleEscape); - }; - }, [isUserMenuOpen]); - - const handleLogout = async () => { - setIsLoggingOut(true); - setIsUserMenuOpen(false); - await logout(); - router.replace('/login'); - }; - - const handleSidebarToggle = () => { - if (window.innerWidth < 1000) { - setMobileSidebarOpen(!isMobileSidebarOpen); - return; - } - - toggleSidebar(); - }; - - const handleOpenVersionModal = () => { - setSelectedReleaseChannel('stable'); - setVersionFeedback(null); - setManualUpgradeStatus(null); - setManualUpgradeError(null); - setIsVersionModalOpen(true); - if (isRoot) { - void stableReleaseQuery.refetch(); - } - }; - - const handleUpgrade = () => { - setVersionFeedback(null); - setManualUpgradeStatus(null); - setManualUpgradeError(null); - upgradeMutation.mutate(selectedReleaseChannel); - }; - - const handleCheckRelease = () => { - setVersionFeedback(null); - if (isRoot) { - if (selectedReleaseChannel === 'preview') { - void previewReleaseQuery.refetch(); - } else { - void stableReleaseQuery.refetch(); - } - } - }; - - const handleReleaseChannelChange = (channel: ReleaseChannel) => { - setSelectedReleaseChannel(channel); - setVersionFeedback(null); - }; - - const handleUploadBinary = (binary: File) => { - setUploadProgress(0); - setManualUpgradeStatus(null); - setManualUpgradeError(null); - uploadBinaryMutation.mutate(binary); - }; - - const handleConfirmManualUpgrade = () => { - if (!uploadedBinary?.upload_token) { - setManualUpgradeStatus(null); - setManualUpgradeError('请先上传并检查升级包。'); - return; - } - setVersionFeedback(null); - setManualUpgradeStatus(null); - setManualUpgradeError(null); - confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token); - }; - - const selectedRelease = - selectedReleaseChannel === 'preview' - ? previewReleaseQuery.data - : stableReleaseQuery.data; - const releaseWithStream = mergeReleaseWithUpgradeStream( - selectedRelease, - upgradeStream, - ); - const selectedReleaseError = - selectedReleaseChannel === 'preview' - ? previewReleaseQuery.error - : stableReleaseQuery.error; - const isSelectedReleaseError = - selectedReleaseChannel === 'preview' - ? previewReleaseQuery.isError - : stableReleaseQuery.isError; - const hasUpdate = Boolean(isRoot && stableReleaseQuery.data?.has_update); - const currentVersion = publicStatusQuery.data?.version || 'unknown'; - const versionLabel = hasUpdate - ? `版本 ${publicEnv.appVersion} · 可升级` - : `版本 ${publicEnv.appVersion}`; - const versionButtonClassName = hasUpdate - ? 'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]' - : 'border-[var(--border-default)]'; - const versionErrorMessage = - versionFeedback || - (isSelectedReleaseError - ? selectedReleaseError instanceof Error - ? selectedReleaseError.message - : '版本检查失败,请稍后重试。' - : undefined); - const manualUpgradeErrorMessage = manualUpgradeError ?? undefined; - - return ( - <> -
-
- - -
- - -
- - - {isUserMenuOpen ? ( -
-
-

- {user?.display_name || user?.username || '用户'} -

- {user?.username ? ( -

- @{user.username} -

- ) : null} -
- -
- ) : null} -
-
-
-
- - setIsVersionModalOpen(false)} - currentVersion={currentVersion} - release={releaseWithStream} - selectedChannel={selectedReleaseChannel} - uploadedBinary={uploadedBinary} - isLoading={ - (selectedReleaseChannel === 'preview' - ? previewReleaseQuery.isLoading && !previewReleaseQuery.data - : stableReleaseQuery.isLoading && !stableReleaseQuery.data) && - isRoot - } - releaseErrorMessage={versionErrorMessage} - manualStatusMessage={manualUpgradeStatus ?? undefined} - manualErrorMessage={manualUpgradeErrorMessage} - canUpgrade={isRoot} - isChecking={ - selectedReleaseChannel === 'preview' - ? previewReleaseQuery.isFetching - : stableReleaseQuery.isFetching - } - isUpgrading={upgradeMutation.isPending} - isUploadingBinary={uploadBinaryMutation.isPending} - uploadProgress={uploadProgress} - isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending} - onChannelChange={handleReleaseChannelChange} - onCheck={handleCheckRelease} - onUpgrade={handleUpgrade} - onUploadBinary={handleUploadBinary} - onConfirmManualUpgrade={handleConfirmManualUpgrade} - /> - - ); -} - -function mergeReleaseWithUpgradeStream( - release: LatestReleaseInfo | null | undefined, - stream: UpgradeStreamSnapshot | null, -) { - if (!release || !stream) { - return release; - } - - return { - ...release, - in_progress: stream.in_progress, - upgrade_status: stream.upgrade_status, - upgrade_logs: stream.upgrade_logs, - }; -} diff --git a/openflare-server/web/components/layout/page-header.tsx b/openflare-server/web/components/layout/page-header.tsx deleted file mode 100644 index 5d096304..00000000 --- a/openflare-server/web/components/layout/page-header.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type {ReactNode} from 'react'; - -interface PageHeaderProps { - title: string; - description?: string; - action?: ReactNode; -} - -export function PageHeader({ title, description, action }: PageHeaderProps) { - return ( -
-
-

- OpenFlare -

-
-

- {title} -

- {description ? ( -

- {description} -

- ) : null} -
-
- {action ?
{action}
: null} -
- ); -} diff --git a/openflare-server/web/components/layout/public-shell.tsx b/openflare-server/web/components/layout/public-shell.tsx deleted file mode 100644 index d3ef65a2..00000000 --- a/openflare-server/web/components/layout/public-shell.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import Link from 'next/link'; -import type {ReactNode} from 'react'; - -import {publicEnv} from '@/lib/env/public-env'; - -interface PublicShellProps { - children: ReactNode; -} - -export function PublicShell({ children }: PublicShellProps) { - return ( -
-
-
-
-

- OpenFlare -

-
-
- - {publicEnv.appVersion} - -
-
- -
{children}
- -
-
- - 返回 - - - 关于 - -
-
-
-
- ); -} diff --git a/openflare-server/web/components/providers/app-providers.tsx b/openflare-server/web/components/providers/app-providers.tsx deleted file mode 100644 index 5bb4b13f..00000000 --- a/openflare-server/web/components/providers/app-providers.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { type ReactNode, useState } from 'react'; - -import { AuthProvider } from '@/components/providers/auth-provider'; -import { ThemeProvider } from '@/components/providers/theme-provider'; -import { Toaster } from '@/components/ui/sonner'; - -interface AppProvidersProps { - children: ReactNode; -} - -export function AppProviders({ children }: AppProvidersProps) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - retry: 1, - staleTime: 30_000, - }, - mutations: { - retry: 0, - }, - }, - }), - ); - - return ( - - - - {children} - - - - - ); -} diff --git a/openflare-server/web/components/providers/auth-provider.tsx b/openflare-server/web/components/providers/auth-provider.tsx deleted file mode 100644 index 5a007156..00000000 --- a/openflare-server/web/components/providers/auth-provider.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client'; - -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; - -import { - logout as logoutRequest, - getCurrentUser, -} from '@/features/auth/api/auth'; -import { clearStoredOpenFlareToken } from '@/lib/api/auth-token'; -import type { AuthUser } from '@/types/auth'; - -interface AuthContextValue { - user: AuthUser | null; - isLoading: boolean; - isAuthenticated: boolean; - refreshUser: () => Promise; - setUser: (user: AuthUser | null) => void; - logout: () => Promise; -} - -const AuthContext = createContext(null); - -interface AuthProviderProps { - children: ReactNode; -} - -export function AuthProvider({ children }: AuthProviderProps) { - const [user, setUserState] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - const refreshUser = useCallback(async () => { - try { - const nextUser = await getCurrentUser(); - setUserState(nextUser); - return nextUser; - } catch { - clearStoredOpenFlareToken(); - setUserState(null); - return null; - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - void refreshUser(); - }, [refreshUser]); - - const setUser = useCallback((nextUser: AuthUser | null) => { - setUserState(nextUser); - setIsLoading(false); - }, []); - - const logout = useCallback(async () => { - try { - await logoutRequest(); - } finally { - setUserState(null); - setIsLoading(false); - } - }, []); - - const value = useMemo( - () => ({ - user, - isLoading, - isAuthenticated: user !== null, - refreshUser, - setUser, - logout, - }), - [isLoading, logout, refreshUser, setUser, user], - ); - - return {children}; -} - -export function useAuth() { - const context = useContext(AuthContext); - - if (!context) { - throw new Error('useAuth must be used within AuthProvider'); - } - - return context; -} diff --git a/openflare-server/web/components/providers/theme-provider.tsx b/openflare-server/web/components/providers/theme-provider.tsx deleted file mode 100644 index 4a694e3a..00000000 --- a/openflare-server/web/components/providers/theme-provider.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; - -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; - -import { - isThemeMode, - resolveTheme, - THEME_MEDIA_QUERY, - THEME_STORAGE_KEY, - type ResolvedTheme, - type ThemeMode, -} from '@/lib/theme/theme'; - -interface ThemeContextValue { - themeMode: ThemeMode; - resolvedTheme: ResolvedTheme; - setThemeMode: (mode: ThemeMode) => void; -} - -const ThemeContext = createContext(null); - -interface ThemeProviderProps { - children: ReactNode; -} - -function getSystemPreference() { - return window.matchMedia(THEME_MEDIA_QUERY).matches; -} - -function applyTheme(mode: ThemeMode) { - const resolvedTheme = resolveTheme(mode, getSystemPreference()); - const root = document.documentElement; - - root.dataset.themeMode = mode; - root.dataset.theme = resolvedTheme; - root.style.colorScheme = resolvedTheme; - window.localStorage.setItem(THEME_STORAGE_KEY, mode); - - return resolvedTheme; -} - -export function ThemeProvider({ children }: ThemeProviderProps) { - const [themeMode, setThemeModeState] = useState('system'); - const [resolvedTheme, setResolvedTheme] = useState('dark'); - - useEffect(() => { - const root = document.documentElement; - const domThemeMode = root.dataset.themeMode; - const nextThemeMode = isThemeMode(domThemeMode) ? domThemeMode : 'system'; - const nextResolvedTheme = applyTheme(nextThemeMode); - - setThemeModeState(nextThemeMode); - setResolvedTheme(nextResolvedTheme); - }, []); - - useEffect(() => { - const mediaQuery = window.matchMedia(THEME_MEDIA_QUERY); - - const handleChange = () => { - if (themeMode !== 'system') { - return; - } - - setResolvedTheme(applyTheme('system')); - }; - - mediaQuery.addEventListener('change', handleChange); - - return () => { - mediaQuery.removeEventListener('change', handleChange); - }; - }, [themeMode]); - - const setThemeMode = useCallback((mode: ThemeMode) => { - setThemeModeState(mode); - setResolvedTheme(applyTheme(mode)); - }, []); - - const value = useMemo( - () => ({ - themeMode, - resolvedTheme, - setThemeMode, - }), - [resolvedTheme, setThemeMode, themeMode], - ); - - return ( - {children} - ); -} - -export function useTheme() { - const context = useContext(ThemeContext); - - if (!context) { - throw new Error('useTheme must be used within ThemeProvider'); - } - - return context; -} diff --git a/openflare-server/web/components/ui/app-card.tsx b/openflare-server/web/components/ui/app-card.tsx deleted file mode 100644 index 6cfa257b..00000000 --- a/openflare-server/web/components/ui/app-card.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { HTMLAttributes, ReactNode } from 'react'; - -import { cn } from '@/lib/utils/cn'; - -interface AppCardProps extends HTMLAttributes { - title?: string; - description?: string; - action?: ReactNode; -} - -export function AppCard({ - title, - description, - action, - className, - children, - ...props -}: AppCardProps) { - return ( -
- {(title || description || action) && ( -
-
- {title ? ( -

- {title} -

- ) : null} - {description ? ( -

- {description} -

- ) : null} -
- {action ?
{action}
: null} -
- )} -
{children}
-
- ); -} diff --git a/openflare-server/web/components/ui/app-modal.tsx b/openflare-server/web/components/ui/app-modal.tsx deleted file mode 100644 index d72b197c..00000000 --- a/openflare-server/web/components/ui/app-modal.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client'; - -import { useEffect, type ReactNode } from 'react'; - -import { cn } from '@/lib/utils/cn'; - -interface AppModalProps { - isOpen: boolean; - title: string; - description?: string; - children: ReactNode; - footer?: ReactNode; - onClose: () => void; - size?: 'md' | 'lg' | 'xl'; -} - -const sizeClassNameMap = { - md: 'max-w-2xl', - lg: 'max-w-4xl', - xl: 'max-w-5xl', -} satisfies Record, string>; - -export function AppModal({ - isOpen, - title, - description, - children, - footer, - onClose, - size = 'lg', -}: AppModalProps) { - useEffect(() => { - if (!isOpen) { - return; - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } - }; - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - window.addEventListener('keydown', handleKeyDown); - - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', handleKeyDown); - }; - }, [isOpen, onClose]); - - if (!isOpen) { - return null; - } - - return ( -
- -
-
- {children} -
- {footer ? ( -
- {footer} -
- ) : null} -
-
- ); -} diff --git a/openflare-server/web/components/ui/drawer.tsx b/openflare-server/web/components/ui/drawer.tsx deleted file mode 100644 index 98d778d4..00000000 --- a/openflare-server/web/components/ui/drawer.tsx +++ /dev/null @@ -1,336 +0,0 @@ -'use client'; - -import { - cloneElement, - createContext, - type HTMLAttributes, - isValidElement, - type ReactElement, - type ReactNode, - useContext, - useEffect, - useId, - useMemo, - useState, -} from 'react'; -import {createPortal} from 'react-dom'; - -import {cn} from '@/lib/utils/cn'; - -type DrawerDirection = 'top' | 'right' | 'bottom' | 'left'; - -type DrawerContextValue = { - open: boolean; - setOpen: (open: boolean) => void; - direction: DrawerDirection; -}; - -const DrawerContext = createContext(null); - -function useDrawerContext() { - const context = useContext(DrawerContext); - - if (!context) { - throw new Error('Drawer components must be used within Drawer.'); - } - - return context; -} - -function renderWithOptionalChild( - child: ReactNode, - props: Record, - fallback: ReactNode, -) { - if (isValidElement(child)) { - return cloneElement(child as ReactElement, props); - } - - return fallback; -} - -export function Drawer({ - children, - open, - defaultOpen = false, - onOpenChange, - direction = 'bottom', - title, - description, - footer, -}: { - children: ReactNode; - open?: boolean; - defaultOpen?: boolean; - onOpenChange?: (open: boolean) => void; - direction?: DrawerDirection; - title?: string; - description?: string; - footer?: ReactNode; - size?: 'md' | 'lg' | 'xl'; -}) { - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const isControlled = open !== undefined; - const resolvedOpen = isControlled ? open : internalOpen; - - const value = useMemo( - () => ({ - open: resolvedOpen, - direction, - setOpen: (nextOpen) => { - if (!isControlled) { - setInternalOpen(nextOpen); - } - onOpenChange?.(nextOpen); - }, - }), - [direction, isControlled, onOpenChange, resolvedOpen], - ); - - return ( - - {title || description || footer ? ( - - -
- {title ? {title} : null} - {description ? ( - {description} - ) : null} -
- -
-
- {children} -
- {footer ? {footer} : null} -
- ) : ( - children - )} -
- ); -} - -export function DrawerTrigger({ - children, - asChild = false, -}: { - children: ReactNode; - asChild?: boolean; -}) { - const { setOpen } = useDrawerContext(); - - if (asChild) { - return renderWithOptionalChild( - children, - { - onClick: () => setOpen(true), - }, - children, - ); - } - - return ( - - ); -} - -export function DrawerClose({ - children, - asChild = false, -}: { - children: ReactNode; - asChild?: boolean; -}) { - const { setOpen } = useDrawerContext(); - - if (asChild) { - return renderWithOptionalChild( - children, - { - onClick: () => setOpen(false), - }, - children, - ); - } - - return ( - - ); -} - -export function DrawerContent({ - children, - className, - 'aria-label': ariaLabel, -}: HTMLAttributes) { - const { open, setOpen, direction } = useDrawerContext(); - const titleId = useId(); - const descriptionId = useId(); - - useEffect(() => { - if (!open) { - return; - } - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setOpen(false); - } - }; - - window.addEventListener('keydown', handleKeyDown); - - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', handleKeyDown); - }; - }, [open, setOpen]); - - if (!open || typeof document === 'undefined') { - return null; - } - - const positionClassName = - direction === 'right' - ? 'inset-y-0 right-0 h-full border-l' - : direction === 'left' - ? 'inset-y-0 left-0 h-full border-r' - : direction === 'top' - ? 'inset-x-0 top-0 border-b' - : 'inset-x-0 bottom-0 border-t'; - - return createPortal( -
-
, - document.body, - ); -} - -type DrawerMetaContextValue = { - titleId: string; - descriptionId: string; -}; - -const DrawerMetaContext = createContext(null); - -function useDrawerMetaContext() { - const context = useContext(DrawerMetaContext); - - if (!context) { - throw new Error( - 'Drawer title and description must be used within DrawerContent.', - ); - } - - return context; -} - -export function DrawerHeader({ - className, - ...props -}: HTMLAttributes) { - return ( -
- ); -} - -export function DrawerFooter({ - className, - ...props -}: HTMLAttributes) { - return ( -
- ); -} - -export function DrawerTitle({ - className, - ...props -}: HTMLAttributes) { - const { titleId } = useDrawerMetaContext(); - - return ( -

- ); -} - -export function DrawerDescription({ - className, - ...props -}: HTMLAttributes) { - const { descriptionId } = useDrawerMetaContext(); - - return ( -

- ); -} diff --git a/openflare-server/web/components/ui/input.tsx b/openflare-server/web/components/ui/input.tsx deleted file mode 100644 index 0283183c..00000000 --- a/openflare-server/web/components/ui/input.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; - -import {cn} from '@/lib/utils/cn'; - -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - - ); - }, -); -Input.displayName = 'Input'; - -export { Input }; diff --git a/openflare-server/web/components/ui/label.tsx b/openflare-server/web/components/ui/label.tsx deleted file mode 100644 index fcc43a1c..00000000 --- a/openflare-server/web/components/ui/label.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; - -import {cn} from '@/lib/utils/cn'; - -const Label = React.forwardRef>( - ({ className, ...props }, ref) => ( -