diff --git a/.gitignore b/.gitignore index f91f5d0..9bbb3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,13 @@ .build/ dist/ *.fpk +*.log +app-src/.data/ +app-src/web/node_modules/ +app-src/web/dist/ +app-src/cmd/havline/web/dist/ +app-src/bin/ +app-src/*.exe +app-src/internal/frp/agentbin/ havline/app/server/amd64/havline havline/app/server/arm64/havline diff --git a/README.md b/README.md index 22bdd37..1f01dc7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ 本目录保存 Havline 的飞牛 Native 应用工程,不依赖 Docker。 +本仓库完全独立,包含: + +- `app-src/`:Havline 主程序源码副本 +- `havline/`:飞牛 Native 应用打包工程 +- `fnpack.exe`:飞牛打包工具 +- `build.ps1`:从 `app-src` 构建双架构二进制并打包 + +构建过程不再引用 `F:\Fonu-main\havline`。 + 阶段一范围: - Go 主程序原生运行 diff --git a/app-src/.dockerignore b/app-src/.dockerignore new file mode 100644 index 0000000..e7c15bc --- /dev/null +++ b/app-src/.dockerignore @@ -0,0 +1,16 @@ +.git +web/node_modules +web/dist +bin +*.db +/data +# 本地运行数据与归档,不进镜像(防止把真实数据库/证书/日志带入构建上下文) +.data +# 文档与本地杂项 +docs +# 本地导出/打包产物与日志(预防性:不进入构建上下文) +*.log +*.rar +*.zip +*.tar.gz +.breezell diff --git a/app-src/.gitattributes b/app-src/.gitattributes new file mode 100644 index 0000000..75dd0cf --- /dev/null +++ b/app-src/.gitattributes @@ -0,0 +1,2 @@ +# shell 脚本一律以 LF 入库与检出,避免在 Windows 上被 CRLF 破坏(Linux 端会报 bad interpreter) +*.sh text eol=lf diff --git a/app-src/.github/workflows/ci.yml b/app-src/.github/workflows/ci.yml new file mode 100644 index 0000000..74a7c88 --- /dev/null +++ b/app-src/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # 顺序不能颠倒:cmd/havline/main.go 用 //go:embed all:web/dist 嵌入前端, + # 干净检出时该目录不存在,go vet / go build 会直接失败。 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install web dependencies + working-directory: web + run: npm ci + + - name: Type-check and build web + working-directory: web + run: npm run build + + - name: Run web unit tests + working-directory: web + run: npm test + + - name: Sync web dist into embed directory + run: | + rm -rf cmd/havline/web/dist + cp -r web/dist cmd/havline/web/dist + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... -count=1 + + - name: go build + run: go build ./... + + # 说明:gofmt 卡口暂未开启——仓库里还有 25 个历史未格式化文件(见 docs/project-audit-2026-09-24.md §八), + # 全仓 `gofmt -w` 之后再补一步 `gofmt -l internal cmd` 作为门禁。 diff --git a/app-src/.github/workflows/docker-release.yml b/app-src/.github/workflows/docker-release.yml new file mode 100644 index 0000000..c62c5e9 --- /dev/null +++ b/app-src/.github/workflows/docker-release.yml @@ -0,0 +1,105 @@ +name: Docker Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v0.1.0)' + required: true + default: v0.1.0 + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: docker-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}-${{ matrix.arch }} + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + arch: amd64 + - platform: linux/arm64 + arch: arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + + - name: Resolve version + id: version + env: + REF_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + run: echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + if: matrix.arch == 'arm64' + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + build-args: | + VERSION=${{ steps.version.outputs.version }} + tags: ${{ secrets.DOCKERHUB_USERNAME }}/havline:${{ steps.version.outputs.version }}-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=${{ github.repositoryUrl }} + org.opencontainers.image.version=${{ steps.version.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + + create-manifest: + needs: build-and-push + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Resolve version + id: version + env: + REF_NAME: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + run: echo "version=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create multi-arch manifest + env: + IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/havline + VERSION: ${{ steps.version.outputs.version }} + run: | + if [ -z "${IMAGE}" ]; then + echo "DOCKERHUB_USERNAME secret is not set" >&2 + exit 1 + fi + docker buildx imagetools create \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:latest" \ + "${IMAGE}:${VERSION}-amd64" \ + "${IMAGE}:${VERSION}-arm64" diff --git a/app-src/.github/workflows/github-release.yml b/app-src/.github/workflows/github-release.yml new file mode 100644 index 0000000..541d06f --- /dev/null +++ b/app-src/.github/workflows/github-release.yml @@ -0,0 +1,51 @@ +name: GitHub Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v0.1.0)' + required: true + +concurrency: + group: github-release-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }} + cancel-in-progress: true + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + env: + VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ env.VERSION }} + + - name: Extract release notes from CHANGELOG + id: notes + run: | + if awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { found = 1; next } + found && /^## \[v/ { exit } + found { print } + ' CHANGELOG.md > release-body.md && [ -s release-body.md ]; then + echo "use_changelog=true" >> "$GITHUB_OUTPUT" + else + echo "use_changelog=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.VERSION }} + name: ${{ env.VERSION }} + body_path: ${{ steps.notes.outputs.use_changelog == 'true' && 'release-body.md' || '' }} + generate_release_notes: ${{ steps.notes.outputs.use_changelog != 'true' }} + draft: false + prerelease: false diff --git a/app-src/.gitignore b/app-src/.gitignore new file mode 100644 index 0000000..6e8dbfa --- /dev/null +++ b/app-src/.gitignore @@ -0,0 +1,31 @@ +bin/ +.data/ +web/node_modules/ +web/dist/ +cmd/havline/web/dist/ +dist/ +*.db +/data/ +# 会话密钥等本地环境变量(.env.example 这类模板可以照常入库) +.env +.env.local +.DS_Store +.cursor/ +.playwright-cli/ +output/ +web/public/111favicon.png +web/src/assets/brand/logo00.png +# scripts/ 下的本地辅助脚本不入库,但随仓库分发的 agent 安装脚本需要跟踪 +scripts/* +!scripts/agent-install.sh +!scripts/build-linux.ps1 +!scripts/havline-install.sh +!scripts/upgrade.sh +!scripts/upgrade-hub.sh +!scripts/push-oci-manual.ps1 +# 本地压缩包与导出产物不入库(如 web/src.rar) +*.rar +*.zip +*.7z +*.tar.gz +*.log diff --git a/app-src/CHANGELOG.md b/app-src/CHANGELOG.md new file mode 100644 index 0000000..deb873a --- /dev/null +++ b/app-src/CHANGELOG.md @@ -0,0 +1,1545 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### 变更 + +- **品牌全量切换为 Havline**:项目显示名、Go module、命令目录、二进制、环境变量、数据库、Cookie、Docker/Compose、Agent、Updater、脚本和当前文档统一切换到 `Havline / havline / HAVLINE_` +- **AgentVersion 提升到 0.15.0**:Agent 安装路径、配置目录、数据目录、systemd unit、Nginx 托管标记和 API 服务标识全部切换到 `havline-agent` +- **历史文档保留原品牌名**:`CHANGELOG.md` 历史版本、旧品牌评估文档和带日期的历史分析报告保留原始名称,作为项目沿革和 GPL 来源记录 + +## [v1.9.0] - 2026-10-01 + +### 新增 + +- **Cloudflare 隧道出口抽象**:反向代理规则新增 `local` / `cloudflare` 出口选择,支持同时使用本机 Nginx 与 Cloudflare 隧道;托管隧道从服务发布规则自动派生 ingress,规则新增、修改、删除、启停时自动同步 Cloudflare 云端与 DNS +- **Cloudflare 独立配置页**:隧道「编辑」改为独立普通页面 `/cloudflare/:id/config`,包含指标、基础配置、拓扑图、路由列表和运行控制;基础配置通过按钮打开弹窗,不再直接堆叠在页面中 +- **Cloudflare 路由管理增强**:新增路由添加、编辑、删除、上移、下移、复制和源服务 TCP 连通性测试;通配路径 `*` 只在界面显示,不再作为非法正则发送给 Cloudflare +- **Cloudflare 接入向导与预检**:新增 cloudflared、Account ID、API Token、Zone 权限检查,并在 Cloudflare 页面提供接入向导入口 +- **Cloudflare 调和中心**:隧道列表显示“调和”状态;检测本地规则与云端 ingress 漂移,支持以本地规则一键覆盖修复 +- **Cloudflare 运维能力**:新增隧道健康巡检、24 小时可用率、断连/恢复通知、状态页事故记录、仪表盘摘要、日志中心分页、漂移检测、一键诊断、Access 只读检测、Zone 列表和 DNS 记录归属管理 +- **cloudflared 二进制管理**:支持查询最新版本、指定版本下载、保留上一版回滚;Cloudflare 页面显示当前版本并支持检查更新 +- **NAS 服务发现增强**:服务发现目录新增 Home Assistant、Immich、AdGuard Home、Synology DSM 等常见 NAS 应用;扫描后可按 Cloudflare 出口预填服务发布规则 +- **备份恢复增强**:备份归档加入 `cloudflared/tunnels`,保留隧道 `config.yml`、`creds.json` 与相关数据库记录 +- **Cloudflare 拓扑图**:按“域名 → 出口 → 隧道 → 服务”展示服务发布链路,支持点阵画布、节点和曲线连接 + +### 修复 + +- **Cloudflare 全局启动策略**:全局托管开启或 Fonu 启动时,默认启动全部隧道;关闭时停止全部隧道并阻止应用重启自动拉起 +- **Cloudflare 路由保存提示**:规则本体保存成功但 Cloudflare 同步失败时返回 `sync_warning` 并在前端提示,不再把已保存的规则判为完全失败 +- **Cloudflare metrics 端口识别**:兼容 `Starting metrics server...` 大小写日志格式,修复 metrics 端口、连接数、传输协议无法采集的问题 +- **Cloudflare DNS 安全清理**:只删除 Fonu 接管或创建的 DNS 记录,不覆盖用户已有的其他目标 CNAME +- **Cloudflare 隧道删除保护**:仍被反向代理规则引用的托管隧道不允许删除或切换为手工隧道 + +### 测试 + +- 新增 Cloudflare 出口默认值与持久化、托管规则生成 ingress、健康状态与可用率、路由解析、通配 path、DNS 记录归属、漂移比较、备份包含隧道配置等测试 +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test -- --run`、`npm run build` 通过 + +### 变更 + +- 版本号提升到 1.9.0;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化 + +## [v1.8.0] - 2026-09-30 + +### 新增 + +- **Cloudflare 隧道全局托管开关**:页面顶部新增「全局托管」开关: + - 关闭时立即停止所有隧道进程,应用重启后不再自动拉起;单条隧道的「启动」按钮同时禁用 + - 开启时只恢复标记了 `auto_start` 的隧道,其余保持停止 + - 只影响本机 cloudflared 进程托管,不删除本地配置、凭据、云端隧道或 DNS 记录 +- 新增 `cf_settings.enabled` 字段(迁移 `034`)与 `POST /api/cloudflare/enabled` 接口 + +### 变更 + +- 版本号提升到 1.8.0;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- 新增「全局停用时拒绝启动」单元测试 +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.7.0] - 2026-09-28 + +### 新增 + +- **自动创建 Cloudflare DNS 记录**:创建隧道时,Fonu 会为每个暴露域名查找所属 Cloudflare Zone(从 hostname 逐级向上)并幂等创建 / 更新 CNAME: + - 目标为 `.cfargotunnel.com` + - 自动开启代理(橙云) + - 同名 CNAME 已存在时更新而不是重复创建 + - 编辑隧道时同步 DNS;创建失败只在页面提示警告,不影响隧道本身 +- API Token 权限说明更新为:`Account: Cloudflare Tunnel: Edit`、`Zone: Zone: Read`、`Zone: DNS: Edit` + +### 变更 + +- 版本号提升到 1.7.0;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.9] - 2026-09-28 + +### 修复 + +- **测试连接未使用当前输入的 Account ID**:测试连接原先只读取数据库中已保存的 Account ID,未保存直接点测试会误报「请先填写 Account ID」。现在前端会把输入框里的 Account ID 与 API Token 一起提交,后端优先使用输入值,留空时才回退到已保存配置 + +### 变更 + +- 版本号提升到 1.6.9;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.8] - 2026-09-28 + +### 变更 + +- **Cloudflare 凭据模型改为 Account ID + API Token**:应用配置不再要求 Tunnel Token;Fonu 用 Account ID + API Token 调用 Cloudflare API: + - 「测试连接」调用 `GET /accounts/{account_id}/tokens/verify` + - 新建隧道时自动创建 `config_src=local` 的命名隧道、获取凭据、生成本地 `config.yml` 与 `creds.json` + - 删除隧道时同步删除云端隧道;编辑时同步推送 ingress +- 隧道弹窗去掉 Token 与模式选择,只保留名称、暴露域名、回源 service 与自动启动 +- 新增 `cf_settings.account_id` 字段(迁移 `033`) + +### 变更 + +- 版本号提升到 1.6.8;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.7] - 2026-09-28 + +### 新增 + +- **API Token 连接验证**:应用配置的 Token 区域拆分为两个输入: + - 隧道 Token(Tunnel Token):用于 cloudflared 运行 + - API Token(`cfat_...`):用于连接验证,调用 Cloudflare 官方 `GET /user/tokens/verify` 接口 +- 「测试连接」优先使用 API Token;未配置 API Token 时回退到隧道 Token 的临时连接测试;输入以 `cfat_` 开头时也会自动走 API 验证 +- 新增 `cf_settings.api_token_enc` 字段(迁移 `032`),API Token 独立加密存储与掩码回显 + +### 变更 + +- 版本号提升到 1.6.7;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.6] - 2026-09-28 + +### 修复 + +- **Token 解码兼容 URL-safe base64**:Cloudflare 面板生成的 Tunnel Token 使用 URL-safe base64(含 `-` / `_`),原先只按标准 base64 解码,会报 `illegal base64 data at input byte N` 并返回 502。现在同时兼容标准 / URL-safe 与有无填充四种形式,失败时给出更明确的提示 + +### 变更 + +- 版本号提升到 1.6.6;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- 新增 URL-safe 与无填充 token 解码测试 + +## [v1.6.5] - 2026-09-28 + +### 新增 + +- **Token 配置连接入口**:应用配置的 Token 区域新增两个入口: + - 「测试连接」:后端用 cloudflared 临时启动一次连接,最多等待 25 秒,确认 token 能注册到 Cloudflare 边缘;成功后显示隧道 ID,失败给出明确原因 + - 「获取 Token」:直达 Cloudflare Zero Trust 面板 +- 新增 `POST /api/cloudflare/settings/test` 接口;请求体 token 留空时使用已保存的默认 token + +### 变更 + +- 版本号提升到 1.6.5;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.4] - 2026-09-28 + +### 变更 + +- **新建 / 编辑隧道弹窗去重**:移除与应用配置重复的 Token 输入与网络调优字段;Token、传输协议、边缘地址族、HA 连接数、代理模式统一在「应用配置」中维护,保存时自动应用到新建或编辑的隧道 +- 新建隧道前若未配置默认 Token,弹窗内直接提示并阻止提交;编辑已有隧道不受影响 +- 隧道弹窗现在只保留名称、模式、暴露域名、回源 service 与自动启动 + +### 变更 + +- 版本号提升到 1.6.4;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.3] - 2026-09-28 + +### 新增 + +- **Cloudflare 应用配置**:「cloudflared 二进制」弹窗升级为「应用配置」,包含三块: + - 程序安装:当前版本、下载镜像、下载并安装 + - Token 配置:保存模块级默认 token,掩码回显;新建隧道时可以留空,直接使用默认 token + - 默认网络设置:传输协议、边缘地址族、HA 连接数、代理模式,作为新建隧道的默认值 +- 新增 `cf_settings` 表(迁移 `031`)与 `GET / PUT /api/cloudflare/settings` 接口 + +### 变更 + +- 版本号提升到 1.6.3;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- 新增应用配置与默认 token 的端到端单元测试 +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.2] - 2026-09-28 + +### 修复 + +- **Cloudflare 隧道弹窗宽度**:`n-modal` 的宽度原先写在 scoped class 上,没有传到 naive-ui 的 modal 根节点,部分视口下弹窗被拉成 100%。现在改为与其他页面一致的 `:style` 写法:编辑弹窗 `min(720px, 96vw)`、详情弹窗 `min(760px, 96vw)`、二进制弹窗 `min(560px, 94vw)` + +### 变更 + +- 版本号提升到 1.6.2;`AgentVersion` 保持 **0.14.1** + +### 测试 + +- `npm run typecheck`、`npm run build` 通过 + +## [v1.6.1] - 2026-09-28 + +### 修复 + +- **Cloudflare 隧道弹窗完善**:编辑弹窗回填已配置的暴露域名与回源 service(后端从 `config.yml` 解析);按「基本信息 / 暴露规则 / 网络调优」分组,增加模式说明;云端规则模式隐藏本地域名配置并提示规则由 Cloudflare 面板管理 +- **详情弹窗**:改为状态卡 + `config.yml` / 日志分页,日志每 3 秒自动刷新,支持一键复制配置,刷新按钮带加载状态 +- **二进制弹窗**:显示当前版本与目标文件;镜像列表为空时回退官方源 +- **保存校验**:提交前校验名称、token 与本地规则模式的暴露域名 + +### 变更 + +- 版本号提升到 1.6.1;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化 + +### 测试 + +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +## [v1.6.0] - 2026-09-28 + +### 新增 + +- **Cloudflare 隧道独立模块(阶段 1–2)**:新增 `internal/cloudflared` 包,覆盖 token 解码、`creds.json` / `config.yml` 生成、ingress 生成器、SQLite 存储、cloudflared 二进制下载(镜像表 + 大小与可执行校验)、子进程托管、日志追加、metrics 端口发现与健康抓取 +- 新增 `cf_tunnels` 表与 `/api/cloudflare/*` 接口:列表、创建、更新、删除、启动、停止、重启、状态、日志、配置预览、二进制信息与下载、ingress 候选域名 +- 前端新增「Cloudflare 隧道」页面:隧道列表、创建 / 编辑、启停、配置预览、日志、二进制下载与网络调优设置 +- 支持 `token-local`(Fonu 生成本地 ingress)与 `token-remote`(Cloudflare 面板管理规则)两种模式;界面显示模式差异 +- 网络调优默认值:`http2` / IPv4 / HA 2 / 跟随系统代理;ingress 锁定 catch-all、`127.0.0.1` 归一化与稳定排序三条不变式 + +### 变更 + +- 版本号提升到 1.6.0;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化 + +### 测试 + +- `internal/cloudflared`:token 解码、ingress 不变式、config.yml、metrics 解析、启动参数、镜像 URL、代理禁用单元测试 +- `go test ./...`、`go vet ./...`、`npm run typecheck`、`npm test`、`npm run build` 通过 + +### 未实施 + +- 账号模式(`cert.pem`)与云端 ingress 的 fetch / push +- 边缘 IP 优选、快速隧道、自动 DNS + +## [v1.5.4] - 2026-09-28 + +### 变更 + +- **P1-5 统一前端轮询组合式函数**:`useVisibilityPolling` 重写为支持 `enabled` / `immediate` / `onError` 选项,加入 in-flight 互斥,返回 `start` / `stop` / `busy` / `lastError`;核心逻辑抽成 `createVisibilityPoller`,可在无 DOM 环境下测试 +- Dashboard、Proxy、Logs、Settings、Frp、UpdateModal、Agent 的轮询全部迁移到组合式函数;仅 AppLayout 时钟保留原生计时器 +- 慢接口不再堆积:上一轮未结束时跳过下一轮;页面隐藏时暂停,回到前台立即补一次 + +### 测试 + +- 新增 6 项组合式函数单元测试:隐藏不执行、回到前台补一次、in-flight 互斥、`enabled=false`、卸载清理、`onError` 后继续轮询 +- `npm run typecheck`、`npm test`(25 项)、`npm run build` 通过 + +### 变更 + +- 版本号提升到 1.5.4;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化 + +## [v1.5.3] - 2026-09-28 + +### 新增 + +- **P1-4 路由鉴权覆盖测试**:API 路由收敛为可遍历路由表,新增 `internal/api/router_auth_test.go` 遍历 100+ 受保护路由,匿名请求必须返回 401,公开路由不被鉴权误拦;Agent 路由收敛为 `agentRoutes` 声明,新增 `internal/agent/router_auth_test.go` 遍历全部 `/api/v1/*` 验证无 Token 必须 401 +- **配置回滚测试**:本机新增 `internal/nginx/versions_test.go`,覆盖版本列表 / 读取、非法版本名拒绝路径上跳、语法校验失败不覆盖当前配置、成功回滚保留当前版本;Agent 新增写 vhost 前保存版本、校验失败恢复上一可用版本、首次部署失败删除新文件、回滚后执行 reload 的测试 +- **Agent 版本兼容测试**:新增 `internal/frp/agent_versions_compat_test.go`,覆盖 404 / 405 判定端点缺失并提示最低版本与「升级 Agent」、400 / 500 不误判、状态接口 `agent_version` 解析 +- **前端版本字段降级测试**:新增 `agentVersionText` 工具与测试,旧 Agent 缺少 `agent_version` 时显示占位符而不是空白 + +### 变更 + +- 版本号提升到 1.5.3;`AgentVersion` 保持 **0.14.1**,本次无 Agent 行为变化 +- Agent 的 nginx 校验与 reload 增加测试注入钩子;生产路径仍调用真实 nginx,行为不变 + +### 测试 + +- `go test ./...`、`go vet ./...` 通过 +- `npm run typecheck`、`npm test`(19 项)、`npm run build` 通过 + +## [v1.5.2] - 2026-09-28 + +### 修复 + +- **升级 Agent 不再强制切回 SSH 隧道**:HTTPS 直连模式下点击「升级 Agent」原先会无条件建立隧道并把 transport 改回 `tunnel`,用户升级后需要重新切换 HTTPS。现在升级前是 `https-pin` 时会保持 HTTPS 直连,只更新 Token 密文并重新验证连通 +- **清理隧道模式下残留的 HTTPS 反代**:旧升级流程切回隧道时没有关闭 Agent 上的 `fonuagent-https.conf`,导致 7443 等端口继续暴露。现在从 HTTPS 迁移到隧道、应用启动恢复隧道、手动「重启隧道」时都会通过隧道调用 `DisableHTTPS` 清理残留 + +### 变更 + +- 版本号提升到 1.5.2;`AgentVersion` 保持 **0.14.1**,本次是中央端修复,不需要重新升级 Agent + +### 测试 + +- `internal/frp` 现有单元测试与编译校验通过;升级分支依赖真实 SSH/Agent 环境,未新增本地集成测试 + +## [v1.5.1] - 2026-09-28 + +### 变更 + +- **P1-1 共享 Nginx 模板包**:新增纯生成包 `internal/nginxtmpl`,统一 TLS 指令、安全响应头、allow / deny、WebSocket 头、限流指令与 zone 命名;本机 `internal/nginx` 与公网 Agent 均改为调用共享函数,重复模板实现已删除 +- **安全响应头对齐**:两端统一输出 HSTS、`X-Content-Type-Options`、`X-Frame-Options`、`Referrer-Policy`,本机此前缺少 `Referrer-Policy` +- **访问控制顺序统一**:统一为黑名单 `deny` → 仅大陆规则 → 白名单 `allow` → `deny all`,黑名单优先匹配 +- **TLS 指令显式化**:两端统一显式输出 `ssl_protocols TLSv1.2 TLSv1.3` 或 `TLSv1.3` +- 版本号提升到 1.5.1;`AgentVersion` 0.14.0 → **0.14.1**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/nginxtmpl`:新增共享函数的 TLS、安全头、访问控制、限流、zone 与 WebSocket 特征化测试 +- `internal/nginx`:本机生成测试继续通过;`internal/agent`:新增 vhost 顺序与共享语义测试 + +## [v1.5.0] - 2026-09-28 + +### 新增 + +- **HTTPS 直连端口自动选择**:Agent 在 `7443` / `8443` / `9443` 中逐个尝试可用端口,每轮都会写配置、reload 并做本机证书自检;全部失败时返回每个端口的具体原因。显式传入端口时仍按指定值执行,`443` 可通过接口显式指定 +- **证书自动轮换**:证书有效期 365 天,剩余 30 天时后台自动生成新证书并通过当前连接安装;过渡期 7 天内客户端同时接受新旧 pin,避免轮换瞬间连接失败。旧记录缺少到期信息时,先从 Agent 读取当前证书到期时间再决定是否轮换 +- **主机防火墙辅助**:Agent 新增防火墙检测与显式放行接口,支持 `ufw` / `firewalld` 自动放行;`nftables` / `iptables` 只检测和提示,不自动修改规则。Agent 页增加「检测主机防火墙 / 放行主机防火墙」 +- **隧道后台保活与重连**:隧道启动后每 30 秒保活,断线按 1 秒到 60 秒指数退避重建,不再只在有流量时才尝试重连 +- **传输详细状态接口**:`GET /api/frpmulti/servers/{id}/agent/transport` 返回 transport、状态、端口、pin、证书到期时间与防火墙状态 +- **审计记录**:transport 切换、证书轮换、防火墙放行写入 AUDIT 日志,包含 admin_id、server_id 与来源地址 +- **证书 SAN 校验**:pin 校验同时校验证书 SAN 与连接主机一致 + +### 变更 + +- 版本号提升到 1.5.0;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.13.3 → **0.14.0**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 +- Agent 页轮询改用 `useVisibilityPolling`,页面隐藏时暂停 + +### 测试 + +- `internal/frp`:新增多 pin 过渡、SAN 拒绝、端口解析与轮换兼容测试 +- `internal/agent`:新增 ufw 解析与候选端口测试 +- `internal/frp`:隧道退避策略单元测试 + +## [v1.4.6] - 2026-09-28 + +### 修复 + +- **HTTPS 直连证书指纹不匹配可定位**:nginx 配置校验通过、但公网 `7443` 返回的证书与刚写入的不一致时,原来只提示“证书指纹不匹配”,无法判断是 nginx 未生效还是端口被其他服务接管。现在 HTTPS server 使用 `default_server` 绑定,Agent 会在 reload 后从本机 `127.0.0.1:<端口>` 握手自检并重试 5 秒;指纹不一致时回滚,并回报实际指纹与 `ss -tlnp` 端口占用进程 +- **公网链路错误提示**:主程序在 Agent 本机自检通过、但公网地址校验失败时,会明确提示检查 `7443` 的端口转发、防火墙或其他服务接管 + +### 变更 + +- 版本号提升到 1.4.6;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.13.2 → **0.13.3**,内嵌 amd64 / arm64 二进制已重编;VPS 上需重新点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/agent`:新增证书 PEM 指纹计算测试 + +## [v1.4.5] - 2026-09-28 + +### 修复 + +- **Agent 探测阶段无法自愈 nginx 残留目录**:0.13.1 的迁移只在配置目录已知或 `nginx -t` 校验前执行;当 `nginx -T` 在探测目录阶段就因 `.versions` 目录失败时,Agent 仍返回 `nginx -T 执行失败: exit status 1`。现在会先从 `nginx -T` 报错文本解析出 `<配置>.versions` 路径并迁移,再重试一次;安装 / 升级 Agent 的脚本也会先把 `sites-enabled`、`conf.d` 下的旧 `.versions` 目录移出 include 范围 + +### 变更 + +- 版本号提升到 1.4.5;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.13.1 → **0.13.2**,内嵌 amd64 / arm64 二进制已重编;VPS 上需重新点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/agent`:新增从 nginx 报错文本解析并迁移旧版本目录的测试 +- `scripts/agent-install.sh`:`bash -n` 语法校验通过 + +## [v1.4.4] - 2026-09-28 + +### 修复 + +- **Agent nginx 版本目录被误加载**:切换 HTTPS 直连时,配置备份会写到 `<配置文件>.versions` 目录;该目录位于 `sites-enabled` 时会被 nginx 的 `include .../sites-enabled/*` 当成配置文件读取,导致 `nginx -t` 报 `pread() ".../fonuagent-https.conf.versions" failed (21: Is a directory)`。现在版本目录改为隐藏目录 `.<文件名>.versions`,`include *` 不再匹配;旧目录会在读取、备份或 `nginx -t` 前自动迁移,历史版本内容保留 + +### 变更 + +- 版本号提升到 1.4.4;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.13.0 → **0.13.1**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/fsutil`:新增隐藏版本目录与旧目录迁移测试 +- `internal/agent`:新增 `migrateLegacyVersionDirs` 迁移测试 + +## [v1.4.3] - 2026-09-28 + +### 修复 + +- **HTTPS 直连旧地址自愈**:从 SSH 隧道切换到「HTTPS + 证书固定」后,历史实例的 `agent_url` 可能仍为已失效的 `http://127.0.0.1:17701`,隧道关闭后直接报 `connection refused`。现在读取 HTTPS transport 时会依据 `ssh_host` 与 `agent_listen_addr` 自动重建 `https://:`,并在地址变化时回写数据库;切回 SSH 隧道也使用同一重建逻辑,无需手工修改地址 + +### 变更 + +- 版本号提升到 1.4.3;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.13.0 + +### 测试 + +- `internal/frp`:新增 `TestAgentHTTPSBaseURL`,覆盖旧隧道地址重建、已是 HTTPS 地址保持、默认端口回退与缺少 SSH 主机的情况 + +## [v1.4.2] - 2026-09-28 + +### 修复 + +- **切换 HTTPS 后 Agent URL 未更新**:启用 HTTPS 直连时只更新了 transport 和证书 pin,数据库里的 `agent_url` 仍保留旧的 `http://127.0.0.1:17701`。隧道关闭后仍访问旧本地地址,导致 `connect: connection refused`。现在 transport、URL、端口和 pin 同批更新,并在切换后清理旧 AgentClient 缓存 +- **切回 SSH 隧道时同步恢复本地 URL**:恢复隧道会重新写入 `http://127.0.0.1:`,避免残留 HTTPS 地址影响后续状态读取 + +### 变更 + +- 版本号提升到 1.4.2;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.13.0 + +### 测试 + +- `internal/frp`:transport 切换与缓存失效编译验证 + +## [v1.4.1] - 2026-09-28 + +### 修复 + +- **SSH 隧道端口分配竞态**:原来先探测端口再关闭 listener,随后才真正启动隧道,两个安装动作或残留占用可能在两步之间抢到同一端口,报 `bind: Only one usage of each socket address`。现在改为分配端口时直接持有 listener 并交给隧道使用;17700 被占用时自动尝试后续端口 + +### 变更 + +- 版本号提升到 1.4.1;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.13.0 + +### 测试 + +- `internal/frp`:隧道 listener 复用、端口占用与释放回归测试 + +## [v1.4.0] - 2026-09-28 + +### 新增 + +- **Agent 默认监听回环**:`fonu-agent` 默认改为 `127.0.0.1:7700`;监听非回环地址必须显式设置 `FONU_AGENT_ALLOW_REMOTE=1`,状态接口新增监听地址、监听范围和传输安全字段 +- **SSH 隧道自动接入**:中央 Fonu 安装或升级 Agent 后自动分配 `17700-18700` 范围内的本地回环端口,通过现有 SSH 凭据建立隧道访问 VPS 的 `127.0.0.1:7700`,无需新增公网端口、证书或防火墙规则 +- **隧道生命周期管理**:应用启动后自动恢复 tunnel transport;应用退出时统一关闭;每个服务端支持单独的「重启隧道」操作 +- **旧公网 HTTP 自动迁移**:旧 Agent 点击「升级 Agent」后安装脚本会备份旧 `agent.env`、改写为回环监听并自动建立隧道;隧道验证失败时恢复旧监听配置 +- **可选 HTTPS 直连 + 证书指纹固定**:安装后可从 SSH 隧道切换到 HTTPS 直连。Fonu 自动生成 ECDSA 自签名证书、通过现有安全连接下发,VPS Agent 自动写入独立 Nginx TLS 反代;后续连接按证书 SHA-256 pin 校验,不使用 `InsecureSkipVerify` 裸跳过 +- **防火墙提示边界**:HTTPS 模式明确提示云安全组需放行 TCP 7443;Fonu 不自动修改云安全组,主机防火墙自动处理仍留在后续阶段 + +### 变更 + +- 数据库新增 Agent transport / 本地端口 / TLS pin / 监听地址 / 连接状态 / 连接错误字段(迁移 028) +- 前端「公网 Agent」页显示传输方式、隧道状态、本地入口和 Agent 监听地址;旧公网 HTTP 显示迁移警告 +- 版本号提升到 1.4.0;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.12.9 → **0.13.0**,内嵌 amd64 / arm64 二进制已重编;VPS 上需升级 Agent + +### 测试 + +- `internal/agent`:回环默认值与远程监听保护 +- `internal/frp` / `internal/app`:隧道生命周期、transport 存储、证书生成与 pin 校验测试 + +## [v1.3.9] - 2026-09-28 + +### 修复 + +- **限流状态损坏时拒绝覆盖**:`readLimitState` 原来忽略 JSON 解析错误并返回空 map,保存任一规则时可能把其他域名的限流配置清空。现在文件不存在仍视为空状态,读取失败、空文件或 JSON 损坏则返回错误,`applyLimitState` / `clearLimitState` 会保留原文件与现有 Nginx zone,不再覆盖 +- **一键升级宿主机目录自动发现**:当 Compose 标签里的 `working_dir` 是 `/workspace` 这类容器路径时,不再直接把它当宿主机目录;优先使用 `project.config_files` 推导真实目录,无法得到宿主机路径时明确报错,避免 helper 挂载 `/workspace:/workspace` 后找不到 Compose 文件 + +### 变更 + +- 版本号提升到 1.3.9;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.12.8 → **0.12.9**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/agent`:新增限流状态文件缺失、正常解析、空文件、截断 JSON、null 内容与失败后原文件不变的测试 + +## [v1.3.8] - 2026-09-28 + +### 新增 + +- **示例页面 v1.3.8 验证标识**:页面版本提示、「本次升级验证」区块、页面状态和验证标识同步更新为 `v1.3.8-example-page`,用于再次验证线上升级后的前端资源切换 + +### 变更 + +- 版本号提升到 1.3.8;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8 + +### 测试 + +- 前端类型检查、单元测试与生产构建 + +## [v1.3.7] - 2026-09-28 + +### 新增 + +- **示例页面新增独立升级验证区块**:页面增加「本次升级验证」高亮区块,显示 `v1.3.7`,验证标识更新为 `v1.3.7-example-page`,用于直观确认升级后的前端资源已切换 + +### 修复 + +- **复用原 Compose 项目名重建容器**:updater 在通过 helper 容器执行 `docker compose` 时,先从现有 `fonu` 容器读取 `com.docker.compose.project` 标签,并显式传入同一个项目名,避免 Compose 将现有容器视为另一个项目后重新创建网络、数据卷并撞上 `/fonu` 容器名冲突。现有容器不是 Compose 管理时,updater 会拒绝升级并提示按 Compose 方式重新部署,避免误建空数据卷 + +### 变更 + +- 版本号提升到 1.3.7;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8 + +### 测试 + +- 前端类型检查、单元测试与生产构建 + +## [v1.3.6] - 2026-09-28 + +### 修复 + +- **自动定位宿主机 Compose 目录**:当 updater 容器内的 `/workspace` 没有挂载到真实 Compose 目录时,不再直接失败。updater 会从 `fonu` 容器的 `com.docker.compose.project.working_dir` / `project.config_files` 标签读取宿主机路径,并通过临时 helper 容器挂载真实目录执行 `docker compose`。`FONU_UPDATE_HOST_DIR` 仍可作为显式覆盖 +- 示例页验证标识更新为 `v1.3.6-example-page` + +### 变更 + +- 版本号提升到 1.3.6;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8 + +### 测试 + +- `internal/updater`:覆盖 Compose 文件直挂载与宿主机目录 helper 模式 + +## [v1.3.5] - 2026-09-28 + +### 新增 + +- **示例页面升级验证内容**:页面版本提示与验证标识更新为 `v1.3.5-example-page`,新增「页面状态」统计卡和「本次示例页变化」说明,便于确认升级后的前端资源已经切换 + +### 变更 + +- 版本号提升到 1.3.5;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8 + +### 测试 + +- 前端类型检查、单元测试与生产构建 + +## [v1.3.4] - 2026-09-28 + +### 修复 + +- **一键升级找不到 Compose 文件**:fnOS 或 Docker UI 启动 Compose 时,updater 的 `.:/workspace` 可能解析到错误目录,构建时报 `open /workspace/docker-compose.hub.yml: no such file or directory`。Compose 文件现支持用 `FONU_UPDATE_HOST_DIR` 显式指定宿主机 Compose 目录,并在状态接口中提前报告缺少 Compose 文件,避免排到升级后才失败 +- **示例页验证标识更新为 v1.3.4-example-page**,用于确认升级后的前端资源确实生效 + +### 变更 + +- 版本号提升到 1.3.4;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8 + +### 测试 + +- `internal/updater`:补充 Compose 文件缺失检查 + +## [v1.3.3] - 2026-09-27 + +### 新增 + +- **示例页面升级验证标识**:示例页面新增固定的 `v1.3.3-example-page` 验证标识与展示项,升级后可直接通过标识区分新旧前端资源,确认页面、路由和静态资源均已更新 + +### 变更 + +- 版本号提升到 1.3.3;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化 + +### 测试 + +- 前端类型检查、单元测试与生产构建 + +## [v1.3.2] - 2026-09-27 + +### 修复 + +- **一键升级无法读取远端版本**:`fonu-updater` 检查更新时原来会 `git clone --depth 1` 整个仓库,而状态接口的父级超时只有 20 秒,克隆未完成即被 `signal: killed`,表现为 `fatal: early EOF`,前端因此一直不显示「一键升级」。现改为优先通过 Gitea API / raw 地址或 GitHub raw 读取 `VERSION`,显著降低检查耗时,不再依赖完整克隆 + +### 变更 + +- 版本号提升到 1.3.2;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化 +- `fonu-updater` 位于 sidecar,不能通过自身升级自身;已有部署需要先手动重建一次 `updater` 服务,之后该修复才生效 + +### 测试 + +- `internal/updater`:新增 Gitea API / raw 地址解析、文本与 base64 JSON 版本内容解析测试 + +## [v1.3.1] - 2026-09-27 + +### 新增 + +- **示例页面(在线升级验证页)**:新增 `/example` 页面与侧栏「示例页面」入口,页面实时读取 `/api/version`,展示当前版本、验证步骤和验证结果,用于确认一键升级后前端路由、菜单与后端版本均已更新 + +### 变更 + +- 版本号提升到 1.3.1;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化 + +### 测试 + +- 前端类型检查、单元测试与生产构建 + +## [v1.3.0] - 2026-09-27 + +### 新增 + +- **Web 一键升级(Docker Compose)**:新增受限的 `fonu-updater` 升级 sidecar,支持 `docker-compose.yml`(本机源码构建)与 `docker-compose.hub.yml`(Git 仓库构建)两种部署。主程序只通过共享 Unix Socket 调用 sidecar,不直接挂载 Docker Socket;升级时只重建 `fonu` 服务,构建或健康检查失败会自动尝试回滚到升级前的镜像 tag +- 前端侧栏在检测到可升级版本时显示「一键升级」入口,升级弹窗展示当前版本、目标版本、升级方式、阶段消息与最近日志;服务重启期间浏览器会自动等待恢复 +- 新增 `docs/system-upgrade.md`,说明首次部署 sidecar、安全边界、升级流程、回滚和常见问题 + +### 变更 + +- 版本号提升到 1.3.0;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化 + +### 注意 + +- 已经运行旧版本的实例,需要先手动更新 Compose 文件并执行一次 `docker compose up -d --build`,让 `fonu-updater` sidecar 启动。此后才可以在 Web 页面一键升级 + +### 测试 + +- `internal/updater`:版本比较、compose 路径白名单、配置校验 + +## [v1.2.21] - 2026-09-27 + +### 修复 + +- **Agent Bearer Token 改为常量时间比较**:`internal/agent` 原来用普通字符串比较校验 Token,现将 Authorization 解析与 Token 比较拆分,改用 `crypto/subtle.ConstantTimeCompare`,避免认证路径上的长度与内容时序泄漏 + +### 变更 + +- 版本号提升到 1.2.21;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 0.12.7 → **0.12.8**,内嵌 amd64 / arm64 二进制已重编;VPS 上需点一次「升级 Agent」才会生效 + +### 测试 + +- `internal/agent`:新增认证测试,覆盖正确 Token、大小写 Bearer、多余空白、错误 Token、长度不同、缺失 Authorization、错误认证方案 + +## [v1.2.20] - 2026-09-27 + +### 修复 + +- **DDNS 页 FRP 提示恢复**:`DdnsView.vue` 原来调用已退役的 `/api/frp`,请求失败后会把 `frpEnabled` 固定为 `false`,导致“FRP 内网穿透已启用”提示不再出现。现改用 `/api/frpmulti/status`,仅在“已配置服务端且存在启用规则”时显示,并补充判定函数单元测试 + +### 变更 + +- 版本号提升到 1.2.20;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- 本轮不涉及 Go 与 agent 行为,`AgentVersion` 仍为 0.12.7 + +## [v1.2.19] - 2026-09-27 + +### 新增 + +- **数据目录可选(默认行为不变)**:三个 compose 的挂载改为 `${FONU_DATA_PATH:-fonu-data}:/data`——默认仍是命名卷 `fonu-data`,**老用户升级后不受影响**;在同层 `.env` 里写 `FONU_DATA_PATH=/vol1/docker/fonu-data` 即可让数据落到指定宿主机目录(以 `/` 或 `.` 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷) +- **新增 `docs/container-deploy-and-data.md`(容器部署与数据指南)**:三个编排文件的分工与选择、干净机器单文件部署、数据目录(默认卷 / 指定目录 / 从卷迁移)、备份与恢复的三条注意、升级与回滚、环境变量速查、常见问题(401 加速器、`FONU_SESSION_SECRET` 报错、`fonu Pulling` 超时、换目录后「数据没了」、仪表盘三张卡为空)、与原生安装之间的迁移 + +### 变更 + +- 版本号提升到 1.2.19;`web/package.json` 同步对齐 +- 本轮不涉及 Go 代码(只改 compose 与文档),因此安装包未重出,`dist/` 里 1.2.17 的包仍可用;`AgentVersion` 仍为 0.12.7 + +## [v1.2.18] - 2026-09-27 + +### 修复 + +- **国内加速器上构建失败(401 Unauthorized)**:`Dockerfile` 顶部原来显式写了 `# syntax=docker/dockerfile:1`,这会让 BuildKit 额外去拉 `docker/dockerfile` 镜像,而在飞牛的 `docker.fnnas.com` 这类加速器上会被拦成 401(`resolve image config for docker-image://docker/dockerfile:1` 失败)。该行已移除:`RUN --mount=type=cache` 由 Docker 内建的 BuildKit frontend 已支持,不需要显式指定,缓存挂载保持不变 +- **compose 无谓地尝试拉镜像**:`docker-compose.hub.yml` 增加 `pull_policy: build`,跳过日志里那条 "fonu Pulling"(国内直连 Docker Hub 的超时告警),直接构建 + +### 变更 + +- 版本号提升到 1.2.18;`web/package.json` 同步对齐 +- 本轮**不涉及 Go 代码**(只改 Dockerfile 与 compose),因此安装包未重出,`dist/fonu-1.2.17-linux-*.tar.gz` 仍可用;`AgentVersion` 仍为 0.12.7 + +## [v1.2.17] - 2026-09-27 + +### 新增 + +- **会话密钥自动生成**:`FONU_SESSION_SECRET` 未配置时,应用首次启动会在数据目录里生成 32 字节随机密钥(64 字符)并持久化到 `<数据目录>/session_secret`(权限 0600,先写临时文件再改名;读到损坏内容会重新生成)。启动日志会写明密钥来源(环境变量 / 文件路径 / 仍在使用历史默认值的提示) +- **compose 改为「留空即自动」**:三个 compose 统一 `FONU_SESSION_SECRET: ${FONU_SESSION_SECRET:-}`;`docker-compose.hub.yml` 的单文件部署现在只要 `docker compose up -d --build`,**不再需要预先准备 `.env`** + +### 变更 + +- `FONU_SESSION_SECRET` 的默认值由固定的 `change-me-in-production` 改为空(空 = 未配置 → 自动生成) +- **不会自动替换已配置的值**:这个密钥同时是 `secret.NewBox` 的加密密钥,替换会让库里已加密的凭据(frp token、通知密码等)无法解密;对仍在使用历史默认值的实例只提示、不改数据 +- `.gitignore` 补上 `.env` 与 `.env.local`(`.env.example` 这类模板仍可入库) +- 版本号提升到 1.2.17;`web/package.json` 同步对齐 +- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.7 + +### 测试 + +- `internal/config`:密钥生成与复用、文件权限(类 Unix 上校验 0600)、显式配置优先且不写文件、历史默认值保留并提示、损坏文件重新生成 + +## [v1.2.16] - 2026-09-27 + +### 改进 + +- **`docker-compose.hub.yml` 改为「单文件从仓库拉取源码构建」**:`build.context` 直接用 Git 地址(`http://45.207.223.190:10082/mrlee/-Fonu.git#main`),由 BuildKit 自己克隆后再构建——干净机器上**只需要这一个 compose 文件 + 一个 `.env`**,不必先手动 `git clone`。升级也是回该目录重跑 `docker compose up -d --build`(会重新拉 `main` 的当前代码)。文件头补齐了完整用法 +- **会话密钥不再静默回退**:这一份把 `FONU_SESSION_SECRET` 写成必填(`${FONU_SESSION_SECRET:?...}`),没在 `.env` 里给值就直接拒绝启动并给出生成命令,避免用 `change-me-in-production` 直接跑在公网 + +### 变更 + +- 版本号提升到 1.2.16;`web/package.json` 同步对齐 +- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.7 +- 三个 compose 的分工:`docker-compose.yml`(本地源码上下文)、`docker-compose.hub.yml`(**从仓库拉取**)、`docker-compose.host.yml`(host 网络,仍需本地源码) + +## [v1.2.15] - 2026-09-27 + +### 新增 + +- **仪表盘「实时流量」支持切换数据源(本机 / 公网)**:本机读 Fonu 管理的本机 Nginx 访问日志;公网读 frps 侧逐规则的 `today_traffic_in/out` 与 `cur_conns`(速率由后端两次采样算出,口径与「公网反代」页的当前上传/下载一致)。切换时会清空 5 分钟曲线,避免两个来源的采样混在一张图里;还没有穿透规则时「公网」按钮禁用并给出说明 + +### 改进 + +- **容器交付:升级改为在部署机(NAS)上直接构建**(不再需要 save / 传文件 / load 镜像包) + - `Dockerfile`:加 `# syntax=docker/dockerfile:1` + 四处 `RUN --mount=type=cache`(npm 与 Go 模块/编译缓存跨次构建复用,重复构建从几分钟降到几十秒) + - 三个 compose 统一 `name: fonu`(否则改了目录名/项目名后 compose 会把它当成新项目,`fonu-data` 变成新空卷,现象就是「数据丢了」)与 `image: fonu:${FONU_VERSION:-local}`(镜像 tag 随版本走,便于回滚);`.host.yml` 补上原本缺失的 `image:` + - 新增 `scripts/upgrade.sh`:一条命令完成「拉代码 → 构建 → 重建 → 健康检查」,支持 `--no-pull`、`--rollback `(不重新构建)与 `COMPOSE_FILE=`(host 网络那仃) + +### 变更 + +- 版本号提升到 1.2.15;`web/package.json` 同步对齐 +- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.7 + +## [v1.2.14] - 2026-09-27 + +### 新增 + +- **证书颁发机构:LiteSSL 替换 Buypass**。LiteSSL(亚数 TrustAsia 的公益免费 DV 证书,90 天,官方支持单域名 / 通配符 / 多域名)已接入,ACME 目录 `https://acme.litessl.com/acme/v2/directory`。该目录里 `meta.externalAccountRequired = true`,所以**强制 EAB**:在 FreeSSL 平台「证书自动化 → EAB 管理」创建后,把 Kid 与 HMAC 填进设置即可(不需要像 ZeroSSL 那样用 API Key 去换取)。EAB HMAC 只回掩码。 + Buypass 保留常量与展示元数据(历史证书记录仍能正常显示),但不再可选,直接提交会被拦下并提示替代项。域名若有 CAA 记录,需允许 `trustasia.com`。 + +### 修复 + +- **frps 安装一直报 HTTP 404**:frp 官方的校验文件名始终是 `frp_sha256_checksums.txt`(不带版本号,v0.52.3 / v0.58.1 / v0.62.1 / v0.71.0 实测均如此),而代码拼的是 `frp_<版本>_sha256sums.txt`——任何版本、任何网络环境下都装不上。现改为按候选名依次尝试(旧名保留兜底),失败信息里列出尝试过的文件名。 + **同一条 bug 也在本机下载 frpc 的路径上,一并修复**;SHA-256 校验逻辑本身一行未改。 +- **Nginx 探测两处(均在 agent)**: + - `nginxPath()` 探测不到时不再回落到配置里的裸名字 `nginx`:那会让「未安装」被误判为「配置校验未通过(nginx -t 执行失败)」,且「VPS 未安装 Nginx」告警与一键安装按钮**永远不会出现**。 + - vhost 目录探测不再要求被加载文件以 `.conf` 结尾:Debian/Ubuntu 的 `sites-enabled/default` 不带后缀、而 `nginx.conf` 里是 `include .../sites-enabled/*`,过滤掉它之后只剩主配置目录可试,报「在 1 个被加载目录中均无法放置完整 server 配置」;现在失败信息会带上最后一次的具体原因与常见显式指定目录。 +- **前端申请证书弹窗的颁发机构卡片**改为前端可控清单:原先硬编码为 Let's Encrypt / ZeroSSL / Buypass,后端 `/api/certs/options` 的结果不参与渲染,导致后端增删 CA 时弹窗不变(本次 LiteSSL 替换就是这幺被发现的)。现在卡片为 Let's Encrypt / ZeroSSL / LiteSSL。 + +### 测试 + +- `internal/frp`:校验文件候选名顺序、sha256sum 格式解析(含 `*` 前缀与不存在的文件负例) +- `internal/acme`:LiteSSL 目录地址 / 展示名 / 别名归一化、通配符与多域名不被拦、Buypass 停用提示里含替代项 + +### 变更 + +- `AgentVersion` 0.12.4 → **0.12.7**(0.12.5 修 frps 校验文件名;0.12.6 修 nginxPath;0.12.7 修 vhost 目录探测),双架构内嵌二进制已重编;**VPS 上需点一次「升级 Agent」** +- 版本号提升到 1.2.14;`web/package.json` 同步对齐 + +## [v1.2.13] - 2026-09-27 + +### 新增 + +- **Linux 原生安装包**:`scripts/build-linux.ps1` 一条命令产出 `fonu-<版本>-linux-{amd64,arm64}.tar.gz`(内含二进制、migrations、systemd 安装脚本、前端运行时资源与 sha256 校验文件,无需 Docker/WSL,依赖纯 Go 的 modernc.org/sqlite);`scripts/fonu-install.sh` 负责目标机安装与卸载:装到 `/opt/fonu`、数据在 `/var/lib/fonu`、环境变量在 `/etc/fonu/fonu.env`(首次生成随机会话密钥,重装保留)并注册 `fonu.service` +- **`--reset-admin`**:忘了管理员口令时清空 `admins`/`sessions` 后重启,启动流程会重新生成随机口令;装完也会直接打印初始口令(以前只提示访问地址,用户找不到口令) +- **Cloudflare Tunnel 融合方案文档** `docs/cloudflared-integration-plan.md`:同类项目对比、两种托管模型、可复用面盘点、参考实现分析(含用户自有项目 cf-tunnel-manager)与 IP 优选可行性边界 +- 设置页「ACME 证书」新增 **DNS 解析器(高级)**设置项 + +### 修复 + +- **证书申请:DNS-01 传播检查用错解析器**:lego 默认读系统 `/etc/resolv.conf`,容器里那是 Docker 的 `127.0.0.11`,它不返回权威 NS,于是报 `could not determine authoritative nameservers`。现在显式传入 `dns01.AddRecursiveNameservers`(默认 `223.5.5.5:53,1.1.1.1:53,8.8.8.8:53`,可用 `acme_dns_resolvers` 覆盖,填 `system` 回到系统解析器;配置无效时报明确错误而不是静默降级) +- **Buypass 下线**:该 CA 已于 2025-10-15 停止签发、2026-04-15 终止 ACME 服务,已从可选 CA 列表移除;历史记录仍可展示,直接提交会被拦下并给出中文原因(不再让用户面对一个看不懂的 ACME 报错) +- **ZeroSSL 报错信息不全**:返回 422 时把 ZeroSSL 的 `error.type` / `error.code` 带进错误;非 JSON 响应退回压平截断的正文,便于定位 +- **密钥泄露**:ZeroSSL EAB 请求失败时,`url.Error` 里带 `access_key` 的完整 URL 会被写进任务日志与 `<数据目录>/logs/app.log`;现在只保留底层错误原因 + +### 测试 + +- `internal/acme`:递归解析器列表解析(分隔符/自动补 53/上限 8 条/默认值可用);ZeroSSL EAB 四条(成功、422 带错误类型、非 JSON 退回正文、网络错误不泄露密钥) + +### 变更 + +- 版本号提升到 1.2.13;`web/package.json` 同步对齐 +- 本轮不含 agent 行为变更,`AgentVersion` 仍为 0.12.4 + +## [v1.2.12] - 2026-09-25 + +### 修复 + +- **frps.toml 同源配置预览泄露明文密钥**:`FRPSServerConfig` 是对外**预览**入口,之前直接把 builder 输出返回,而模板里含解密后的 `auth.token`——「即将下发的内容」弹窗拿到的是明文,与同文件「密钥不入其他 API 响应」的约定矛盾。现在预览统一走 `maskFRPSSecrets`(`auth.token` / `auth.oidc.clientSecret` 的值换成 `********`,键名与行结构保留、注释行不动);**真正下发路径不受影响**(`PushFRPSConfigToAgent` 与安装流程仍用未脱敏内容)。预览弹窗补了说明,「复制」改为「复制(已脱敏)」 +- 规则详情「日志」页签改为**倒序**:加载后反转,最新一条在最上面(与日志页「FRP 日志」页签一致) + +### 改进 + +- 日志页「FRP 日志」的**来源**列不再显示 frpc 的 Go 源码文件行号(如 `client/control.go:174`),改为中文模块名(登录 / 连接 / 代理 / 服务 / 配置 / 健康检查 / 服务端);认不出来的值(包括回落到的组件名)原样保留 +- 「公网服务端」日志弹窗改为显示 **VPS 上的 frps 服务日志**(该页职责是管理 frps 端),标题与说明同步;本机 frpc 日志仍在日志页「FRP 日志」页签 +- frps 日志从写死 6 行改为**最近 20 行、最新在最上面**,并把 `2026-09-22T18:43:34+08:00 RainYun-XXX systemd[1]: …` 重写成 `2026-09-22 18:43:34 systemd[1]: …`(去掉 T 分隔、时区偏移与每行重复的主机名);`frps/action` 失败时带回的日志一并统一格式 + +### 测试 + +- `internal/frp`:`maskFRPSSecrets` 脱敏(明文不出现、非敏感行不被误伤)与注释行不被改写 + +### 变更 + +- **agent 行为变更**:`AgentVersion` 0.12.2 → **0.12.4**(0.12.3:20 行 + 倒序 + ISO;0.12.4:重写日期格式),双架构内嵌二进制已重编;**需在 VPS 上点一次「升级 Agent」**才生效 +- 版本号提升到 1.2.12;`web/package.json` 同步对齐 + +## [v1.2.11] - 2026-09-25 + +### 新增 + +- **定时自动备份 + 保留策略**:每天自动备份一份到 `/backups`,超过保留份数自动清理最旧的;重启不会重复备份(按「最新备份是否在 24 小时内」判定)。设置页「数据」卡可开关、改份数、立即备份,并列出/下载/恢复/删除备份,**恢复前先校验归档** +- **健康检查持续化 + 公开状态页**:规则的四段健康结论按**状态变更**落库(采样间隔即巡检轮次),由此推导可用率与不可用区间;设置页「高级」新增「公开状态页」——默认关闭,可设访问码(只存 SHA-256、不下发明文),按 IP 限流,只暴露域名与可用率 +- **指标历史与趋势图**:新增纵向点表 `metrics_samples`,记录本机 CPU / 内存 / 磁盘与每条规则的上行/下行速率(采样跟随巡检,30 天保留);仪表盘新增「资源与流量趋势」卡,支持 1 小时 / 24 小时 / 7 天 +- 告警事件新增「自动备份失败」(默认开启) + +### 修复 + +- **隧道探活:「判不了」不再当成「不通」**:`RouteHealthCandidate` 新增 `tunnel_known`,frps 管理接口未配置或不可用时不再把 `tunnel_ok=false` 当成故障——此前会对这类用户误报「隧道连续不通」 +- 健康汇总同样区分「无法判定」与「不通」:新增 `tunnel_unknown` 计数,规则表徽标用警告色并说明「不代表隧道不通」,「无法判定」不再算作不健康 + +### 改进 + +- 巡检每轮只采一次主机资源,同时喂给「资源阀值告警」与「指标历史」(CPU 占用率靠两次采样的差值,采两次会互相干扰) +- 流量聚合抽出 `aggregateHostKeys`,端点聚合与按域名聚合共用同一套速率算法,避免两处漂移 +- 仪表盘:新增趋势卡;「运行状态」并入顶部统计区(运行时长补进「服务」卡);「性能」并入系统资源那一行,整行 4 列 +- 趋势图:图例与绘图区留白调整;点少时画点(此前只有一两个点时线根本画不出来);空桶断开而不是补 0 + +### 测试 + +- `internal/backup`:导出 + 校验往返、校验拒绝非 gzip、列表/剪枝/名称白名单、到期判断 +- `internal/metrics`:分桶平均与空桶不产点、窗口外的点、退化输入 +- `internal/frp`:可用率推导(区间 + unknown、仍在持续、无记录不出结果)、健康汇总的 unknown 用例 +- `internal/api`:状态页限流与访问码哈希 +- `internal/app`:monitor 与 frp 两侧状态字面量的翻译 + +### 变更 + +- 新增迁移 `026_route_health.sql`、`027_metrics_samples.sql`(启动时自动执行) +- agent 无改动,`AgentVersion` 保持 **0.12.2**,本轮不需要在 VPS 上重新升级 Agent +- 版本号提升到 1.2.11;`web/package.json` 同步对齐 + +## [v1.2.10] - 2026-09-25 + +### 新增 + +- **告警事件新增三项巡检**(默认关闭,设置页「告警事件 → 系统」): + - **公网服务端掉线**:服务端进程没在跑、或隧道连不上 frps 时通知,恢复时再通知一次;只对「用户要它运行」的服务端判定,手动停止的不报 + - **公网 agent 不可达**:VPS 上的 fonu-agent 连续无响应(重启后没起来最常见),恢复时再通知一次;没配 agent 的服务端不参与判定 + - **配置漂移**:VPS 上的 vhost 与 Fonu 记录不一致(被手工改过、或 agent 重装后没重部署)时通知,漂移清空时补一条恢复 +- 通知图标与分类补齐:📈 系统(资源占用)、🔌🛑📡 隧道(隧道 / 服务端 / agent)、🧭 配置(漂移) +- 通知方式的 `Webhook` 改名为「Webhook / IM 机器人」:企业微信、钉钉、飞书机器人挂在 Webhook 预设下,原标签没说明白 + +### 改进 + +- 巡检改为 Source 接口(Routes / Servers / Agents / Drift),`internal/app/monitor_source.go` 负责把 frp 服务适配进来,`internal/monitor` 因此不依赖 frp 包 +- 「连续 N 次失败」抽成共用的 `failureTransition`(原 `tunnelTransition` 泛化):三条新巡检与原有隧道巡检共用「刚好达标报一次、恢复报一次」;漂移不是连续失败型,单独用冷却(默认 24 小时,可配) +- 避免误报的三条约定:服务端只在「用户要它运行」时判定;`ConnectionState` 为 `unknown` 按健康处理;没配 agent 的服务端不参与可达性判定 +- 开关关闭时连数据采集都跳过(关掉的功能不该有后台开销);所有 `notify.Alert` 一律在锁外调用 +- 新增设置项:`notify_on_frps_down` / `notify_on_agent_unreachable` / `notify_on_route_drift`,以及 `notify_frps_fail_threshold`(2) / `notify_agent_fail_threshold`(3) / `notify_drift_cooldown_hours`(24) + +### 测试 + +- `internal/monitor/monitor_test.go` 增加 `serverDown`(手动停止不报、进程未运行或连接断开要报、连接状态未知不误报)、`serverLabel`、`stateOr` 用例 + +### 变更 + +- 版本号提升到 1.2.10(版本单一来源仍是根目录 `VERSION`);`web/package.json` 同步对齐 +- agent 无改动,`AgentVersion` 保持 **0.12.2**,本轮不需要在 VPS 上重新升级 Agent + +## [v1.2.9] - 2026-09-24 + +### 新增 + +- **配置版本历史 / 对比 / 回滚**:主 `nginx.conf` 每次保存前留一份版本(`.versions/`,保留最近 10 次**内容有变化**的改动,内容相同自动去重);设置页「Nginx 全局配置」内可看版本列表、逐行差异并一键回滚——回滚先过语法校验与 `nginx -t`,不过关不覆盖当前配置 +- 公网反代页的「查看配置」弹窗内可看该域名在 VPS 上的历史版本、与当前配置做差异对比并回滚(需 agent ≥ 0.12.2) +- **通知渠道补齐**:新增企业微信机器人、钉钉机器人、飞书机器人(钉钉正文以「Fonu · 」开头,机器人用「自定义关键词 = Fonu」即可命中;加签方式暂不支持) +- **阈值型告警**(默认关闭):CPU / 内存 / 磁盘持续超阈值、隧道连续 N 次未在 frps 上注册时通知,恢复时再通知一次;阈值与开关在设置页「告警事件」里配置 +- **API Token**:新增 `api_tokens` 表与 `Authorization: Bearer ` 认证,供脚本 / HomeAssistant / 手机快捷指令调用;设置页「高级」可签发(明文只显示一次)、查看最近使用时间与撤销 + +### 修复 + +- agent 写 vhost 时 `nginx -t` 校验失败**不再直接删文件**(那等于把在跑的站点从磁盘上抹掉),改为恢复上一个可用版本 +- 配置版本名做路径校验,拒绝带 `/` 或 `..` 的名称,避免接口被用来读版本目录以外的文件 +- 通知:切换 Webhook 预设时服务地址自动换成该预设默认值(此前会把 Bark 的 `api.day.app` 带到企业微信上) +- API Token 的令牌管理、改密、退出**只允许登录会话**——否则一个 write 令牌能给自己提权或无限续期 + +### 改进 + +- `internal/fsutil` 新增 `BackupVersioned` / `ListVersions` / `ReadVersion`;`internal/nginx` 的回滚与 `Apply` 共用抽出的 `activate()`,两条路径行为一致 +- 新增 `internal/monitor` 巡检包(每 2 分钟一轮,注册进 `internal/scheduler`);资源类告警同一项 30 分钟静默期,回落到阈值以下即清冷却,开关关闭时连采集与探测都跳过 +- `providerUsesKey()` 收敛「凭据放 Key 还是 WebhookSecret」的判定;前端新增 `ConfigDiffView.vue` 与 `utils/diff.ts`(LCS 行级差异,超长文本退化为整体替换) +- API Token 的 `last_used_at` 限频回写(1 分钟一次),单连接 SQLite 上不做每请求写 + +### 测试 + +- `internal/auth/token_test.go`(令牌生命周期 / 过期 / 非法输入 / 明文不落盘)、`internal/api/middleware_test.go`(只读令牌只读、写令牌可写、令牌不能管理令牌、错误凭据 401) +- `internal/monitor/monitor_test.go`(连续失败只报一次、恢复报一次、未达阈值静默) +- `internal/notify/push_test.go` 增加企业微信 / 钉钉 / 飞书三家的端点与 payload 用例;`internal/fsutil` 增加版本列表 / 内容去重 / 路径校验用例 + +### 变更 + +- `AgentVersion` 0.12.1 → **0.12.2**(域名 vhost 的版本列表 / 对比 / 回滚端点,写配置前留版本);内嵌 `fonu-agent-linux-amd64` 与 `-arm64` 已重编,VPS 上需点「升级 Agent」才会生效 +- 版本号提升到 1.2.9(版本单一来源仍是根目录 `VERSION`);`web/package.json` 同步对齐 +- 新增 `docs/feature-benchmark-2026-09-24.md`:同类项目对标与增补建议,含 §九 本轮 P0 实施状态 + +## [v1.2.8] - 2026-09-24 + +### 修复 + +- 公网服务端:`frps` 启停失败不再假成功——之前忽略 `systemctl` 的 err 后无条件写「frps.service 已启动」,现在失败时返回 `ok:false` 与 stderr,运维不会误以为服务在跑 +- frps 配置写入:`.bak` 备份失败时立即中止并删除 `.verify`,不再覆盖唯一副本(旧配置得留退路) +- 安装 frps 后下发同源 `frps.toml` 失败不再静默吞掉:结果里带 `config_warning` 并追加到 `notes` +- 请求体解析:证书申请 / 续期、DDNS 测试改为严格 400;frps 安装允许空请求体(走默认版本)但拒绝 JSON 语法错误 +- 会话与登录:同一 IP 在 5 分钟内失败 5 次后返回 429(成功后清零,带过期清理与上限);改密码只失效该账号的**其他**会话,不再把当前会话一并踢掉;会话 cookie 按 `r.TLS` / `X-Forwarded-Proto` 决定是否加 `Secure`(默认明文部署不加,否则浏览器丢 cookie 导致登不进去),`clearSessionCookie` 补 `SameSite=Lax` +- 后台下载改为继承应用 ctx(新增 `Service.SetBaseContext`),App 关闭时随之中止;`scheduler` 每个任务包 `recover()`,单个任务 panic 不再带走整个进程,且建表时跳过非正间隔(`time.NewTicker` 会 panic) +- 两个 `http.Server` 补 `ReadTimeout` / `IdleTimeout`(agent 60s/2min;应用 5min/2min,**不设 `WriteTimeout`**——否则日志 SSE 会被中途切断) +- 公网反代页:`refreshAll` 的 7 个独立接口由串行 `await` 改为 `Promise.all`,每次刷新不再等于 7 个往返相加 +- 内网穿透页:删除 74 条随服务端 UI 迁走的死样式;公网服务端页合并逐字重复的样式块 +- 文档与工程:修正 README / DEPLOYMENT / `frps_template.go` 中「Docker 镜像内置 frpc 0.71.0」的不准确说法(镜像不预装、按需下载);`.dockerignore` 删掉两行已失效的归档排除项并修掉 ` docs` 的前导空格 + +### 改进 + +- 新增 `internal/fsutil`:`WriteFileAtomic`(同目录随机名临时文件 + rename + 显式 chmod)与 `Backup`(文件不存在视为无需备份),收敛 agent / frp / nginx / chinacidr 各处自建的 `.tmp` 与 `.bak` 逻辑(其中 nginx 原来用固定 `.tmp` 名,并发写同一路径会互相踩) +- 流量采集:`notify.RecordAccessHit`(写库 + 判断通知)从写锁内移到锁外,不再顶住 `/api/proxies/traffic` 与 `/clients` 的读 +- `internal/frp` 的 agent 客户端改为按 `serverID` 缓存(指纹 = `AgentURL` + Token 密文),批量同步 N 条路由不再新建 N 个连接池与 TLS 握手 +- 前端新增 `useVisibilityPolling()`:标签页隐藏时暂停轮询、回前台立即补一次;公网反代页与内网穿透页已接入,仪表盘流量轮询加 in-flight 互斥避免堆积并发请求 +- 新增 `docs/README.md` 文档索引;给 `agent-capability-plan` / `agent-view-design` / `-v2` / `upstream-sync-plan` 加实施状态与时效说明 + +### 测试 + +- Go 新增 4 个测试文件:`backup`(`../` 逃逸与 `../data-other/` 同前缀绕过、还原权限固定)、`scheduler`(非正间隔跳过、panic 后继续)、`frp`(`shellPath` 单引号转义、`isELFBinary`)、`agent`(`validateRoute` 表驱动、`nginxRoute` 关键行、`fileSHA256`) +- 前端引入 vitest(`npm test` = `vitest run`),新增 13 个单测覆盖 `utils/format.ts` 与 `utils/status.ts`(含一条守住「n-tag 的 type 值不能当状态值」的回归用例);CI 增加「Run web unit tests」一步 + +### 变更 + +- `AgentVersion` 0.12.0 → **0.12.1**(frps 启停失败如实回报、HTTP 服务补超时);内嵌 `fonu-agent-linux-amd64` 与 `-arm64` 已重编,VPS 上需点「升级 Agent」才会生效 +- 版本号提升到 1.2.8(版本单一来源仍是根目录 `VERSION`);`web/package.json` 的版本同步对齐,本地构建不再显示 1.0.2 + +## [v1.2.7] - 2026-09-24 + +### 修复 + +- 安全(P0):备份恢复的上传文件名不再拼进临时路径,改用 `os.CreateTemp`——此前 multipart 文件名完全可控,`../../root/.ssh/authorized_keys` 之类可逃出临时目录(`internal/api/backup_handler.go`) +- 安全(P0):备份还原的条目路径校验从「字符串前缀比较」改为 `filepath.Rel` 判定并拒绝 `..`——此前 `DataDir=/data` 时条目 `../data-other/x` 能通过前缀匹配写到 DataDir 之外;同时不再照搬归档里的权限位(固定文件 `0o640` / 目录 `0o750`),避免构造的归档造出 0777 或带执行位的文件 +- 安全(P0):agent 安装 frps 时 `proxy` 参数改走白名单(与「FRP 下载加速代理」同一组前缀),且改为先取官方 `frp__sha256sums.txt`、下载后比对 SHA-256 通过才解压安装——此前该参数未校验就拼进下载地址,等于可让 VPS 拉取任意地址的二进制并装成 frps +- 安全(P0):SSH 不再 `InsecureIgnoreHostKey()`,改为 known_hosts + TOFU——首次连接把主机指纹记到 `/ssh_known_hosts`(新增 `config.SSHKnownHostsPath()`),之后指纹不一致直接拒绝连接,错误里带上「期望 / 实际」指纹与处理提示 +- 安全(P0):`GET /api/settings` 不再回传 ZeroSSL API Key 明文,改为只回 `********` 掩码(前端以「非空」判断是否已配置,保存时仅在重新输入才会提交该字段) +- 安全(P0):agent 环境探测不再回传 Agent Token 明文,改为 `agent_token_configured` + `agent_token_hint`(末 4 位) +- 安全(P0):反代自动生成的 Basic Auth 密码不再写进 nginx 配置注释(此前会被「查看配置」永久读到),改为在部署响应里一次性返回、页面上提示「仅显示这一次」;部署失败会丢弃刚生成的凭据 +- 公网服务端页:服务端详情弹窗的状态点恒为灰色——`serverStartupType()` 返回的是 n-tag 的 type 值,被当成状态值传给 `StatusBadge :value` 后落到 unknown;新增返回 `StatusKind` 的 `serverStartupKind()` 并改用 `:kind` +- 仪表盘:域名卡与证书卡的徽标固定显示「正常」,后端返回异常状态时会出现「红/灰点 + 正常」的矛盾,改为按状态值推导文案并在无值时隐藏 + +### 变更 + +- `AgentVersion` 0.11.0 → **0.12.0**(frps 安装校验 + Basic Auth 密码处理),内嵌 `fonu-agent-linux-amd64`(10.4 MB)与 `-arm64`(9.7 MB)已重编,VPS 上需点「升级 Agent」才会生效 +- 新增 `.github/workflows/ci.yml`:push / PR 触发,按「前端构建 → 同步 dist 到 `cmd/fonu/web/dist` → `go vet` → `go test ./... -count=1` → `go build ./...`」执行(顺序不可颠倒:`cmd/fonu` 用 `//go:embed all:web/dist`,干净检出缺该目录时编译直接失败);gofmt 卡口待全仓格式化后再开 +- `web/package.json` 版本由 1.0.2 对齐到 `VERSION`,并声明 `packageManager: npm@10.9.4`(与 Dockerfile 的 `node:22-alpine` 一致);删除 `web/pnpm-workspace.yaml`(占位残留,仓库并无 pnpm 锁文件) +- 版本号提升到 1.2.7(版本单一来源仍是根目录 `VERSION`) + +## [v1.2.6] - 2026-09-24 + +### 改进 + +- 公网服务端页(`/frp-agent`)改为 DDNS 页那样的**左列表 + 右详情**:左侧是服务端列表(名称 / 启动状态 / `主机:端口` / 延迟与位置 / TLS·Token·Agent·SSH 标签,点行即选中并高亮),右侧是选中服务端的详情;原列表行内操作(启动·停止 / 查看 / 日志 / 测速 / 校验 / 编辑 / 删除)移到右侧工具条,顶部「选择服务端」下拉去掉(左侧列表本身就是选择器),页面标题改「公网服务端」,≤1100px 收成一列 +- 内网穿透页(`/frp`)改为**左:服务端列表 / 右:该服务端的穿透规则**:左侧点服务端即筛选(显示规则数与启用数),右侧保留原来的穿透规则表格(`:data` 按选中服务端过滤、去掉冗余的「服务端」列、`:scroll-x` 2020 → 1890),工具条显示当前服务端与规则条数;规则详情仍是弹窗(表格「详情」按钮),实时速率采样只在弹窗打开期间进行 +- 服务端列表从内网穿透页整体并入公网服务端页:服务端表单 / 服务端日志 / 服务端详情 / frps.toml 同源配置四个弹窗随列表一起迁移;内网穿透页不再持有服务端的增删改与启停 +- 内网穿透页规则区在「尚未配置服务端」时给出指向「公网服务端」页的空态(原来只剩一个被禁用的「添加规则」按钮) +- 日志中心「公网 FRP」页签改为时间倒序(新日期在前),分页与筛选跟着一致 + +### 变更 + +- 「Agent 管理」跳转改为深链:内网穿透页点某台服务端直接落到该台详情(`/frp-agent?server=`,id 不存在时回落为第一台) +- 服务端列表不再按「已配 Agent / SSH」过滤——未配的服务端也要能在本页编辑与删除 +- 版本号提升到 1.2.6(版本单一来源仍是根目录 `VERSION`) + +## [v1.2.5] - 2026-09-24 + +### 新增 + +- 公网 Agent 能力扩展(agent 0.11.0):新增 `GET /api/v1/certs`(VPS 证书清单——遍历证书目录、用标准库 `crypto/x509` 解析 `fullchain.pem` 的到期时间、按剩余天数排序、解析失败的单条记入 `warnings` 不中断整轮)与 `POST /api/v1/nginx/reload`(显式校验并重载 Nginx,分别回传 `nginx -t` 与 reload 的失败原因;成功后让可用性探测缓存失效,避免页面继续显示旧结论) +- 公网 Agent 页:新增「VPS 证书」卡(域名 / 剩余天数 / **Fonu 证书库** / 到期时间),与 Fonu 证书库交叉核对(含通配符匹配),宝塔自行申请的证书也能看到;剩余不足 30 天转橙、已过期转红加粗 +- 公网 Agent 页:Nginx 卡片新增「重载 Nginx」按钮,校验 / 重载失败原因(`nginx -t` 输出或 reload 的 stderr)直接显示在卡内 + +### 改进 + +- 公网 Agent 页布局:「VPS 资源」与「VPS 证书」改为并排各占半行——`dep-grid` 由 3 列改为 6 列并按 span 分配(原有三张卡仍每行三张),半宽下资源三项指标等分、平均负载与运行时长落到卡底一行,证书表列宽收紧;≤1199px 复位跨度避免 `span` 在列数变少后撑破网格 +- 应用侧对 agent 端点的版本要求提升到 **≥ 0.11.0**(旧 agent 仍按 404/405 统一降级为「请升级 Agent」提示) + +### 变更 + +- `AgentVersion` 0.10.0 → **0.11.0**;内嵌 `fonu-agent-linux-amd64`(10.0 MB)与 `-arm64`(9.3 MB)已重编,VPS 上需点「升级 Agent」才会生效 +- 版本号提升到 1.2.5(版本单一来源仍是根目录 `VERSION`) + +## [v1.2.4] - 2026-09-24 + +### 新增 + +- 公网 Agent 能力扩展(agent 0.10.0):新增三个只读端点——`GET /api/v1/metrics`(VPS 主机资源:CPU / 内存 / 磁盘 / 负载 / 运行时长,CPU 用两次采样差值,1 秒内的重复请求直接复用上次结果)、`GET /api/v1/logs/nginx`(access / error 日志尾部,路径只从 `nginx -T` 实际生效配置、`FONU_AGENT_NGINX_LOG_DIR` 或默认目录取;按 32KB 块倒读,单行 4KB 截断,上限 2000 行)、`GET /api/v1/frps/config`(frps.toml 回读,256KB 上限,文件不存在返回 `exists: false`) +- 公网 Agent 页:新增「VPS 资源」横排带(CPU / 内存 / 磁盘三项并排 + 进度条 + 右侧平均负载与运行时长),占满「环境与依赖」整行,不做单独占格 +- 日志中心:新增「公网 Nginx」页签(服务端 / 访问或错误日志 / 行数 200·500·2000 + 关键词搜索 + 分页),并显示 agent 解析出的实际日志路径与「仅显示尾部」标记 +- 公网服务端「frps.toml 同源配置」弹窗:新增「比对 VPS 实际文件」——去注释与空行后逐行比对,一致给出绿色说明,不一致指出第一个差异行(下发值 / VPS 值) + +### 改进 + +- `internal/sysinfo` 补 `LoadAvg()` 与 `UptimeSeconds()`:Linux 读 `/proc/loadavg`、`/proc/uptime`,Windows 用 `GetTickCount64`,其它平台返回空值 +- 老版本 agent 的降级:三个新端点遇到 HTTP 404/405 时统一转成「VPS 上的 agent 版本过低,不支持该接口(需 ≥ 0.10.0),请在服务端详情里升级 Agent」的可操作提示,而不是原始 HTTP 错误 + +### 变更 + +- `AgentVersion` 0.9.2 → **0.10.0**;内嵌 `fonu-agent-linux-amd64`(9.9 MB)与 `-arm64`(9.2 MB)已用新代码重编,VPS 上需点「升级 Agent」才会生效 +- 版本号提升到 1.2.4(版本单一来源仍是根目录 `VERSION`) + +## [v1.2.3] - 2026-09-24 + +### 新增 + +- 仪表盘:新增「公网穿透」统计卡,并在「运行状态」面板增加「公网反代」行——数据来自新增的 `GET /api/frpmulti/routes/summary`(逐台服务端采集四段健康后聚合,单台失败只记名不中断) +- 仪表盘:新增「性能」面板(今日异常 / 平均响应,`/api/status` 早已返回但页面从未展示),「实时流量」面板新增「流量 Top 5」(直接复用已有的逐规则流量轮询) +- 仪表盘:新增一行三卡「系统资源 / 中国 IP 段 / 通知通道」;系统资源来自新增的跨平台采集包 `internal/sysinfo`(Linux `/proc`、Windows kernel32,无新增依赖;容器内为宿主机视角) +- 公网反代:新增部署漂移清单(未部署 / 孤儿配置 / 未生效 / 内容不一致)与「重新部署全部」——逐条下发、单条失败不中断;新增 `GET /api/frpmulti/servers/{id}/agent/routes/drift` 与 `POST .../agent/routes/redeploy` +- 公网 Agent:端口矩阵补端口含义提示;「frps 运行时」新增「启动一致性」核对(systemd 实际启动命令 vs 配置的二进制与配置文件,检出「Fonu 写入的 frps.toml 不是 frps 实际加载的那份」) +- 新增 `docs/agent-capability-plan.md`:fonu-agent 能力扩展方案(12 个端点现状盘点、P0–P2 分批设计、agent 侧与应用侧改动流程、验收与风险回滚) + +### 改进 + +- 仪表盘「服务」卡:原先写死「正常 / 4 个」,现按 Fonu / Nginx / DDNS / 证书四项实际状态聚合,异常时点名并把徽标降级为 warning/error(此前 Nginx 停止、DDNS 异常时该卡仍显示「正常」,与右侧「运行状态」面板自相矛盾) +- 仪表盘轮询:`/api/status` 每 15 秒刷新一次(CPU 占用率需要两次采样才有意义),并在页面隐藏时暂停「实时流量」与「状态」两个轮询 +- 公网反代健康列:证书徽标补「证书来源」——`Fonu 证书库:有(剩余 N 天)` 或 `无(需先在证书管理申请)`,用于区分「没有证书可推送」与「VPS 上证书过期或未覆盖」 + +### 修复 + +- FRP:frpc 版本读不出来——`RuntimeStatus.Version` 带 `omitempty`,`frpc --version` 探测失败时字段被整体省略,前端只能显示「未检测 / 未知」;现回落为按 `/bin/<版本>/` 目录名推导(与「FRP 应用配置」弹窗的 `active_version` 同源),并改为逐台服务端探测版本(原先只探测默认那台) +- 仪表盘:删除死样式(`.dashboard` / `.stat-highlight` / `.proxy-metric*`) + +### 变更 + +- 版本号提升到 1.2.3(版本单一来源仍是根目录 `VERSION`) + +## [v1.2.2] - 2026-09-23 + +### 新增 + +- 日志中心:新增「FRP 日志」Tab——把 frpc 运行日志解析为 时间 / 级别 / 来源 / 消息 四列,支持关键词与级别筛选、分页、错误/警告行着色,与系统日志、访问日志、Nginx 日志同一套交互(对齐上游 1.0.3) +- 新增 `web/src/utils/clipboard.ts`:统一的剪贴板复制工具(异步剪贴板 API 优先,失败或非安全上下文时回退 `execCommand`) + +### 改进 + +- 剪贴板复制在非 HTTPS 环境可用:Fonu 常以 `http://内网IP:6893` 访问,属非安全上下文,此时 `navigator.clipboard` 为 undefined,复制必然失败;公网反代(复制域名 / 复制配置)、公网 Agent(配置查看)、DDNS、本地反向代理(复制访问链接)共 5 处调用点统一改用上述工具(对齐上游 1.0.4) +- 容器时区:`Dockerfile` 新增 `TZ=Asia/Shanghai` 并在运行时阶段安装 `tzdata`(只设 `TZ` 而镜像内无 zoneinfo 时 Go 仍会回落 UTC),三个 compose 文件均支持 `TZ` 覆盖——通知消息时间按系统时区显示(对齐上游 1.0.4) +- 反代配置查看弹窗:补齐内容区内边距(与头部 24px 对齐),新增来源说明(由 VPS 上的 fonu-agent 生成、文件名 `fonu-<域名>.conf`、手工修改会被下次部署覆盖),并改为说明固定 + 代码块内部滚动 +- 反向代理:复制规则不再自动追加「-复制」后缀,保留原名称与域名、由用户在表单里确认修改(原先一点复制就得到一个改过名的副本,容易误建同名规则)(对齐上游 1.0.5) +- FRP 穿透页:frps 同源配置弹窗新增「VPS 防火墙 / 安全组放行 TCP 远程端口」提示,端口列表由已启用的 TCP/UDP 规则实时推导(与 frps 的 `allowPorts` 同源;未放行时隧道连不上,但日志不会点明原因)(对齐上游 1.0.3) +- 通知:Telegram 代理地址只填 `host:port` 时自动补全 `http://`,并校验 scheme 仅支持 http / https / socks5,错误信息带上原始地址便于自查(对齐上游 1.0.3) + +### 修复 + +- 通知:单个通道密文解密失败不再让整份配置加载失败——原先 SMTP / Webhook / Telegram 任一密文损坏(例如更换过加密密钥)都会直接返回错误,导致其它已配置通道一并无法告警;现改为仅把该通道标记为「未配置」并继续解析其它通道(对齐上游 1.0.5) +- 反向代理:监听占用冲突提示写明占用方——优先规则备注名,其次该规则首个域名,最后回落 `规则 #id`;原先只说「已被其他规则使用」,用户得自己翻列表找是哪一条 +- 反向代理:手动(custom)Nginx 配置的监听占用纳入校验。custom 配置的正本在文件系统(数据库只记 `nginx_mode` 开关),其 `listen` / `server_name` 原先完全不参与校验,与其它规则「同域名 + 同端口」的冲突只会在 Nginx reload 时静默失效(第一个 server 块生效、后面的被忽略);现于保存 / 回滚时解析并写入新表 `proxy_custom_endpoints`,与表单规则共用同一条判定(对齐上游 1.0.5) +- 反向代理:并发下修改监听端口可能绕过保存前校验(`UPDATE proxy_hosts` 的唯一约束冲突此前直接抛出原始 SQL 错误),现改为按域名复检并给出带规则名的提示 + +### 变更 + +- 新增 `docs/upstream-sync-plan.md`:上游 1.0.2 → 1.0.5 的逐版本对照方案,含 8 项待核对项的代码级核实结论、不照搬项及原因、以及「可增加项清单」(11 项,按价值排序) +- 新增迁移 `024_proxy_custom_endpoints.sql`(手动 Nginx 配置的占用表,`UNIQUE(rule_id, hostname, listen_port)` + `ON DELETE CASCADE`),解析器 `internal/nginx/endpoints.go`(`ParseServerEndpoints`,保守解析:`include` 片段 / 正则 `server_name` / `listen unix:` 一律忽略,宁可漏报不误报)与 `internal/proxy/custom_endpoints.go` +- 补测试:`internal/nginx/endpoints_test.go`(多 server 块、`[::]` 写法、通配与正则、无 `listen` 默认 80)、`internal/proxy/custom_endpoints_test.go`(走真实迁移建内存库:冲突含规则名、排除自身、同域名不同端口放行、custom 占用对表单规则可见、级联清理) +- `docs/upstream-sync-plan.md` 补 §八「第 5 项详细整理」:Nginx 端口占用语义(一条规则占用哪些端口)、现有校验链逐跳、三处真实缺口与方案 A/B/C 对比、实施记录与未覆盖边界 + + +## [v1.2.1] - 2026-09-23 + +### 改进 + +- 公网 Agent 页:按「功能清单」重排为五段分区(运行状态 / 环境与依赖 / frps 控制 / Agent 生命周期 / 输出与日志);4 张状态卡统一为标准 `.stats-row` + `.stat-card` 规格;补齐 5 项「数据已返回但页面未显示」的能力——Agent 凭据状态、8080 端口、已注册反代路由数、最后检查时间、Nginx 可用性 +- 公网 Agent 页:「环境与依赖」改为三列并排(Fonu Agent / Nginx / frps 运行时),卡内短字段双列、长路径整行;「frps 控制」与「Agent 生命周期」两列并排;新增「配置查看」按钮(只读弹窗展示即将下发的 frps 配置,含复制),卸载(含数据)增加二次确认 +- 公网反代页:规则台账改用 `n-data-table`(与本地反向代理页一致,表头与数据天然对齐),列为 规则/域名(含复制与打开按钮)、协议、上游/回源、健康(DNS·证书·隧道·服务)、连接、当前上传、当前下载、总上传、总下载、更新时间、操作 +- 公网反代页:新增统计区(可部署规则 / 已生效 / 健康通过 / 当前连接)与环境与 Nginx 卡(4 列);「使用说明」整卡改为页面级提示与入口行;部署设置弹窗对齐本地反向代理页规格(900px、三段式、页脚分隔线、开关行底色)并新增右侧「配置说明」栏 +- 公网反代页:协议列改为按部署设置的「启用 HTTPS」自动识别(规则 type 恒为 http,不能代表入口协议),HTTPS 绿色 / HTTP 灰色并带悬停说明 +- FRP 穿透页:4 张状态卡统一为标准统计区;公网服务端列表由自建网格改为 `n-data-table`(8 列,含启动/停止/查看/日志/测速/校验/Agent 管理/编辑/删除) +- FRP 穿透页:6 个弹窗统一到同一外壳(`.frp-card-modal` / `.frp-detail-card`),运行日志、导出配置、frps 同源配置、FRP 应用配置的头部/滚动区/页脚一致 +- 统一设计令牌:清理硬编码色值(公网反代页 6 处、FRP 穿透页 13 处)与内联样式,删除死样式;统计区、弹窗、表格单元格全部走 `--fonu-*` 令牌与共享组件(`StatusBadge` / `EmptyState` / `LoadError` / `FonuCard`) +- 表格与列表交互:数字列右对齐、操作按钮右对齐并按行禁用、长文本换行、单元格内容垂直居中;窄屏按 1200 / 900 / 760 断点降列 + +### 修复 + +- FRP 穿透页「运行中」状态显示为灰色:`statusKind` 返回 `'success'` 却被当作 `value` 传给 `StatusBadge`(`utils/status.ts` 不识别该值,落到 `unknown`),且其中含非法的 `'default'`;现改为返回合法 `StatusKind` 并改用 `:kind`,服务端详情弹窗同类问题一并修复 +- 表格单元格样式整体失效:`n-data-table` 的 render 函数用 `h()` 创建的节点不带 scoped 属性,导致 `table-muted` / `type-chip` / `mono` 与各 `cell-*` 样式未生效(表现为「上游/回源」两行变一行、域名与按钮错位、徽标无间距);统一改用 `:deep()` 选择器 +- 公网反代页刷新失败静默:`loadErrorMsg` 此前写入但从未渲染,现改为顶部可见提示,并让单个接口失败不再中断整轮数据加载 +- 公网反代页切换服务端残留上一台数据、轮询在页面隐藏时仍请求、loading 粒度粗(任一操作全页按钮转圈)等问题修复 + +### 变更 + +- 新增设计文档:`docs/agent-view-design-v2.md`(公网 Agent 页)、`docs/proxy-remote-design.md`(公网反代页),以及被取代留档的 `docs/agent-view-design.md`、`docs/agent-view-refactor-plan.md` +- 新增页面 UI 标准 skill(工作区级 `.breezell/skills/fonu-page-ui`,不在仓库内):页面骨架、共享组件契约、设计令牌、类名约定、弹窗标准结构、脚本约定与验收清单 + +## [v1.2.0] - 2026-09-23 + +### 新增 + +- FRP 公网反代:独立「公网反代」页(`/public-proxy`)——服务端选择、规则列表(名称 / 域名 / 内网目标 / 回源端口)、部署与移除、配置查看、15 秒自动刷新 +- FRP 公网反代:**部署后回源自检**——agent 写入配置后模拟 Nginx 回源(`Host: 域名` → `127.0.0.1:`)并分级提示:0=连接不上 vhost、404=隧道未注册、5xx=内网服务异常,不再出现「部署成功但打开 502」 +- FRP 公网反代:**状态徽章实时化**——基于 `nginx -T` 同时判定「文件存在 / 语法通过 / 实际被加载」,展示已生效 / 已写入未加载 / 语法错误 / 未部署 +- FRP 公网反代:**配置查看**——`GET /api/v1/routes/{domain}/conf` 直读 VPS 上生成的 nginx 配置,排障无需 SSH +- FRP 公网反代:**五段健康检查**(逐规则)——DNS 指向、证书覆盖与剩余天数、隧道注册、内网服务探活、反代生效态;任一断点红标 + 悬停显示原因 +- FRP 公网反代:**流量列**——当前连接 / 当前上传 / 当前下载 / 总上传 / 总下载(取自 frps dashboard 聚合) +- FRP 公网反代:「部署设置」弹窗(与本地反向代理弹窗同构,三 Tab) + - 基础:启用 HTTPS(显式开关)、HTTP 强制跳转、WebSocket、上传大小限制、代理读超时 + - 安全:IP 白名单、IP 黑名单、Basic Auth(用户名 + 自定义密码,留空首次自动生成)、仅中国大陆 IP + - 高级:安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy)、仅 TLS 1.3、请求限流(速率 / 突发)、每 IP 连接数上限 +- FRP 公网反代:限流与连接数落地——agent 生成 `fonuagent-limits.conf`(http 上下文 zone 定义,由状态文件驱动增删),server 内输出 `limit_req_status 429` / `limit_conn_status 503` +- FRP 公网反代:仅中国大陆 IP——Fonu 把本地维护的中国 IP 段下发到 agent(`PUT /api/v1/china-cidr`),agent 生成 `geo $fonu_china_client`;IP 段未更新时部署显式报错,不产出半成品配置 +- FRP 公网 Agent:新增独立「公网 Agent」页(`/frp-agent`)——agent 状态面板、frps 配置下发、frps 版本安装 / 升级、frps 启停与重启、frps 管理入口、日志、SSH 生命周期与诊断 +- FRP 公网 Agent:**Nginx 状态卡片**——状态徽标(正常 / 未安装 / 配置校验未通过 / 已安装未运行)、版本、可执行路径、vhost 配置目录、`nginx -t` 结果、80/443 监听、反代配置文件数 +- FRP 公网 Agent:**一键安装 Nginx**——agent 识别 apt-get / dnf / yum / apk 非交互安装并启动,回写探测结果(需 agent 以 root 运行) +- FRP 公网反代:兼容 frps v0.68 与 v0.71+——新版走 `GET /api/v2/proxies`,旧版走 `GET /api/proxies/{user.name}` 逐条查询 + +### 改进 + +- FRP 公网反代:部署选项(WebSocket / 强制跳转 / IP 白名单 / Basic Auth 等)从穿透规则弹窗迁移到公网反代页「部署设置」,语义归位(这些是公网反代属性,与隧道无关) +- FRP 公网反代:隧道匹配优先按域名(`spec.http.customDomains`),比按代理名猜测可靠;同时兼容 `user` 前缀(`admin.<规则名>`)与裸规则名 +- FRP 公网反代:frps 管理接口调用失败时把具体原因透出到前端悬停(请求 URL、状态码、响应体、密码读取 / 解密环节),不再笼统提示「接口不可用」 +- FRP 公网反代:部署预检——未安装 Nginx 时直接提示「VPS 未安装 Nginx,请先安装(apt install nginx / yum install nginx)」,替代 `nginx config rejected` 技术堆栈 +- FRP:agent 部署接口由 10 个位置参数改为 `RouteSpec` 结构体(`SyncRouteSpec`),`SyncRouteEx` 保留为兼容包装 +- FRP:agent 版本递增至 0.9.1,状态新增 `nginx_ok` / `nginx_missing` / `nginx_running` / `nginx_path` / `nginx_version` / `nginx_error` +- FRP:agent 0.9.2——Basic Auth 哈希改为**纯 Go apr1**(与 `openssl passwd -apr1` 输出逐字节一致,已用参考向量做单元测试),安装脚本 token 改用 `/dev/urandom`:agent 与安装流程彻底去除对 VPS 上 openssl 的依赖(原先缺 openssl 时会静默写入空 token、导致 Fonu 无法认证 agent) + +### 修复 + +- FRP 公网反代:隧道状态误报 ✗——`RouteHealth` 静默忽略 dashboard 密码解密错误导致空密码 401,现逐环节记录并透出 +- FRP 公网反代:隧道检测端点错配——旧代码请求 `/api/proxies`(405)或按类型子端点(404),现按 frps 版本自适应并解析 V2 的 `data.items` +- FRP 公网反代:流量恒为 0——V2 返回的键是 `admin.ssssss` 而代码只查裸规则名,现按「裸名 / user 前缀名 / 域名索引」三级匹配 +- FRP 公网反代:列表健康徽标样式丢失(此前无样式渲染) +- 修复被外部进程清空的 `internal/frp/store.go`、`internal/api/ddns_handler.go`、`web/src/views/DdnsView.vue` + +### 变更 + +- FRP 公网反代管理从「公网 Agent」页拆分为独立页面(`/public-proxy`),侧栏新增「公网反代」 +- 新增 agent 端点:`GET /api/v1/routes/{domain}/conf`、`PUT /api/v1/china-cidr`、`POST /api/v1/nginx/install` +- 新增 Fonu 接口:`GET /api/frpmulti/servers/{id}/agent/routes/health`、`GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf`、`POST /api/frpmulti/servers/{id}/agent/nginx/install` +- 明确不做(已写入 `docs/agent-proxy-settings-plan.md`):公网反代不使用「目标 Host 头」(会破坏 frps vhost 路由导致 404)、不做「忽略后端 TLS 校验」(回源为明文 HTTP)、不做公网侧自定义 upstream 覆盖(会绕过隧道) + +## [v1.1.0] - 2026-09-19 + +### 新增 + +- FRP:新增公网 `fonu-agent` 服务端管理基础能力——VPS 侧提供 Bearer Token API,Fonu 本地可测试 Agent、读取状态、下发 frps.toml,并在 HTTP/HTTPS 规则保存后同步公网 Nginx 反代路由 +- FRP:服务端新增「Agent 管理」弹窗——API 状态面板(连接/frps/Nginx/80-443 端口监听/已注册路由)与 SSH 生命周期操作(探测环境、一键安装/升级/卸载、SSH 诊断),安装后自动回填 Agent 地址与 Token;agent 二进制内嵌随主程序分发,无需单独下载 +- FRP:模块整体升级为多服务端管理——支持添加多台 frps(独立启动 / 停止 / 日志 / 测速 / 校验),穿透规则支持全部 8 种类型(tcp / udp / http / https / tcpmux / stcp / sudp / xtcp)与插件(static_file / socks5 / https2http 等,按插件类型校验必填字段) +- FRP:服务端详情五项独立诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地),含最近 30 分钟可用率与延迟曲线 +- FRP:frps.toml 同源生成——服务端「详情」一键生成与 frpc 侧配置同源的服务端配置(bindPort / 认证 / webServer / 传输参数 / vhost 端口 / 防火墙放行清单);支持 token / 无认证 / OIDC 三种方式;格式对齐官方 `frps_full_example.toml`(v0.52.0+ TOML,v0.63.0+ 均适用) +- FRP:穿透规则实时流量与连接数展示(需在 frps 配置 webServer 管理接口) +- FRP:「应用配置」旁新增「使用说明」——配置流程、DDNS / 反向代理 / 证书组合说明与十个组合场景详解(双标签页 + 手风琴折叠展示) +- FRP:frpc 客户端多版本管理(「应用配置」弹窗内下载 / 启用 / 删除 / 配置下载代理) +- DDNS:新增「自定义 IP」——按任务设置固定 IPv4 / IPv6(如公网服务器 IP),更新时直接使用该 IP,不再检测本机出口 IP;须为公网地址,否则拒绝更新 + +### 改进 + +- FRP:脱敏令牌完整闭环——Token / OIDC 客户端密钥 / frps 管理密码掩码往返保护,回传掩码不会覆盖真实凭据;表单统一「已配置」标识 +- FRP:服务端详情「配置」面板重新分组排版(描述 / 连接与认证 / 诊断) +- 前端:使用说明弹窗对齐「添加穿透规则」弹窗的自绘外壳样式 + +### 变更 + +- FRP:旧「Nginx 网关模式」退役——原 5 条 `/api/frp` 接口移除,由 `/api/frpmulti` 接管;旧实现归档为 `internal/frp-1.0.2`(不参与运行、可整目录删除);旧网关配置(settings 键值)保留但不再读取,升级后需在「FRP 穿透」页重新配置服务端 +- 数据库:新增迁移 015–021(FRP 多服务端核心表)与 022(DDNS 自定义 IP 字段),升级后首次启动自动应用 + +## [v1.0.2] - 2026-09-18 + +### 新增 + +- 证书:支持火山引擎 DNS 的 ACME DNS-01 验证 + +## [v1.0.1] - 2026-09-18 + +### 新增 + +- DDNS:支持火山引擎 DNS(AccessKey / Secret Access Key) +- DDNS:委托子域与多层子域名 Zone 自动匹配(最长后缀优先,如 `a.zzz.com`) + +### 改进 + +- DDNS:解析记录统一展示完整域名;任务列表仅显示记录条数 +- DDNS:保存时校验域名格式(非法字符、标签长度、连字符位置) +- 证书:火山引擎 DNS 任务申请证书时给出明确不支持 DNS-01 的提示 + +### 修复 + +- DDNS:多层子域名(如 `aaaa.vvv.bbb.com`、`www.a.zzz.com`)保存后被截断的问题 +- DDNS:同一任务混用多根域/委托子域时 Zone 解析错误 + +## [v1.0.0] - 2026-09-18 + +### 新增 + +- 通知:支持 SMTP 邮件、Webhook 预设(Bark / ntfy / Gotify / 自定义)与 Telegram 机器人(可选 HTTP 代理),全局仅可启用一种方式 +- 设置页新增「测试通知」按钮,无需保存即可验证当前配置 +- 告警事件:DDNS / 证书 / 安全 / 系统分组,含 IP 变更、续签成功/失败、登录异常、Nginx 重载失败等 +- 设置页改为「常规 / 通知 / 安全 / 高级」分栏,减轻单页信息密度 +- 日志中心:系统日志、访问日志、Nginx 错误日志三 Tab;移除实时日志 SSE +- 凭据类字段统一「已配置」标识与「留空表示不修改」占位(SMTP / Webhook / Telegram / DDNS / FRP / Basic Auth 等) + +### 改进 + +- 旧版 `notify_webhook_url` 自动迁移为「自定义 Webhook」 +- 通知密钥加密存储,接口不再返回明文;保存后表单状态与服务端同步 +- 系统日志中文展示(历史英文日志兼容翻译) +- 仪表盘与反代详情「最近访问」展示反代服务名而非域名 +- README 界面截图更新为原图,两列排版展示 + +### 修复 + +- 通知 / 系统设置保存后刷新丢失的问题 + +## [v0.1.31] - 2026-09-18 + +### 新增 + +- 反向代理 Nginx:支持单条规则自动/手动编辑、备份与回滚;详情页只读预览,编辑弹窗内配置 +- 设置页 Nginx 全局片段:http 块自定义编辑,默认上传大小 50M 片段与框架说明 +- Nginx 编辑器:CodeMirror 语法高亮;自动生成配置附带中文注释 +- 保存 Nginx 时基础语法校验(无 nginx 可执行文件时亦可拦截明显错误) + +### 改进 + +- 反代日志面板:移除「隐藏轮询」开关,简化日志工具栏 + +## [v0.1.30] - 2026-09-17 + +### 修复 + +- 反向代理 Basic Auth:htpasswd 文件补充 nginx 可读权限与 bcrypt 前缀兼容,修复认证后 500 错误 +- 反代连接数:按「域名 + 监听端口」统计,修复同域名多规则互相串数的问题 +- 仪表盘「今日请求」:排除错误页与 favicon 噪音,趋势改为较昨日对比 + +### 新增 + +- 反代详情日志:支持全屏查看实时访问日志 + +## [v0.1.29] - 2026-09-17 + +### 修复 + +- 反向代理 Basic Auth:修复密码哈希在 Nginx 重载前被剥离导致认证不生效的问题 +- 反代上传:全局默认 `client_max_body_size 50m`,修复大文件上传 413 错误 + +### 改进 + +- README 更新界面截图(v0.1.28)并补充开源地址与协议说明 + +## [v0.1.28] - 2026-09-16 + +### 修复 + +- 仪表盘「今日请求」:改为今日 0–24 时固定坐标轴(00:00–22:00),未来时段为空 + +### 改进 + +- FRP Token:保存后显示「已保存」状态,说明不显示明文的原因 +- FRP Web 穿透:移除重复域名输入框,统一在「已同步域名」弹窗中编辑保存 + +## [v0.1.27] - 2026-09-16 + +### 修复 + +- FRP:移除无效的 `pidFile` 配置项,修复 frpc 启动即退出(exit status 1) +- FRP:关闭穿透时保存按钮不可用,导致无法真正关闭 +- FRP:frps 配置随连接设置实时生成,不再展示静态示例 +- 仪表盘「今日请求」:改为滚动最近 24 小时统计,修复当前时段无数据 + +### 改进 + +- Docker 镜像内置 frpc 升级至 v0.71.0 + +## [v0.1.26] - 2026-09-16 + +### 新增 + +- FRP 内网穿透(Nginx 网关模式):独立「内网穿透」页面配置 frps 连接,自动穿透本地 Nginx HTTP/HTTPS 端口 +- Docker 镜像内置 frpc v0.66.x(amd64 / arm64) +- DDNS 页:FRP 启用时提示将域名解析到 VPS 公网 IP + +## [v0.1.25] - 2026-09-15 + +### 新增 + +- DDNS 与证书 DNS-01:支持腾讯云 DNS(SecretId + SecretKey,区别于 DNSPod ID+Token) + +## [v0.1.24] - 2026-09-14 + +### 修复 + +- 中国 IP 段更新后 nginx 重载失败:校验用临时文件,应用配置时改用正式的 `china_cidr.conf` +- 仪表盘「今日请求」图表:从完整 access.log 按小时统计,不再仅用最近 10 条访问记录 + +## [v0.1.23] - 2026-09-14 + +### 修复 + +- 自定义错误页插图无法显示:图片路径改为公开 location,浏览器可正常加载 `error.png` / `429.png` + +## [v0.1.22] - 2026-09-14 + +### 改进 + +- 仅中国大陆:内网 IP(10/8、172.16/12、192.168/16 等)默认放行,无需手动加白名单 +- 自定义错误页图片内嵌进二进制,Docker 镜像补充 `web/assets/image` 资源目录 + +### 修复 + +- 错误页目录权限:生成后自动 chmod/chown,避免 nginx(www-data)无法读取导致回退默认 403 页 +- 中国 IP 段未就绪时,仅中国大陆规则改为只拦截非公网内网地址 + +## [v0.1.21] - 2026-09-14 + +### 新增 + +- 反代安全设置:IP 黑/白名单、仅中国大陆、Basic Auth、速率/连接数限制 +- 设置页:信任代理(CDN Real IP)、全局 IP 黑名单、中国 IP 段定时更新 +- 自定义 Nginx 错误页(403 / 404 / 429 / 500 / 503) +- 高级选项:上游 TLS 校验跳过、目标 Host 头、TLS 1.3 仅、HTTPS 安全响应头 + +### 改进 + +- 中国 IP 段:同步更新 API、ghfast 镜像回退、多源下载与状态展示 +- 反代编辑/详情弹窗 UI 重构;列表域名访问链接与复制 +- 仅中国大陆 + 白名单:白名单 IP 正确豁免大陆限制(geo + 变量组合) + +### 修复 + +- 白名单模式开启但未填 IP 时的校验 +- API 测试依赖 settings 包导入 + +## [v0.1.20] - 2026-09-13 + +### 改进 + +- 反代详情改为弹窗展示,概览布局重排(信息条 + 图表/指标 + 最近访问) +- 标题栏显示当前连接数;最近访问列表支持纵向滚动 +- 反代详情日志结构化展示(时间、方法、路径、状态码等分列) + +### 文档 + +- README 默认反代端口更新为 `18080` / `9443` + +## [v0.1.19] - 2026-09-13 + +### 修复 + +- 反代详情日志:修复无法滚动的问题,悬停显示滚动条,默认跟随最新日志 + +## [v0.1.18] - 2026-09-13 + +### 改进 + +- 反代详情:概览 / 日志 Tab 切换,默认概览;日志区域撑满侧栏 +- 反代列表:状态列改为开关,可直接启用/停用 +- 反代「复制」改为填入新建表单(名称加 `-复制` 后缀) +- README Docker 徽章按 semver 排序,避免显示 `-amd64` 标签 + +### 移除 + +- 反代详情「设置」Tab 与拖动排序提示文案 + +## [v0.1.17] - 2026-09-13 + +### 新增 + +- 反代规则「备注」改为「名称」,列表以名称优先展示 +- 反代规则复制当前信息到剪贴板 +- 反代列表拖动排序(无筛选时) + +## [v0.1.16] - 2026-09-13 + +### 改进 + +- 证书申请:首次未配置 ACME 邮箱时提示填写,前后端校验邮箱格式 +- 证书列表空状态与反代/DDNS 页风格统一 + +## [v0.1.15] - 2026-09-13 + +### 修复 + +- Docker 发版:amd64/arm64 均构建完成后再推送 `latest` 与版本多架构 manifest +- 补全 `web/assets/image` 品牌图片资源,同步仪表盘 banner + +### 改进 + +- arm64 镜像构建:前端与 Go 在 BUILDPLATFORM 编译,缩短 QEMU 构建时间 + +## [v0.1.14] - 2026-09-13 + +### 新增 + +- ZeroSSL 证书申请支持 +- 反代规则备注字段;域名+端口唯一约束 +- 可配置默认反代监听端口(`FONU_NGINX_HTTP_PORT` / `FONU_NGINX_HTTPS_PORT`),适配飞牛 host 部署 +- `docker-compose.host.yml` 飞牛 host 模式部署示例 +- 全新 Web UI:品牌视觉、仪表盘、证书/DDNS/反代/设置页重构 + +### 改进 + +- README 重写:飞牛 fnOS 部署说明、界面截图、Docker Hub 徽章 +- `/api/version` 返回默认反代端口配置 +- 移除仓库内 `scripts/`、`cmd/seed` 本地开发辅助 + +## [v0.1.13] - 2026-09-13 + +### 修复 + +- 修复证书页 TypeScript 类型错误导致 Docker 构建失败 + +## [v0.1.12] - 2026-09-12 + +### 新增 + +- DDNS:单任务支持多根域名,列表按域名展示解析 IP 与同步状态 +- 证书:申请进度弹窗与实时日志(SSE 异步任务);列表支持下载证书/私钥/ZIP +- 反代:按规则实时访问日志、流量统计(累计/速率)与当前连接 IP 查看 +- 日志:访问/错误/运行日志分页;实时日志预载最近 100 条 Nginx 记录 + +### 修复 + +- 实时日志 SSE 因中间件未转发 Flush 导致始终为空 +- Vite 开发代理对 SSE 流的缓冲问题 +- 证书申请:同一 DDNS 凭证管理多个根域(如 roven.cc + chiak.cc)时校验失败 +- 首次启动自动创建管理员并在运行日志输出初始密码(移除独立初始化页) +- 证书申请失败写入 `last_error` 并在列表展示 +- 系统日志过滤无意义 API 轮询噪音 + +### 改进 + +- 仪表盘/证书/DDNS 列表加载优化(轻量接口与缓存刷新) +- Nginx 访问日志增加 `request_length` / `bytes_sent` 供流量统计 +- 反代与证书页操作按钮横排布局 + +## [v0.1.11] - 2026-09-12 + +### 修复 + +- Vite 开发服务器允许经 Nginx 反代用自定义域名访问(`server.allowedHosts`) + +## [v0.1.10] - 2026-09-12 + +### 修复 + +- Windows 本地 Nginx:证书/日志使用绝对路径,HTTPS 块先于 HTTP 重定向,修复 `ssl_certificate` 校验失败 +- Windows 进程检测与启动:修复 `isPIDAlive` 误判、启动阻塞及重复拉起 Nginx 导致端口冲突 +- 系统日志写入 `app.log`,API 请求记录状态码;实时日志连接时预载最近条目 +- 未设置 `FONU_DATA_DIR` 时默认使用当前目录 `.data` + +### 改进 + +- 新增 `scripts/dev.ps1`、`scripts/clean-data.ps1` 便于 Windows 本地开发 +- 测试与文档示例域名统一为 `example.com`,移除个人域名硬编码 + +## [v0.1.9] - 2026-09-12 + +### 修复 + +- 反代保存/删除不再因 Nginx 重载卡住 30 秒:优先 SIGHUP 信号重载,失败时快速强制重启 +- 保存/更新规则先落库再重载 Nginx,避免超时后规则未写入 +- 部分成功(规则已保存但 Nginx 失败)时前端仍关闭弹窗并刷新列表 + +## [v0.1.8] - 2026-09-12 + +### 修复 + +- 修复反代规则删除无反应:删除失败时显示错误提示;Nginx 重载失败时不再回滚已删除的规则 + +## [v0.1.7] - 2026-09-12 + +### 改进 + +- 界面左下角版本号随发版标签注入,与 Docker 镜像版本一致(不再固定显示 v0.1.0) +- 新增 `GET /api/version` 接口;发版时更新根目录 `VERSION` 文件 + +## [v0.1.6] - 2026-09-12 + +### 修复 + +- 为 Nginx 校验/重载/启动命令增加 15 秒超时,避免反代保存时无限转圈 +- 前端 API 请求增加 30 秒超时,超时后显示明确错误提示 +- Nginx 重载失败时先尝试优雅停止再重启,降低端口占用导致的卡死 + +## [v0.1.5] - 2026-09-12 + +### 修复 + +- 反代证书匹配改为读取证书库记录与磁盘文件,导入证书可正确用于 Nginx HTTPS +- 启用 HTTPS 但域名未匹配证书时,返回明确错误(含当前证书覆盖范围)而非 Nginx 校验失败 +- 修复 HTTPS 配置生成时可能遗漏 HTTP 回退的问题 + +## [v0.1.4] - 2026-09-12 + +### 修复 + +- 修复启用 HTTPS 但证书文件不可用时,Nginx 生成 `listen ... ssl` 却缺少 `ssl_certificate` 导致反代保存失败 +- 仅在证书文件完整存在时生成 HTTPS 配置,否则自动降级为 HTTP + +## [v0.1.3] - 2026-09-12 + +### 新增 + +- **反代多域名**:一条规则支持多个前端域名、自定义监听端口,以及 IPv4/IPv6 监听开关 +- **单域名独立端口**:前端地址可写 `example.com:6893` 覆盖规则默认端口 + +### 改进 + +- 本地开发未安装 Nginx 时,保存反代规则仍写入配置并跳过校验,便于调试 UI + +### 数据库迁移 + +- `006_proxy_multi_host.sql` — 反代规则多域名与监听配置 + +## [v0.1.2] - 2026-09-12 + +### 修复 + +- 修复 Nginx PID 文件为空或进程已退出时仍尝试重载,导致反代规则保存失败 + +## [v0.1.1] - 2026-09-12 + +### 修复 + +- 修复全新部署时 API 返回 `null` 导致仪表盘/日志等页面空白、`map` 报错 +- 修复 Nginx 重载使用错误 PID 路径(`/run/nginx.pid`)导致反代保存失败 +- 公网 IP 检测增加多个备用服务,降低 Docker 环境下单点失败概率 +- 修复 SPA 路由刷新时未正确回退到 `index.html` + +## [v0.1.0] - 2026-09-12 + +### 新增 + +- **DDNS 多域名**:支持多条 DDNS 配置,独立管理根域名与记录 +- **证书多域名**:申请/续签支持多域名与通配符(`*.example.com`) +- **ACME 颁发机构**:申请与续签时可选择 Let's Encrypt 正式/测试环境 +- **证书导入**:支持粘贴 PEM 或指定文件路径导入自签/已有证书 +- **Docker 发版**:推送 `v*` 标签自动分架构推送镜像(`latest` / `0.1.0` / `0.1.0-amd64` / `0.1.0-arm64`) +- **表格操作规范**:所有列表操作按钮平铺展示(`renderTableRowActions`) + +### 改进 + +- **仪表盘**:全新布局,ECharts 图表,状态卡片与日志预览 +- **日志页**:分页(20 条/页),支持 `?tab=` 跳转,统一时间格式 +- **证书页**:概览统计、申请弹窗优化、表格自适应宽度 +- **布局**:顶栏时间靠左、操作靠右,内容区铺满主区域 +- **主题**:深色模式与组件样式统一 + +### 数据库迁移 + +- `003_ddns_multi.sql` — DDNS 多配置 +- `004_cert_acme_ca.sql` — 证书 ACME CA 字段 +- `005_cert_domains.sql` — 证书多域名 diff --git a/app-src/Dockerfile b/app-src/Dockerfile new file mode 100644 index 0000000..2e130e2 --- /dev/null +++ b/app-src/Dockerfile @@ -0,0 +1,89 @@ +# 不写 `# syntax=docker/dockerfile:1`:显式指定会让 BuildKit 额外去拉 docker/dockerfile 镜像, +# 在国内加速器(如飞牛的 docker.fnnas.com)上可能直接 401 Unauthorized; +# 而下面用到的 RUN --mount=type=cache 由 Docker 内建的 BuildKit frontend 已支持,无需显式指定。 + +ARG TARGETARCH +# 版本单一来源:根目录 VERSION 文件,本地改版本号只需改此文件 +ARG VERSION_FILE=VERSION + +# 本地项目:基础镜像默认走国内镜像源(fnOS 等环境直连 Docker Hub 会被加速器拦截 401)。 +# 海外/可直连环境可用 --build-arg 或 .env 覆盖回官方名:NODE_IMAGE=node:22-alpine 等 +ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine +ARG GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm +ARG RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim +ARG UPDATER_IMAGE=docker.m.daocloud.io/library/docker:27-cli +# Go 依赖代理:国内默认 goproxy.cn(官方 proxy.golang.org 国内不可达),海外可覆盖回 direct +ARG GOPROXY=https://goproxy.cn,direct + +# 版本注入 stage:把根目录 VERSION 文件载入构建图,供前后端构建 stage 读取 +FROM ${NODE_IMAGE} AS version +ARG VERSION_FILE +COPY ${VERSION_FILE} /VERSION + +FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS web-builder +WORKDIR /src/web +COPY web/package.json web/package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm npm ci +COPY web/ ./ +COPY --from=version /VERSION /src/web/.version +RUN VITE_APP_VERSION=$(tr -d ' \t\n\r' < /src/web/.version) npm run build + +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-builder +ARG TARGETARCH +ARG GOPROXY +ENV GOPROXY=${GOPROXY} +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +COPY --from=version /VERSION ./VERSION +# 先交叉编译 havline-agent 到 embed 目录(替换仓库中的占位文件),主程序构建时自动内嵌 +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o internal/frp/agentbin/havline-agent-linux-amd64 ./cmd/havline-agent \ + && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -o internal/frp/agentbin/havline-agent-linux-arm64 ./cmd/havline-agent +COPY --from=web-builder /src/web/dist ./cmd/havline/web/dist +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \ + -ldflags "-X github.com/havline/havline/internal/version.Version=$(cat /src/VERSION | tr -d ' \t\n\r')" \ + -o /havline ./cmd/havline + +# 一键升级 sidecar:独立容器持有 Docker Socket,主程序只通过共享 Unix Socket 调用它 +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS updater-builder +ARG TARGETARCH +ARG GOPROXY +ENV GOPROXY=${GOPROXY} +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY cmd ./cmd +COPY internal ./internal +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -o /havline-updater ./cmd/havline-updater + +# updater 运行时:Docker CLI / Compose 与 Git 只在 sidecar 内可用 +FROM ${UPDATER_IMAGE} AS updater +RUN apk add --no-cache git docker-cli-compose docker-cli-buildx ca-certificates +COPY --from=updater-builder /havline-updater /usr/local/bin/havline-updater +ENTRYPOINT ["/usr/local/bin/havline-updater"] + +# 运行时 stage +FROM ${RUNTIME_IMAGE} +ARG TARGETARCH +# frpc 不预装:需要内网穿透时在「应用配置」弹窗内按需下载(存入数据目录) +RUN apt-get update \ + && apt-get install -y --no-install-recommends nginx ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=go-builder /havline /app/havline +COPY --from=go-builder /src/web/assets/image /app/web/assets/image +COPY migrations /app/migrations + +# 时区:Go 的 time.Local 取决于 TZ 环境变量,且需要镜像内存在 zoneinfo(故装上 tzdata) +ENV TZ=Asia/Shanghai \ + HAVLINE_DATA_DIR=/data \ + HAVLINE_LISTEN=:6893 \ + HAVLINE_NGINX_MIME_TYPES=/etc/nginx/mime.types + +EXPOSE 6893 80 443 + +VOLUME ["/data"] + +CMD ["/app/havline"] diff --git a/app-src/LICENSE b/app-src/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/app-src/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/app-src/README.md b/app-src/README.md new file mode 100644 index 0000000..1fbf0f7 --- /dev/null +++ b/app-src/README.md @@ -0,0 +1,232 @@ +

+ Havline Logo +

+ +

Havline

+ +

让每一台 NAS,都能安全地连接世界。

+ +

+ 面向 飞牛 fnOS 等 NAS 环境的轻量公网访问管理控制台
+ 反向代理 · DDNS · HTTPS 证书 · 内网穿透 · Cloudflare Tunnel · 一键升级 +

+ +

+ 飞牛 fnOS + Go 1.23 + Vue 3 + Docker + + GPL-3.0 + +

+ +> **Havline 是基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) 1.0.2 版本进行二次开发的项目。** +> 原项目已经完成了反向代理、DDNS、证书和内网穿透等核心能力。Havline 在此基础上进行品牌重塑、 +> 功能迭代、界面调整、部署流程整理和文档重构,并继续遵循 GPL-3.0 开源协议。 + +--- + +## 项目简介 + +NAS 的价值不只在存储,更在于把文件、媒体、自动化工具和家庭服务安全地带到需要它们的地方。 + +**Havline** 把 NAS 用户最常用、也最容易出错的公网访问能力收进一个 Web 控制台: + +- 管理 Nginx 反向代理,不必手工维护复杂配置; +- 自动同步 DDNS,保持域名始终指向当前公网 IP; +- 使用 ACME DNS-01 申请与续期 HTTPS 证书; +- 通过 FRP 在无公网 IP 时经 VPS 中转服务; +- 通过公网 Agent 远程管理 VPS 上的 frps 与 Nginx; +- 使用 Cloudflare Tunnel 发布服务,不必开放公网端口; +- 在一个仪表盘中查看状态、日志、流量和运行健康度。 + +当前开发与测试环境以 **飞牛 fnOS** 为主。群晖、威联通、自建 Linux 等其他环境尚未充分测试, +部署前请自行验证端口、权限和数据目录。 + +## 界面预览 + +| 仪表盘 | 反向代理 | +| --- | --- | +| ![仪表盘](docs/screenshots/dashboard.png) | ![反向代理](docs/screenshots/proxy.png) | + +| DDNS | 证书管理 | +| --- | --- | +| ![DDNS](docs/screenshots/ddns.png) | ![证书管理](docs/screenshots/certificates.png) | + +## 功能总览 + +| 模块 | 能力 | +| --- | --- | +| **反向代理** | 多域名、多端口、HTTP/HTTPS、自动跳转、Nginx 配置生成与热重载 | +| **DDNS** | Cloudflare、DNSPod、阿里云、腾讯云、火山引擎;单任务多域名与自动同步 | +| **HTTPS 证书** | ACME 自动申请与续期、通配符证书、手动导入、证书与私钥下载 | +| **内网穿透** | FRP 多服务端、8 种代理类型、插件、规则下发、流量与连接数监控 | +| **公网服务端** | SSH 安装与升级 `havline-agent`,远程管理 VPS 上的 frps 与 Nginx | +| **Cloudflare Tunnel** | cloudflared 安装、隧道、路由、DNS、健康检查、漂移修复 | +| **仪表盘** | 公网 IP、域名、证书、代理状态、请求趋势、流量和运行健康度 | +| **日志中心** | 系统日志、Nginx 访问日志、错误日志;分页、筛选与自动刷新 | +| **通知与备份** | 邮件、Webhook、Telegram、钉钉、飞书、企业微信;配置导出与恢复 | +| **一键升级** | Docker Compose sidecar 构建、重建、健康检查与失败回滚 | + +## 快速开始 + +### 推荐:飞牛 fnOS Host 模式 + +飞牛系统通常已经占用 `80/443`,推荐使用 **host 网络 + 高位反代端口**: + +```bash +docker run -d \ + --name havline \ + --net=host \ + -v ./data:/data \ + --restart unless-stopped \ + havline:local +``` + +| 入口 | 默认地址 | +| --- | --- | +| 管理后台 | `http://:6893` | +| HTTP 反向代理 | `http://:18080` | +| HTTPS 反向代理 | `https://:9443` | + +### Docker Compose + +```bash +# 飞牛 fnOS / host 网络 +HAVLINE_SESSION_SECRET=your-secret \ + docker compose -f docker-compose.host.yml up -d + +# Bridge 网络 +HAVLINE_SESSION_SECRET=your-secret \ + docker compose -f docker-compose.yml up -d --build +``` + +### 首次登录 + +1. 查看容器或系统日志,获取自动生成的 `admin` 初始密码; +2. 打开 `http://:6893/login`; +3. 登录后立即在 **系统设置 → 管理员账户** 中修改密码。 + +> 完整部署、权限、端口、数据目录、迁移和故障排查请阅读 +> [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) 与 +> [`docs/container-deploy-and-data.md`](docs/container-deploy-and-data.md)。 + +## 内网穿透与公网服务端 + +没有公网 IPv4 或无法端口映射时,可以使用 FRP 经 VPS 中转: + +```text +用户 → DNS(VPS) → frps → frpc → Havline Nginx → 内网服务 +``` + +公网 VPS 上可以安装 `havline-agent`,由本地 Havline 通过 SSH 管理 agent、frps、Nginx 和证书。 + +| 主题 | 文档 | +| --- | --- | +| FRP 使用与场景 | [`docs/FRP公网暴露操作手册.md`](docs/FRP公网暴露操作手册.md) | +| 公网服务端管理 | [`docs/frps服务端管理方案.md`](docs/frps服务端管理方案.md) | +| Agent 监听与安全 | [`docs/agent-listen-strategy-plan.md`](docs/agent-listen-strategy-plan.md) | + +## Cloudflare Tunnel + +Havline 支持将 Cloudflare Tunnel 作为服务发布出口。反向代理规则可选择本机 Nginx 或 +Cloudflare 出口,并自动派生隧道 ingress、同步 DNS、检查健康状态和修复云端漂移。 + +| 主题 | 文档 | +| --- | --- | +| 当前功能 | [`docs/cloudflared-current-features.md`](docs/cloudflared-current-features.md) | +| 实施方案 | [`docs/cloudflared-havline-fit-plan.md`](docs/cloudflared-havline-fit-plan.md) | +| UX 调研 | [`docs/cloudflared-github-ux-research.md`](docs/cloudflared-github-ux-research.md) | + +## 一键升级 + +`docker-compose.yml` 与 `docker-compose.hub.yml` 会启动受限的 `havline-updater` sidecar。 +检测到新版本后,侧栏会出现「一键升级」入口,可在页面中完成: + +1. 拉取或构建目标版本镜像; +2. 重建 `havline` 服务; +3. 执行健康检查; +4. 失败时尝试回滚到升级前镜像。 + +详细设计、安全边界、故障排查和验证步骤见 [`docs/system-upgrade.md`](docs/system-upgrade.md)。 + +## 环境变量 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `HAVLINE_LISTEN` | `:6893` | 管理后台监听地址 | +| `HAVLINE_DATA_DIR` | `/data` | 数据目录,包含数据库、Nginx、证书与日志 | +| `HAVLINE_SESSION_SECRET` | 空,自动生成 | 会话签名与凭据加密密钥;有数据后不要更换 | +| `HAVLINE_NGINX_BIN` | `nginx` | Nginx 可执行文件路径 | +| `HAVLINE_NGINX_HTTP_PORT` | `80` | 新建 HTTP 反代规则默认监听端口 | +| `HAVLINE_NGINX_HTTPS_PORT` | `443` | 新建 HTTPS 反代规则默认监听端口 | +| `HAVLINE_FRPC_BIN` | `frpc` | frpc 可执行文件路径 | +| `HAVLINE_FRP_PID` | `{DATA_DIR}/frp/frpc.pid` | frpc PID 文件 | +| `HAVLINE_UPDATER_SOCKET` | `/run/havline-updater/updater.sock` | 一键升级 sidecar Socket | +| `HAVLINE_AGENT_LISTEN` | `127.0.0.1:7700` | Agent 默认监听地址 | +| `HAVLINE_AGENT_ALLOW_REMOTE` | `0` | 是否允许 Agent 监听非回环地址 | +| `HAVLINE_AGENT_TOKEN` | 安装时生成 | Agent Bearer Token | + +## 技术架构 + +| 层级 | 技术 | +| --- | --- | +| 后端 | Go 1.23、标准库 HTTP、SQLite | +| 数据存储 | `modernc.org/sqlite`,单文件数据库 | +| 反向代理 | Nginx,配置动态生成与热重载 | +| 证书 | go-acme/lego,ACME DNS-01 | +| 内网穿透 | FRP 客户端 / 服务端与自研管理 Agent | +| 隧道 | Cloudflare Tunnel / cloudflared | +| 前端 | Vue 3、TypeScript、Vite、Naive UI、ECharts | +| 部署 | Docker、Docker Compose、Linux systemd | +| 架构 | amd64 / arm64 | + +## 本地开发 + +```bash +# 前端依赖与热更新 +cd web +npm ci +npm run dev + +# 构建前端并同步到 Go embed 目录 +npm run build +cd .. +Copy-Item -Path web/dist/* -Destination cmd/havline/web/dist -Recurse -Force + +# 运行后端 +go run ./cmd/havline + +# 测试 +go test ./... +go vet ./... +cd web && npm test +``` + +`VERSION` 是版本号单一来源。Docker 构建会读取并注入前后端;直接 `go run` 时未注入版本会显示 `dev`。 + +## 文档导航 + +| 文档 | 说明 | +| --- | --- | +| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | 裸机、Docker、飞牛 host 模式部署 | +| [`docs/container-deploy-and-data.md`](docs/container-deploy-and-data.md) | 容器部署与数据目录 | +| [`docs/system-upgrade.md`](docs/system-upgrade.md) | 一键升级与回滚 | +| [`docs/FRP公网暴露操作手册.md`](docs/FRP公网暴露操作手册.md) | FRP 使用手册 | +| [`docs/frps服务端管理方案.md`](docs/frps服务端管理方案.md) | 公网服务端与 Agent | +| [`docs/cloudflared-current-features.md`](docs/cloudflared-current-features.md) | Cloudflare Tunnel 当前能力 | +| [`docs/fnos-app-packaging-plan.md`](docs/fnos-app-packaging-plan.md) | 飞牛应用打包方案 | +| [`docs/README.md`](docs/README.md) | 全部文档索引 | + +## 开源许可与致谢 + +> 开源不是重新发明轮子,而是站在巨人的肩膀上,把一件事继续做得更好。 + +Havline 基于开源项目 [**O96u/Fonu**](https://github.com/O96u/Fonu) **1.0.2** 版本进行二次开发。 +感谢原项目及其贡献者提供的基础实现和工程积累。 + +本项目继续采用 [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源。品牌名称调整不会改变 +原项目及其衍生版本在 GPL-3.0 下的版权、署名和源代码提供义务。 + +软件按“原样”提供,不附带任何担保。请在部署前做好数据备份、访问控制和网络安全配置。 diff --git a/app-src/VERSION b/app-src/VERSION new file mode 100644 index 0000000..f8e233b --- /dev/null +++ b/app-src/VERSION @@ -0,0 +1 @@ +1.9.0 diff --git a/app-src/cmd/havline-agent/main.go b/app-src/cmd/havline-agent/main.go new file mode 100644 index 0000000..7f4322e --- /dev/null +++ b/app-src/cmd/havline-agent/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/havline/havline/internal/agent" +) + +func main() { + listen := flag.String("listen", envOr("HAVLINE_AGENT_LISTEN", "127.0.0.1:7700"), "agent listen address") + allowRemote := flag.Bool("allow-remote", envBool("HAVLINE_AGENT_ALLOW_REMOTE"), "allow non-loopback listen address") + token := flag.String("token", os.Getenv("HAVLINE_AGENT_TOKEN"), "agent bearer token") + nginxDir := flag.String("nginx-conf-dir", envOr("HAVLINE_AGENT_NGINX_CONF_DIR", ""), "managed nginx config directory(留空自动探测 Nginx 实际加载的 vhost 目录)") + certsDir := flag.String("certs-dir", envOr("HAVLINE_AGENT_CERTS_DIR", "/var/lib/havline-agent/certs"), "certificate directory") + frpsConfig := flag.String("frps-config", envOr("HAVLINE_AGENT_FRPS_CONFIG", "/etc/frp/frps.toml"), "frps config path") + frpsBin := flag.String("frps-bin", envOr("HAVLINE_AGENT_FRPS_BIN", "frps"), "frps binary") + dataDir := flag.String("data-dir", envOr("HAVLINE_AGENT_DATA_DIR", "/var/lib/havline-agent"), "agent data directory") + showVersion := flag.Bool("version", false, "print agent version and exit") + flag.Parse() + if *showVersion { + fmt.Println("havline-agent", agent.AgentVersion) + return + } + if *token == "" { + log.Fatal("HAVLINE_AGENT_TOKEN or -token is required") + } + server, err := agent.NewServer(agent.Config{ + ListenAddr: *listen, AllowRemote: *allowRemote, Token: *token, NginxConfDir: *nginxDir, + CertsDir: *certsDir, FrpsConfigPath: *frpsConfig, FrpsBinary: *frpsBin, DataDir: *dataDir, + }) + if err != nil { + log.Fatal(err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := server.ListenAndServe(ctx); err != nil && ctx.Err() == nil { + log.Fatal(err) + } +} + +func envOr(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func envBool(key string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/app-src/cmd/havline-updater/main.go b/app-src/cmd/havline-updater/main.go new file mode 100644 index 0000000..c462b69 --- /dev/null +++ b/app-src/cmd/havline-updater/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "github.com/havline/havline/internal/updater" +) + +func main() { + server, err := updater.NewServer(updater.Config{ + SocketPath: envOr("HAVLINE_UPDATER_SOCKET", "/run/havline-updater/updater.sock"), + ProjectDir: envOr("HAVLINE_UPDATE_PROJECT_DIR", "/workspace"), + ComposeFile: envOr("HAVLINE_UPDATE_COMPOSE_FILE", "docker-compose.yml"), + Service: envOr("HAVLINE_UPDATE_SERVICE", "havline"), + Mode: envOr("HAVLINE_UPDATE_MODE", "local"), + RepoURL: envOr("HAVLINE_UPDATE_REPO", ""), + Branch: envOr("HAVLINE_UPDATE_BRANCH", "main"), + AppURL: envOr("HAVLINE_UPDATE_APP_URL", "http://havline:6893"), + HostProjectDir: envOr("HAVLINE_UPDATE_HOST_DIR", ""), + ProjectName: envOr("HAVLINE_UPDATE_PROJECT_NAME", ""), + SelfImage: envOr("HAVLINE_UPDATE_SELF_IMAGE", ""), + DockerSocket: envOr("HAVLINE_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"), + }, nil) + if err != nil { + log.Fatal(err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := server.Serve(ctx); err != nil && ctx.Err() == nil { + log.Fatal(err) + } +} + +func envOr(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/app-src/cmd/havline/main.go b/app-src/cmd/havline/main.go new file mode 100644 index 0000000..cf74858 --- /dev/null +++ b/app-src/cmd/havline/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "embed" + "errors" + "io/fs" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/havline/havline/internal/app" + "github.com/havline/havline/internal/config" +) + +//go:embed all:web/dist +var webDist embed.FS + +func main() { + cfg := config.Load() + migrationsDir, err := app.ResolveMigrationsDir() + if err != nil { + log.Fatalf("resolve migrations: %v", err) + } + + staticFS, err := fs.Sub(webDist, "web/dist") + if err != nil { + log.Fatalf("load static files: %v", err) + } + + application, err := app.New(cfg, staticFS, migrationsDir) + if err != nil { + log.Fatalf("start application: %v", err) + } + + go func() { + if err := application.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := application.Shutdown(ctx); err != nil { + log.Printf("shutdown error: %v", err) + } +} diff --git a/app-src/deploy/nginx/mime.types b/app-src/deploy/nginx/mime.types new file mode 100644 index 0000000..e63e970 --- /dev/null +++ b/app-src/deploy/nginx/mime.types @@ -0,0 +1,15 @@ +types { + text/html html htm shtml; + text/css css; + text/javascript js; + application/json json; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/octet-stream bin exe dll; + image/png png; + image/svg+xml svg svgz; + image/webp webp; + font/woff woff; + font/woff2 woff2; +} diff --git a/app-src/docker-compose.host.yml b/app-src/docker-compose.host.yml new file mode 100644 index 0000000..310e5d5 --- /dev/null +++ b/app-src/docker-compose.host.yml @@ -0,0 +1,34 @@ +# 飞牛 NAS 等已占用 80/443 的环境:使用 host 网络 + 高位默认反代端口 +# 本地项目:直接用源码构建镜像,不从 Docker Hub 拉取 +name: havline +services: + havline: + build: + context: . + args: + NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: havline:${HAVLINE_VERSION:-local} + container_name: havline + restart: unless-stopped + network_mode: host + volumes: + # 数据目录:默认用命名卷 havline-data(由 Docker 管理,位置随 Docker 数据根)。 + # 想落到指定目录就在同层 .env 里设 HAVLINE_DATA_PATH=/vol1/docker/havline-data —— + # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 + - ${HAVLINE_DATA_PATH:-havline-data}:/data + environment: + HAVLINE_DATA_DIR: /data + HAVLINE_LISTEN: :6893 + # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) + # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 + HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} + # 时区:通知消息时间按此显示(默认东八区) + TZ: ${TZ:-Asia/Shanghai} + HAVLINE_NGINX_HTTP_PORT: ${HAVLINE_NGINX_HTTP_PORT:-18080} + HAVLINE_NGINX_HTTPS_PORT: ${HAVLINE_NGINX_HTTPS_PORT:-9443} + +volumes: + havline-data: diff --git a/app-src/docker-compose.hub.yml b/app-src/docker-compose.hub.yml new file mode 100644 index 0000000..19b6439 --- /dev/null +++ b/app-src/docker-compose.hub.yml @@ -0,0 +1,92 @@ +# 从仓库拉源码构建并启动(适合干净机器:只要这一个文件) +# +# 用法: +# mkdir -p /opt/havline && cd /opt/havline +# # 取本文件(按你仓库页面上的「原始文件」链接下载,或 git clone 后复制过来): +# # curl -fsSLo docker-compose.hub.yml <仓库里这个文件的原始地址> +# docker compose up -d --build # 会话密钥由应用首次启动自动生成并持久化在数据卷里 +# +# 想自己指定会话密钥(可选项):在 compose 文件同层建 .env 写入 +# HAVLINE_SESSION_SECRET=<一串随机字符,openssl rand -hex 32 可生成> +# 注意:它同时是凭据加密密钥,数据卷里已经有数据后不要随意更换,否则已存的凭据无法解密。 +# +# 构建上下文用的是 Git 地址:BuildKit 会自己把仓库克隆下来,不需要先 git clone 到本地; +# 仓库匿名可读时无需凭据。需要认证时改用 https://<用户名>:<令牌>@<主机>/<路径>.git, +# 或先 git clone 后用 docker-compose.yml(本地上下文那份)。 +# +# 首次构建要拉前端与 Go 依赖,比较慢;之后靠 Dockerfile 里的 cache mount 复用缓存。 +# 80/443 已被占用(如飞牛 NAS)请改用 docker-compose.host.yml。 +name: havline +services: + havline: + build: + context: https://jayhub.top/mrlee/Havline.git#main + args: + NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} + UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: havline:${HAVLINE_VERSION:-local} + # 有 build 段时 compose 默认会先尝试拉同名镜像(日志里那条 "havline Pulling" 就是), + # 国内直连 Docker Hub 会超时报警;强制走构建,跳过这一步。 + pull_policy: build + container_name: havline + restart: unless-stopped + depends_on: + - updater + ports: + - "6893:6893" + - "18080:80" + - "9443:443" + volumes: + # 数据目录:默认用命名卷 havline-data(由 Docker 管理,位置随 Docker 数据根)。 + # 想落到指定目录就在同层 .env 里设 HAVLINE_DATA_PATH=/vol1/docker/havline-data —— + # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 + - ${HAVLINE_DATA_PATH:-havline-data}:/data + - havline-updater-sock:/run/havline-updater + environment: + HAVLINE_DATA_DIR: /data + HAVLINE_LISTEN: :6893 + HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock + # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) + # 想自己指定就在同层 .env 里设这个变量;注意它同时是凭据加密密钥,已有数据后不要更换 + HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} + # 时区:通知消息时间按此显示(默认东八区) + TZ: ${TZ:-Asia/Shanghai} + + # 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发 + updater: + build: + context: https://jayhub.top/mrlee/Havline.git#main + target: updater + args: + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: havline-updater:${HAVLINE_VERSION:-local} + pull_policy: build + container_name: havline-updater + restart: unless-stopped + volumes: + - ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock + - ${HAVLINE_UPDATE_HOST_DIR:-.}:/workspace + - havline-updater-sock:/run/havline-updater + environment: + HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock + HAVLINE_UPDATE_PROJECT_DIR: /workspace + HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.hub.yml + HAVLINE_UPDATE_SERVICE: havline + HAVLINE_UPDATE_MODE: git + HAVLINE_UPDATE_REPO: https://jayhub.top/mrlee/Havline.git + HAVLINE_UPDATE_BRANCH: main + HAVLINE_UPDATE_APP_URL: http://havline:6893 + HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-} + HAVLINE_UPDATE_PROJECT_NAME: ${HAVLINE_UPDATE_PROJECT_NAME:-havline} + HAVLINE_UPDATE_SELF_IMAGE: havline-updater:${HAVLINE_VERSION:-local} + HAVLINE_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock} + TZ: ${TZ:-Asia/Shanghai} + +volumes: + havline-data: + havline-updater-sock: diff --git a/app-src/docker-compose.yml b/app-src/docker-compose.yml new file mode 100644 index 0000000..676d99b --- /dev/null +++ b/app-src/docker-compose.yml @@ -0,0 +1,74 @@ +# 固定项目名:不改的话,改了目录名/项目名后 compose 会把它当成新项目, +# havline-data 会变成新空卷(看起来像「数据丢了」)。 +name: havline + +# 本地项目:用源码构建镜像(镜像名随 VERSION 变,便于回滚;也便于导出 / 备份) +# 升级:docker compose up -d --build(或在部署机跑 scripts/upgrade.sh) +# 回滚:HAVLINE_VERSION=上一个版本号 docker compose up -d(镜像 tag 还在本地) +services: + havline: + build: + context: . + args: + NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine} + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + RUNTIME_IMAGE: ${RUNTIME_IMAGE:-docker.m.daocloud.io/library/debian:bookworm-slim} + UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: havline:${HAVLINE_VERSION:-local} + container_name: havline + restart: unless-stopped + depends_on: + - updater + ports: + - "6893:6893" + - "18080:80" + - "9443:443" + volumes: + # 数据目录:默认用命名卷 havline-data(由 Docker 管理,位置随 Docker 数据根)。 + # 想落到指定目录就在同层 .env 里设 HAVLINE_DATA_PATH=/vol1/docker/havline-data —— + # 以 / 或 . 开头的写法会被 compose 当成 bind mount,其余字符串仍是命名卷。 + - ${HAVLINE_DATA_PATH:-havline-data}:/data + - havline-updater-sock:/run/havline-updater + environment: + HAVLINE_DATA_DIR: /data + HAVLINE_LISTEN: :6893 + HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock + # 留空即自动:应用会在数据目录里生成并持久化 /session_secret(0600) + # 想自己指定就设这个变量;注意它同时是凭据加密密钥,已有数据后不要随意更换 + HAVLINE_SESSION_SECRET: ${HAVLINE_SESSION_SECRET:-} + # 时区:通知消息时间按此显示(默认东八区) + TZ: ${TZ:-Asia/Shanghai} + + # 受限升级 sidecar:只有它持有 Docker Socket,主程序通过共享 Unix Socket 触发 + updater: + build: + context: . + target: updater + args: + GO_IMAGE: ${GO_IMAGE:-docker.m.daocloud.io/library/golang:1.23-bookworm} + UPDATER_IMAGE: ${UPDATER_IMAGE:-docker.m.daocloud.io/library/docker:27-cli} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + image: havline-updater:${HAVLINE_VERSION:-local} + container_name: havline-updater + restart: unless-stopped + volumes: + - ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock + - ${HAVLINE_UPDATE_HOST_DIR:-.}:/workspace + - havline-updater-sock:/run/havline-updater + environment: + HAVLINE_UPDATER_SOCKET: /run/havline-updater/updater.sock + HAVLINE_UPDATE_PROJECT_DIR: /workspace + HAVLINE_UPDATE_COMPOSE_FILE: docker-compose.yml + HAVLINE_UPDATE_SERVICE: havline + HAVLINE_UPDATE_MODE: local + HAVLINE_UPDATE_APP_URL: http://havline:6893 + HAVLINE_UPDATE_HOST_DIR: ${HAVLINE_UPDATE_HOST_DIR:-} + HAVLINE_UPDATE_PROJECT_NAME: ${HAVLINE_UPDATE_PROJECT_NAME:-havline} + HAVLINE_UPDATE_SELF_IMAGE: havline-updater:${HAVLINE_VERSION:-local} + HAVLINE_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock} + TZ: ${TZ:-Asia/Shanghai} + +volumes: + havline-data: + havline-updater-sock: diff --git a/app-src/docs/DEPLOYMENT.md b/app-src/docs/DEPLOYMENT.md new file mode 100644 index 0000000..35eac8d --- /dev/null +++ b/app-src/docs/DEPLOYMENT.md @@ -0,0 +1,305 @@ +# Havline 部署安装文档 + +> 适用版本:v1.1.0(源码包 `havline-1.1.0-src.zip`)。本文覆盖从拿到安装包到服务上线的完整流程:环境要求 → 打包 → 三种部署方式 → 首次配置 → FRP/DDNS/证书联动 → 升级与回滚 → 故障排查。 +> 功能细节与组合场景请配合项目内两份文档:`README.md`(功能总览)与 FRP 页面右上「使用说明」(内置十个组合场景详解)。 + +## 0. 交付物清单与安装路径速查 + +| 交付物 | 内容 | 适用 | 安装方式 | +| --- | --- | --- | --- | +| `havline-1.1.0-linux-amd64.tar.gz`(14MB) | 编译好的 Linux 二进制(前端已内嵌)+ migrations + systemd + 一键脚本 | **裸机 Linux 服务器**,目标机只需系统 nginx | 解压 → `sudo bash install.sh`(见 3.4) | +| `havline-1.1.0-image.tar.gz`(约 100-150MB) | 构建好的 Docker 镜像 `havline:local`(含 nginx + frpc) | 目标机有 Docker、要离线安装 | `docker load` → `docker run`(见 3.3 方式 C) | +| `havline-1.1.0-src.zip`(11MB) | 完整源码 + Dockerfile/compose + 部署文档 | 要自行构建 / 二次开发 / 交叉编译 | `docker compose build` 后按方式 A/B 部署(见第 2 节) | + +**怎么选**:飞牛 fnOS 首选镜像包导入(图形界面,见第 3 节末「飞牛 fnOS 部署详解」),要在 NAS 上构建则用源码包 + host 模式 compose;普通 Linux 服务器要省事用 Linux 运行包(免 Docker);目标机不方便联网构建用镜像包离线导入。三者的功能完全一致——容器里跑的也是同一份二进制 + 系统 nginx。 + +## 1. 包内容与环境要求 + +### 1.1 源码包内容 + +`havline-1.1.0-src.zip`(约 11MB,304 项)为完整可构建源码: + +| 目录/文件 | 说明 | +| --- | --- | +| `cmd/` `internal/` | Go 后端源码(含已迁移的多服务端 FRP 模块) | +| `web/` | Vue 3 + TypeScript 前端源码 | +| `migrations/` | SQLite 数据库迁移 001–022(启动时自动按序应用) | +| `Dockerfile` / `docker-compose*.yml` | 本地构建镜像与三种部署编排 | +| `VERSION` / `CHANGELOG.md` | 版本号单一来源(当前 `1.1.0`)与版本记录 | +| `deploy/nginx/mime.types` | 容器内 Nginx 依赖的 MIME 类型文件 | +| `docs/screenshots/` | README 引用的界面截图 | + +**已排除**(打包时即剔除,无需自行处理):`.data/`(本地运行数据)、`web/node_modules`(构建时自动安装)、`internal/frp-1.0.2/` 与 `FrpView-1.0.2.vue`(弃用归档)、`*.db`。 + +### 1.2 部署机要求 + +| 项 | 要求 | +| --- | --- | +| 系统 | 飞牛 fnOS(主要目标平台);其他 Linux x86_64 / arm64 理论可用,未充分测试 | +| Docker | 20.10+(含 compose v2) | +| 内存 | ≥ 512MB 空闲(构建期建议 ≥ 2GB,前端编译吃内存) | +| 磁盘 | ≥ 2GB(镜像约 300MB + 数据目录按日志量增长) | +| 网络 | 构建期需访问 npm registry 与 Go module proxy;运行期需访问 GitHub(按需下载 frpc)与你的 DNS 服务商 API(DDNS/证书) | +| 端口 | 管理端口 6893;反代端口默认 80/443(Bridge)或 18080/9443(飞牛 host 模式) | + +## 2. 构建镜像(所有部署方式的前提) + +解压源码包后进入项目根目录执行: + +```bash +docker compose build +``` + +一次构建同时产出:前端 `dist`、Go 后端二进制与运行镜像(frpc 不预装,运行期用「应用配置」按需下载),标签为 `havline:local`。 + +**版本号**:读取根目录 `VERSION` 文件(当前 `1.1.0`)自动注入前后端显示;要改版本号只需编辑该文件后重新 build。 + +**网络受限环境**可覆盖基础镜像与 frpc 下载源(默认值见 `Dockerfile` 顶部注释): + +```bash +docker build \ + --build-arg NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine \ + --build-arg GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm \ + --build-arg RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim \ + -t havline:local . +``` + +## 3. 三种部署方式(按环境二选一) + +### 方式 A:飞牛 fnOS / 已占用 80/443 的环境 —— host 模式(推荐) + +飞牛系统自带 Nginx 占用 80/443,Havline 使用 host 网络 + 高位反代端口避免冲突。 + +```bash +HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.host.yml up -d --build +``` + +- 无端口映射,容器直接使用宿主机网络栈; +- 反代端口由环境变量 `HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443` 决定(文件内已内置); +- 入口:管理后台 `http://:6893`,HTTP 反代 `http://:18080`,HTTPS 反代 `https://:9443`; +- 数据持久化在命名卷 `havline-data`(挂载到容器 `/data`)。 + +### 方式 B:其他环境 / 空闲 80/443 —— Bridge 端口映射 + +```bash +HAVLINE_SESSION_SECRET=请改成一串随机字符 docker compose -f docker-compose.hub.yml up -d --build +``` + +- 端口映射 `6893:6893`、`18080:80`、`9443:443`(宿主机 18080/9443 → 容器 80/443); +- 数据同样在命名卷 `havline-data`。 + +### 方式 C:docker run(不想用 compose) + +```bash +docker run -d --name havline \ + --net=host \ + -v ./data:/data \ + -e HAVLINE_SESSION_SECRET=请改成一串随机字符 \ + --restart unless-stopped \ + havline:local +``` + +host 模式下反代端口用环境变量控制:`-e HAVLINE_NGINX_HTTP_PORT=18080 -e HAVLINE_NGINX_HTTPS_PORT=9443`。Bridge 环境改为 `-p 6893:6893 -p 18080:80 -p 9443:443`(去掉 `--net=host`)。 + +**生成随机 SESSION_SECRET**:`openssl rand -hex 32`(Linux)或 PowerShell:`-join ((48..57)+(97..122) | Get-Random -Count 32 | % {[char]$_})`。 + +> **`HAVLINE_SESSION_SECRET` 是什么**:Havline 的主密钥,两个用途——① 签名/加密登录会话 Cookie(决定登录态是否可被伪造);② 作为 `secret.Box` 加密存储中敏感凭据(DDNS API Token、FRP Token、SMTP 密码等)。默认值 `change-me-in-production` 是公开的:拿到它即可伪造管理员会话、解密数据库里的全部凭据,**生产环境必须替换**。⚠️ 换密钥的时机:首次启动前设置;若之后更换,旧密钥加密的凭据将无法解密(应用会提示重新保存),需到对应页面重录 Token。 + +### Linux 服务器完整部署步骤(Ubuntu / Debian / CentOS) + +```bash +# 0. 安装 Docker(已装跳过) +curl -fsSL https://get.docker.com | sh +sudo systemctl enable --now docker + +# 1. 解压源码包并进入目录 +unzip havline-1.1.0-src.zip -d havline && cd havline + +# 2. 构建镜像(约 3-10 分钟,取决于网络与机器性能) +docker compose build + +# 3. 生成密钥并启动(Bridge 模式,映射 6893/18080/9443) +echo "HAVLINE_SESSION_SECRET=$(openssl rand -hex 32)" > .env +docker compose -f docker-compose.hub.yml up -d + +# 4. 防火墙放行(按需) +sudo ufw allow 6893/tcp # 管理后台 +sudo ufw allow 80/tcp 443/tcp # 反代(Bridge 模式容器内监听 80/443) +# firewalld 用: firewall-cmd --permanent --add-port=6893/tcp && firewall-cmd --reload + +# 5. 验证 +docker logs havline | grep -i password # 取 admin 初始密码 +curl -I http://127.0.0.1:6893/login # 应返回 200 +``` + +- **开机自启**:compose 内已含 `restart: unless-stopped`,且第 0 步已 `enable docker`,重启服务器自动拉起; +- **80/443 被系统 Nginx 占用**:改用 `docker-compose.host.yml`(host 网络 + 18080/9443 高位端口),或改 `docker-compose.hub.yml` 里的宿主端口映射; +- **离线部署**(目标机不方便联网构建):在有网的机器上 `docker compose build` 后 `docker save havline:local | gzip > havline.tar.gz`,拷贝到目标机 `docker load < havline.tar.gz`,再用方式 C 的 `docker run` 启动(无需再构建); +- **数据位置**:命名卷 `havline-data`(`docker volume inspect havline-data` 查看宿主机实际路径),备份该卷即备份全部配置与证书。 + +### Linux 运行包安装(免 Docker,裸机部署) + +适用:不想装 Docker 的 Linux 服务器。前提:系统已装 nginx(Havline 复用它做反代),root 权限。 + +```bash +# 1. 解压 +tar xzf havline-1.1.0-linux-amd64.tar.gz -C havline && cd havline + +# 2. 一键安装(自动建 havline 系统用户、装到 /opt/havline、生成随机密钥、注册 systemd 开机自启) +sudo bash install.sh + +# 3. 验证 +systemctl status havline +curl -I http://127.0.0.1:6893/login +journalctl -u havline | grep -i password # 取 admin 初始密码 +``` + +包内容:`havline` 二进制(前端已 `go:embed` 内嵌,无需单独部署 dist)、`migrations/`(从二进制同级目录自动查找)、`deploy/nginx/mime.types`、`havline.service`(systemd 模板)、`install.sh`、`DEPLOYMENT.md`。 + +关键路径(install.sh 自动配置): + +| 项 | 值 | +| --- | --- | +| 二进制 | `/opt/havline/havline` | +| 数据目录 | `/var/lib/havline`(数据库、证书、日志全在这) | +| 会话密钥 | `/etc/default/havline`(重复安装保留原值) | +| 服务 | `systemctl restart havline` 生效配置改动 | + +**备份/迁移**:停服务后打包 `/var/lib/havline` 即可(`tar czf havline-data.tar.gz /var/lib/havline`),新机器解压到同路径 + 装同版本二进制即完成迁移。 + +**手动安装**(不用脚本):按包内 `README.txt` 要点——建 `havline` 系统用户、复制二进制与 migrations 到 `/opt/havline`、`EnvironmentFile` 写入随机密钥、`systemctl enable --now havline`。 + +> 镜像包(`havline-1.1.0-image.tar.gz`)来源:在任意装了 Docker 的机器上对源码包执行 `docker compose build` 后 `docker save havline:local | gzip > havline-1.1.0-image.tar.gz`;目标机 `docker load < havline-1.1.0-image.tar.gz` 后按方式 C 启动。 + +### 飞牛 fnOS 部署详解(图形界面,不敲命令) + +前置:fnOS 应用中心安装 Docker 应用(较新版本自带)。以下菜单名以 fnOS 当前版本实际界面为准,大致路径一致。 + +**方式一:导入现成镜像(推荐,5 分钟)** + +1. **上传文件**:把 `havline-1.1.0-image.tar.gz` 通过文件管理 / SMB 上传到 NAS,如共享目录 `docker/havline/`; +2. **导入镜像**:打开 Docker 应用 → 镜像 → 导入(从文件加载)→ 选择该 tar.gz → 等待镜像列表出现 `havline:local`(若界面只接受 .tar,先在电脑上解压出内层的 .tar 再导入); +3. **创建容器**:容器 → 创建: + +| 配置项 | 填写 | +| --- | --- | +| 名称 | `havline` | +| 镜像 | `havline:local` | +| 网络 | **host**(飞牛 80/443 被系统占用,host + 高位端口最省事;UI 不支持 host 时选 Bridge 并映射 6893/18080/9443) | +| 存储挂载 | NAS 建目录(如 `/vol1/…/docker/havline/data`)→ 映射到容器 `/data`(读写) | +| 环境变量 | `HAVLINE_SESSION_SECRET=随机串`、`HAVLINE_DATA_DIR=/data`、`HAVLINE_LISTEN=:6893`、`HAVLINE_NGINX_HTTP_PORT=18080`、`HAVLINE_NGINX_HTTPS_PORT=9443` | +| 重启策略 | 自动重启 / unless-stopped(开机自启) | + +4. **启动并取初始密码**:容器详情 → 日志,搜 `password`;浏览器打开 `http://:6893` 登录。 + +**方式二:Compose 项目(用源码包,在 NAS 上构建)** + +1. 把源码包 zip 上传到 NAS 并解压(文件管理支持解压,或电脑解压后整目录上传); +2. Docker 应用 → 项目(Compose)→ 新建:指定项目目录为解压后的源码目录、使用 `docker-compose.host.yml`(或直接把该文件内容粘贴到 Compose 编辑器); +3. 在源码目录同层放一个 `.env` 文件,内容一行:`HAVLINE_SESSION_SECRET=随机串`; +4. 构建并启动。⚠️ NAS 上构建耗时较长(前端编译),低配机器建议改用方式一; +5. 后续升级:上传新源码覆盖 → 项目重新构建即可,数据在卷里不受影响。 + +**Compose 构建报 401(docker.fnnas.com)**:fnOS 的 Docker 设置里默认配了自家加速地址,但它对 `library/` 官方镜像拒绝匿名拉取。三种解法任选: + +1. **改用镜像包(最省事)**:跳过构建,按方式一导入 `havline-1.1.0-image.tar.gz`; +2. **覆盖基础镜像源**:在源码目录的 `.env` 里加三行(已有 HAVLINE_SESSION_SECRET 就追加在下面): + ``` + NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine + GO_IMAGE=docker.m.daocloud.io/library/golang:1.23-bookworm + RUNTIME_IMAGE=docker.m.daocloud.io/library/debian:bookworm-slim + GOPROXY=https://goproxy.cn,direct + ``` + + (Dockerfile/compose 现已内置这些默认值,无需 .env;此处列出供手动 docker build 参考或覆盖。`GOPROXY` 解决 go mod download 访问 proxy.golang.org 超时——国内默认 goproxy.cn,海外可覆盖回 direct。) + 然后 Compose 重新构建——compose 文件已支持这三个变量透传; +3. **换/关掉 fnOS 加速地址**:Docker 应用 → 设置 → 镜像源,把 `docker.fnnas.com` 换成可用加速源(如 `docker.m.daocloud.io`)或清空,再重新构建。 + +**fnOS 注意事项**: + +- 80/443 已被 fnOS 系统 Nginx 占用——**不要**把反代规则监听 80/443,用默认 18080/9443; +- host 模式下容器端口即宿主机端口,fnOS 防火墙(如有启用)放行 6893 / 18080 / 9443; +- 数据目录建议放在存储池挂载路径下(`/vol1/…`,以文件管理器显示的实际路径为准),系统重装时数据不随系统分区丢失; +- 升级:方式一重新导入新镜像 → 停止并删除旧容器 → 用相同挂载/环境变量重建(数据在映射目录,不丢);方式二直接重新构建。 + +## 4. 首次登录与安全配置 + +1. 启动后查看初始密码:`docker logs havline | grep -i password`(自动生成的 admin 初始密码会打印在日志里); +2. 浏览器打开 `http://:6893/login`,用 `admin` + 初始密码登录; +3. **立即**在「设置 → 安全」修改管理员密码; +4. 确认「设置 → 常规」中 `HAVLINE_SESSION_SECRET` 已改为非默认值(compose 文件中已传入则跳过); +5. 建议在「设置 → 通知」配置一种告警方式(邮件 / Webhook / Telegram),DDNS 失败、证书续签失败、登录异常等事件会推送。 + +## 5. 反向代理与 HTTPS 证书 + +1. **反向代理页 → 添加规则**:填前端域名(如 `app.example.com`)、后端目标(内网应用地址)、HTTPS 开关与证书; +2. **证书页 → 申请证书**:ACME DNS-01 验证,需先在 DDNS 页配置对应服务商凭证(Cloudflare / DNSPod / 阿里云 / 腾讯云 / 火山引擎),通配符证书 `*.example.com` 可覆盖整组域名; +3. DNS 记录指向本机 IP:家庭宽带场景由 DDNS 自动跟随(见第 6 节); +4. 验证:浏览器访问域名,证书页看剩余有效期,日志中心看访问/错误日志。 + +## 6. DDNS(域名解析自动跟随) + +DDNS 页 → 添加任务:选服务商 → 填 API 凭证(可「测试连接」)→ 添加解析记录(支持 `@`、子域、完整域名)→ 开 IPv4/IPv6 → 保存。 + +**IP 来源两种模式**(任务详情 → 「自定义 IP」按钮设置): + +| 模式 | 填法 | 适用 | +| --- | --- | --- | +| 自动检测(默认) | 自定义 IP 留空 | 域名指向家庭宽带出口 IP | +| 自定义 IP | 填固定公网 IP(如 VPS 的 IP) | 域名直接指向公网服务器 | + +自定义 IP 必须为公网地址,填内网 IP 会被拒绝更新。VPS 动态 IP 场景:自定义 IP 填 VPS 当前 IP,FRP 服务端地址填 DDNS 域名,VPS 换 IP 后自动跟随。 + +## 7. FRP 内网穿透(无公网 IP 时) + +前置:一台有公网 IP 的 VPS。 + +1. **Havline 添加服务端**:FRP 穿透页 → 添加服务端 → 填 VPS 地址、bindPort(默认 7000)、认证方式与 Token(与 frps 一致); +2. **生成 frps 配置**:服务端「详情」→「frps.toml」→ 复制生成内容到 VPS 保存为 `frps.toml`,执行 `frps -c frps.toml`;按文件末尾的防火墙清单在 VPS 安全组放行端口; +3. **添加穿透规则**:8 种类型按需选——tcp/udp 填远程端口;http/https/tcpmux 填自定义域名(走 frps vhost 端口);stcp/sudp/xtcp 凭 secretKey(无公网监听); +4. **应用并验证**:「应用配置」→「校验并应用配置」;服务端「详情」看五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与独立日志。 + +## 8. 升级与回滚 + +### 升级 + +```bash +# 1. 备份数据(最重要) +docker run --rm -v havline-data:/data -v ${PWD}:/backup alpine tar czf /backup/havline-data-backup.tar.gz /data +# 2. 解压新版源码包覆盖项目目录(VERSION 与 migrations 会更新) +# 3. 重建并启动(迁移 015–022 会在启动时自动应用) +docker compose -f docker-compose.host.yml up -d --build +# 4. 看启动日志确认迁移与版本 +docker logs -f havline +``` + +### 回滚 + +```bash +docker compose down +# 还原上一版源码目录后: +docker compose -f docker-compose.host.yml up -d --build +# 数据目录如需还原:解压第 1 步的备份 tar 到挂载卷 +``` + +> 注意:数据库迁移只向前应用(`schema_migrations` 记录版本号)。跨大版本回滚前必须先做第 1 步的数据备份,否则旧版代码遇到新表结构可能启动异常。 + +## 9. 故障排查速查 + +| 现象 | 处理 | +| --- | --- | +| 容器起来但 6893 打不开 | `docker logs havline` 看启动报错;`docker port havline`(Bridge 模式)确认映射 | +| 提示「会话加密密钥」警告 | 未设置 `HAVLINE_SESSION_SECRET`,compose 传入即可 | +| 反代 502 | 目标内网应用未启动或地址填错;日志中心看 Nginx error 日志 | +| 证书申请失败 | DDNS 凭证是否有效、域名 DNS 是否已生效、ACME 邮箱是否已设置 | +| frpc 连不上 frps | 两端 Token/端口不一致;用服务端「详情 → frps.toml」重新生成对齐 | +| 穿透流量显示 0 | frps 未配置 webServer 或 Havline 服务端表单未填管理地址(非必须功能) | +| 页面改动未生效 | 确认访问的是容器端口而非开发服务器;`docker compose up -d --build` 重建 | + +## 10. 卸载 + +```bash +docker compose down # 停止并删除容器(数据卷保留) +docker volume rm havline-data # 彻底删除数据(不可逆,先备份) +docker rmi havline:local # 删除镜像 +``` diff --git a/app-src/docs/FRP公网暴露操作手册.md b/app-src/docs/FRP公网暴露操作手册.md new file mode 100644 index 0000000..1ea87fe --- /dev/null +++ b/app-src/docs/FRP公网暴露操作手册.md @@ -0,0 +1,170 @@ +# 内网应用经 FRP 暴露公网:NAS 侧完整操作手册 + +> 场景:把 NAS 内网应用(本例 `192.168.31.152:7023`)通过一台公网 VPS(`38.95.74.87`)以域名(`tv.36800.cc`)暴露出去,URL 不带端口号,且后续域名 / HTTPS / 访问控制全部在 NAS 的 Havline 网页里管理,VPS 只做纯流量转发。 +> 适用版本:Havline v1.1.0+(FRP 多服务端模块)。VPS 上 **不需要也不应该** 装 Nginx 反代。 + +## 0. 最终架构与访问地址 + +```text +用户浏览器 + ↓ http://tv.36800.cc(80 端口,URL 不带端口) +VPS frps(监听 80 —— 由 frpc 动态注册的 tcp 隧道,VPS 上无需 Nginx) + ↓ FRP 隧道(bindPort 7000,TLS) +NAS frpc + ↓ 127.0.0.1:18080 +Havline Nginx(按域名分流,后续可加 HTTPS/黑白名单) + ↓ http://192.168.31.152:7023 +内网应用 +``` + +**访问地址:`http://tv.36800.cc`** + +| 角色 | 地址 | 说明 | +| --- | --- | --- | +| 公网 VPS | `38.95.74.87`(tv.36800.cc) | 只跑 frps,Nginx 停用 | +| NAS 内网应用 | `192.168.31.152:7023` | 真正的 Web 服务 | +| Havline 管理后台 | `http://:6893` | 全部配置入口 | + +## 1. 前置条件 + +- VPS:已部署 frps(bindPort 7000、token 认证;参考 Havline 服务端详情「frps.toml」一键生成); +- VPS:frps 的 Nginx **未**占用 80(占用见第 5 节排障); +- 云厂商安全组已放行:7000/tcp(frps 控制)、80/tcp(对外)、7500/tcp(frps 管理接口,可选,用于流量统计); +- NAS:Havline 已部署并能打开管理后台; +- 域名:`tv.36800.cc` 的 DNS A 记录指向 VPS IP(可用 Havline DDNS 页「自定义 IP」固定,或由 DNS 服务商直接管理)。 + +## 2. Havline 网页配置(三处) + +### 2.1 FRP 服务端 + +路径:**FRP 穿透 → 添加服务端** + +| 字段 | 值 | +| --- | --- | +| 主机地址 | VPS 的 IP 或域名 | +| 端口 | 7000(= frps bindPort) | +| 认证方式 | Token,与 VPS frps.toml 的 `auth.token` 一致 | +| TLS | 开启(frps 默认接受) | +| frps 管理接口(可选) | 填 frps `webServer` 的地址/端口/用户/密码,用于显示穿透流量 | + +保存后状态应显示「运行中 · 已连接」。 + +### 2.2 穿透规则(核心) + +路径:**FRP 穿透 → 添加规则** + +| 字段 | 值 | 说明 | +| --- | --- | --- | +| 所属服务端 | 上一步的服务端 | | +| 名称 | `tv`(frps 全局唯一,出现在日志里) | | +| 类型 | **tcp** | ⚠️ 不选 http——80 直接由 frps 转发,域名分流交给 Havline 反代 | +| 远程端口 | **80** | 选 tcp 类型后出现此字段;frpc 登录时向 frps 动态注册 | +| 内网目标 | **127.0.0.1:18080** | 指向 Havline 自己的 Nginx HTTP 监听(fnOS host 模式默认 18080) | + +保存 → 右上 **「应用配置」→「校验并应用配置」**,或对该服务端「停止→启动」。 + +### 2.3 反向代理(域名分流) + +路径:**反向代理 → 添加规则** + +| 字段 | 值 | +| --- | --- | +| 前端域名 | `tv.36800.cc` | +| 监听端口 | **18080**(与 2.2 的内网目标端口一致;fnOS 下不要用 80,系统 Nginx 已占用) | +| 目标地址 | `http://192.168.31.152:7023`(⚠️ 指向真正的内网应用,完整 URL 带协议与端口) | +| HTTPS | 按需;fnOS 下 Havline Nginx 的 HTTPS 监听默认 9443 | + +> ⚠️ **回环警告**:目标地址千万不要填 `http://tv.36800.cc:xxxx`——那会让请求从 NAS 出公网再回 NAS(NAS→VPS→NAS 绕圈)。反代目标是**内网应用本身**。 +> ⚠️ **「目标地址缺少主机名」报错**:目标地址格式必须完整 `http://IP:端口`(协议+主机+端口缺一不可,单斜杠 `http:/` 或中文冒号都会报 400)。 + +## 3. VPS 侧配置(一次性的两步) + +### 3.1 frps.toml(Havline 一键生成) + +Havline 服务端「详情」→「frps.toml」→ 复制内容到 VPS 保存为 `frps.toml`: + +```toml +bindAddr = "0.0.0.0" +bindPort = 7000 + +auth.method = "token" +auth.token = "<与 Havline 一致>" + +webServer.addr = "0.0.0.0" # 可选:Havline 拉取穿透流量 +webServer.port = 7500 +webServer.user = "admin" +webServer.password = "<自定义>" + +log.to = "console" +log.level = "info" +log.maxDays = 3 +log.disablePrintColor = false +``` + +> 注意:**这个文件里没有任何远程端口**。tcp 类型规则的远程端口(80)由 frpc 登录时动态注册,frps 只管 bindPort 和认证——规则改了也不用改这个文件。 + +启动:`./frps -c frps.toml`(建议配 systemd)。 + +### 3.2 释放 80 端口(关键,`port unavailable` 的唯一解法) + +80 只能给一个进程。frps 要绑 80,VPS 上原有的 Nginx 必须停掉: + +```bash +# 确认 80 被谁占用 +ss -tlnp | grep ':80 ' + +# 停止并禁用 Nginx(宝塔面板安装的在宝塔「软件商店」里停) +systemctl stop nginx +systemctl disable nginx + +# 确认已释放(应无输出) +ss -tlnp | grep ':80 ' +``` + +frpc 约每 33 秒自动重试注册,释放后无需任何手动操作,注册成功后 frps 日志出现 proxy 启动记录、frpc 日志的 `start error: port unavailable` 消失。 + +> 如果 VPS 的 80/443 还要跑其他站点(Nginx 不能停),本方案不适用——改回「VPS Nginx 监听 80 → proxy_pass 127.0.0.1:8080(frps vhost)」的方案,FRP 规则用 http 类型,见第 6 节方案对比。 + +## 4. 验证清单 + +| 步骤 | 命令 / 操作 | 预期 | +| --- | --- | --- | +| VPS 80 已监听 | `ss -tlnp \| grep ':80 '` | 进程为 frps | +| frpc 注册成功 | Havline 服务端「日志」 | 无 `port unavailable`,有 proxy 启动记录 | +| 隧道通 | Havline 服务端「测速」/「详情」诊断 | TCP 可达、frpc 登录 ok、延迟正常 | +| 分流通 | 服务端「详情」→ 诊断 `proxy_reachable` | ok | +| 端到端 | 外网浏览器 `http://tv.36800.cc` | 直达内网应用页面 | + +## 5. 故障排查(按链路顺序) + +| 现象 | 断点 | 处理 | +| --- | --- | --- | +| `Connection refused`(外部 curl 80) | **VPS 侧**:frps 没绑上 80 | ① `ss -tlnp \| grep ':80 '` 看 80 被谁占(多为 Nginx 没停)→ 停用;② `systemctl status frps` 确认在跑且用的新配置(nohup 手动跑的必须 `pkill frps` 重启);③ 重启后等 frpc 自动重试 | +| frpc 日志 `start error: port unavailable` | 同上 | VPS 80 被占;见 3.2 | +| 诊断「frpc 登录」失败 | Token/端口不一致 | 两端 token 对齐;端口=frps bindPort | +| 登录成功但 `proxy_reachable` 失败 | Havline 反代 | 反代规则监听端口与 FRP 内网目标端口一致(18080);反代已启用 | +| 打开域名 404 | 域名分流失败 | 反代规则的前端域名与访问域名完全一致;frps 无需 vhost(tcp 方案) | +| 域名解析不对 | DNS | `nslookup tv.36800.cc` 应返回 VPS IP;用 DDNS 自定义 IP 固定 | +| 流量统计全 0 | frps webServer 未配 | 非必须;要在 Havline 看流量就配 webServer 并在服务端表单填管理接口 | + +## 6. 两种方案对比(VPS 80 的归属) + +| | 方案 A:NAS 侧全管(本文) | 方案 B:VPS Nginx 反代 | +| --- | --- | --- | +| FRP 规则类型 | tcp,远程端口 80 → 127.0.0.1:18080 | http 类型 + frps `vhostHTTPPort=8080` | +| http 类型去端口 | —(tcp 方案直接占 80) | `vhostHTTPPort` 从 8080 改 **80**:URL 变 `http://域名`(无端口);前提同样是 VPS 停 Nginx 释放 80,改后 `pkill frps` 重启生效;NAS 侧规则无需动 | +| VPS Nginx | 不需要(停用) | 必须:listen 80 → proxy_pass 127.0.0.1:8080(**必须带** `proxy_set_header Host $host`) | +| 加新域名 | Havline 反代加一条 + FRP 加一条 tcp 规则(远程端口仍 80,内网目标同 18080) | VPS 上加 server 块 | +| 上 HTTPS | Havline 证书页(DNS-01)+ 反代开 HTTPS + FRP 加 tcp 443→127.0.0.1:9443 | VPS 上装证书 | +| 访问控制(IP/BasicAuth) | Havline 反代页直接配 | VPS 上手写 | +| 限制 | VPS 80/443 不能再跑别的站点 | VPS 可共存多站点 | +| URL | `http://tv.36800.cc`(无端口) | `http://tv.36800.cc`(80)或带 vhost 端口 | + +选型:VPS 专用做穿透入口 → 方案 A;VPS 80/443 另有他用 → 方案 B。两方案不混用(80 归属唯一)。 + +## 7. HTTPS 升级路径(方案 A 延伸) + +1. **Havline 证书页**:申请 `tv.36800.cc` 或 `*.36800.cc` 证书(ACME DNS-01,复用 DDNS 页服务商凭证); +2. **反代规则**:开 HTTPS、绑定证书、(可选)HTTP 强制跳转; +3. **FRP 加一条 tcp 规则**:远程端口 443 → 内网目标 `127.0.0.1:9443`(Havline Nginx HTTPS 监听); +4. 安全组放行 443;之后 `https://tv.36800.cc` 可用,TLS 全程在 NAS 侧终止。 diff --git a/app-src/docs/README.md b/app-src/docs/README.md new file mode 100644 index 0000000..4b6a1a2 --- /dev/null +++ b/app-src/docs/README.md @@ -0,0 +1,39 @@ +# Havline 文档索引 + +> 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 +> 最近更新:2026-10-01(v1.9.0) + +## 方案与设计(本目录) + +| 文件 | 主题 | 状态 | +| --- | --- | --- | +| `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 | +| `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3、P0-4 已完成,其余待实施 | +| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.8) | +| `agent-listen-strategy-plan.md` | Agent 默认监听策略:回环监听、SSH 隧道优先、HTTPS pin 可选与自动迁移方案 | 阶段 1-4 已实施(v1.4.0);2.0.0 兼容清理待后续 | +| `cloudflared-integration-plan.md` | Cloudflare Tunnel(cloudflared)与 Havline 的融合方案:托管模型、复用面、分阶段落地与 v2 优化 | 阶段 1-2 已实施(v1.6.0);后续按 `cloudflared-havline-fit-plan.md` 推进 | +| `cloudflared-feature-plan.md` | Cloudflare 隧道功能全集:官方最新文档、参考项目功能、Havline 现状差距与 P0-P3 路线 | 查漏用;实施以 `cloudflared-havline-fit-plan.md` 为准 | +| `cloudflared-havline-fit-plan.md` | Cloudflare 隧道 Havline 适配版计划:出口抽象、规则同源、体系整合、明确不做项与阶段实施 | 阶段 1-3 主体已实施;剩余项见 `cloudflared-current-features.md` | +| `cloudflared-current-features.md` | Cloudflare 隧道当前已实现功能说明:页面、出口、路由、DNS、监控、备份、诊断、API 与限制 | 当前版本功能基线 | +| `cloudflared-github-ux-research.md` | GitHub 同类项目调研:Cloudflare 管理面板、反向代理、访问平台、监控与流量检查器的可借鉴体验 | 调研与优化路线 | +| `fnos-app-packaging-plan.md` | 飞牛 fnOS 应用打包方案:官方文档依据、Docker 模板、向导、权限、数据迁移、升级与发布流程 | 方案待实施 | +| `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 | +| `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 | +| `agent-capability-plan.md` | havline-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 | +| `agent-view-design.md` | 公网 Agent 页设计方案(Pass 1/2 流程) | 部分过期:顶部下拉已被左列表 + 右详情取代 | +| `agent-view-design-v2.md` | 公网 Agent 页设计方案 v2 | 部分过期:同上 | +| `agent-view-refactor-plan.md` | 公网 Agent 页四段式(状态 → 控制 → 生命周期 → 输出)整改 | 已实施 | +| `proxy-remote-design.md` | 公网反代页设计方案(以本地反代页为参照) | 已实施;列表已改为左服务端 + 右台账 | +| `agent-proxy-settings-plan.md` | 公网反代「部署设置」对齐本地反代的语义迁移 | 已实施 | +| `agent-proxy-roadmap.md` | 公网反代功能增强规划(含部署踩坑史) | 部分已实施(漂移检测、全量重部署),其余待排期 | +| `frps-features.md` | frps 服务端功能实现清单(对照官方 frps_full_example.toml) | 参考 | +| `frps服务端管理方案.md` | FRPS 服务端管理方案(havline-agent 部署在公网服务器) | 已实施(Agent 管理面板) | +| `frps管理方案.md` | FRPS 管理方案(早期版本) | 疑似已被 `frps服务端管理方案.md` 取代,保留供追溯(待确认) | +| `upstream-sync-plan.md` | 上游 1.0.2 → 1.0.5 对照与可借鉴项 | 部分过期:对 `/frp` 现状的描述基于 2026-09-23 | +| `FRP公网暴露操作手册.md` | FRP 公网暴露操作手册(面向使用) | 参考 | +| `DEPLOYMENT.md` | 部署安装:裸机 / Docker / 飞牛 host 模式 | 参考;适用 `havline-1.1.0` 安装包 | + +## 其它位置 + +- 仓库根 `../docs/`(`111.md`、`FRP迁移方案-0.1.25到1.0.2.md`、`FRP版本对比-1.0.2-vs-0.1.25.md`)为**临时区**,不随本项目维护;正式文档一律放本目录。 +- `screenshots/` 供 README 引用。 diff --git a/app-src/docs/agent-capability-plan.md b/app-src/docs/agent-capability-plan.md new file mode 100644 index 0000000..0a8454d --- /dev/null +++ b/app-src/docs/agent-capability-plan.md @@ -0,0 +1,269 @@ +# havline-agent 能力扩展方案 + +> **实施状态(2026-09-24)**:P0/P1/P2 已全部落地(agent 0.11.0);P3 三项按本文约定不做。剩余为真机验收项,见 `docs/project-audit-2026-09-24.md`。 + +> 本文盘点 VPS 侧 `havline-agent` 现有的接口面与数据面,给出「能直接做」「需要新增 agent 端点」「不做」三类功能的详细设计。 +> +> 依据:本仓库 `internal/agent/`、`internal/frp/agent_client.go`、`internal/frp/agent_installer.go`、`internal/frp/ssh_installer.go`、`internal/api/frpmulti_handler.go`、`web/src/views/AgentView.vue`、`web/src/views/ProxyRemoteView.vue`(均为读代码确认,非推测)。 +> 当前 `AgentVersion = "0.9.2"`(`internal/agent/server.go:34`)。 +> 更新日期:2026-09-23 + +--- + +## 一、现状盘点 + +### 1.1 agent 的接口面(12 个端点) + +`internal/agent/server.go:151-162`: + +| # | 方法与路径 | 作用 | +| --- | --- | --- | +| 1 | `GET /api/v1/ping` | 存活探测(安装/升级后确认 token 与网络) | +| 2 | `GET /api/v1/status` | 一次性返回 Nginx / frps / 端口 / 路由名的快照 | +| 3 | `GET /api/v1/routes` | 每条 `havline-*.conf` 的真实状态:`file_exists` / `syntax_ok` / `loaded` | +| 4 | `PUT /api/v1/routes` | 写入(或覆盖)一条域名的 Nginx 片段 | +| 5 | `GET /api/v1/routes/{domain}/conf` | 回读某域名实际生效的配置文件内容 | +| 6 | `DELETE /api/v1/routes/{domain}` | 删除某域名的片段 | +| 7 | `POST /api/v1/nginx/install` | 一键安装 Nginx | +| 8 | `PUT /api/v1/china-cidr` | 下发国内地址库(供 `china_only` 规则引用) | +| 9 | `PUT /api/v1/frps/config` | 写入 frps.toml | +| 10 | `POST /api/v1/frps/install` | 下载并安装指定版本的 frps(幂等,重复调用=升级) | +| 11 | `POST /api/v1/frps/action` | frps 的 start / stop / restart | +| 12 | `POST /api/v1/certs` | 推送证书(fullchain + privatekey) | + +鉴权:全部走 `s.auth(...)` 包装的 token 校验;agent **只监听 VPS 本地**,由 Havline 主动发起请求,**不做任何主动上报**。 + +### 1.2 agent 的数据面(`status` 已返回、但应用侧只用了一部分) + +`internal/agent/server.go:191` 的 `status` 返回字段: + +| 字段 | 含义 | 应用侧现状 | +| --- | --- | --- | +| `agent_version` | agent 自身版本 | 已用(AgentView 版本核对) | +| `nginx_ok` / `nginx_missing` / `nginx_running` / `nginx_path` / `nginx_version` / `nginx_error` | Nginx 可用性探测(带缓存) | 已用 | +| `nginx_binary` / `nginx_conf_dir` | 配置的二进制与 vhost 目录 | 已用 | +| `ports` | 80 / 443 / 7000 / 8080 等端口是否监听 | **只用到了 8080 一项**(`AgentView.vue:359`) | +| `frps_active` / `frps_enabled` | systemd 单元是否运行 / 是否开机自启 | 已用 | +| `frps_binary` / `frps_config_path` | Havline 侧配置的路径 | 已用 | +| `frps_unit_exec` | systemd 实际执行的启动命令 | 已展示,**未做一致性核对** | +| `frps_version` | 探测到的 frps 版本 | 已展示 | +| `frps_log_tail` | frps 日志尾部 6 行 | 已展示 | +| `dashboard_addr` | 从 frps.toml 解析出的 dashboard 地址 | 已用 | +| `routes` | 已部署的域名列表 | 已用(部署台账) | + +### 1.3 应用侧的调用面 + +`internal/frp/agent_client.go` 暴露 15 个方法,与上面 12 个端点一一对应(`Ping` / `Status` / `SyncRoute` / `SyncRouteSpec` / `SyncRouteEx` / `RouteConf` / `RoutesDetail` / `DeleteRoute` / `InstallNginx` / `SyncChinaCIDR` / `FRPSAction` / `InstallFRPS` / `PushFRPSConfig` / `PushCertificate`)。 + +其中 `RouteSpec`(`agent_client.go:83`)承载了完整部署语义:`Domain / Upstream / CertDir / TLS / WebSocket / RedirectHTTPS / HTTPSDisabled / SecurityHeaders / TLS13Only / VhostPort / AllowIPs / DenyIPs / BasicAuth* / ClientMaxBodySize / ProxyReadTimeout / RateLimit*`。 + +### 1.4 安装与升级链路(已闭环) + +- agent 二进制**内嵌在 Havline 里**(`internal/frp/agentbin`,按 arch 取),通过 **SSH** 上传到 VPS 并注册/重启 systemd 单元(`agent_installer.go` + `ssh_installer.go`)。SSH 连接信息就是迁移 023 加的 `ssh_host / ssh_port / ssh_user / ssh_auth / ssh_secret_enc`。 +- 因此「升级 Agent」不需要 agent 自己有下载能力——**自升级不是必须项**。 +- 版本约定:`AgentVersion` 上方按「每项行为变更一行」的注释累积,改动后手动递增(当前 0.9.2)。 + +### 1.5 能力边界(缺口所在) + +1. **没有资源采集**:VPS 的 CPU / 内存 / 磁盘 / 负载完全不可见。 +2. **没有日志读取**:只有 frps 的 6 行 tail,Nginx 的 access / error 日志读不到(排障必须 SSH)。 +3. **frps 配置只写不读**:`PUT /api/v1/frps/config` 存在,没有对应的 GET;用户手改 VPS 上的 frps.toml 后 Havline 无从知晓,下次下发直接覆盖。 +4. **没有证书清单**:只能推送,无法列出 VPS 上现有的证书与到期时间(宝塔自己申请的证书看不到)。 +5. **部署是逐条的**:`SyncRouteSpec` 按单条规则调用,没有「全量重部署」入口——agent 重装/换机后只能一条条点。 +6. **写入结果不含 reload 细节**:片段写入成功但 Nginx reload 失败时,用户拿不到失败原因。 + +--- + +## 二、方案总览 + +| 批次 | 项 | 改动面 | 前置依赖 | 预估 | +| --- | --- | --- | --- | --- | +| **P0-1** | 端口矩阵 | 前端(AgentView) | 无,`status.ports` 已有 | 0.5h | +| **P0-2** | 部署漂移清单 + 一键全量重部署 | 后端 service + 前端 | 无,`GET /routes` 与 `RouteConf` 已有 | 2h | +| **P0-3** | frps 运行一致性核对 | 前端 | 无,`status` 已有 | 0.5h | +| **P0-4** | 证书推送核对 | 前端 | 无,`RouteHealth` 已有 | 0.5h | +| **P1-1** | VPS 资源采集 `GET /api/v1/metrics` | agent + 应用 + 前端 | 需递增 AgentVersion、重编二进制、VPS 上升级 | 2h | +| **P1-2** | 公网 Nginx 日志 `GET /api/v1/logs/nginx` | agent + 应用 + 前端 | 同上 | 2.5h | +| **P1-3** | frps.toml 回读 `GET /api/v1/frps/config` | agent + 应用 + 前端 | 同上 | 1.5h | +| **P2-1** | VPS 证书清单 `GET /api/v1/certs` | agent + 应用 + 前端 | 同上 | 1.5h | +| **P2-2** | reload 失败原因回传 | agent + 应用 | 同上 | 1h | +| **P3** | 不做:防火墙读写、任意命令执行、agent 自升级 | — | — | — | + +**贯穿约束(所有新增 agent 端点都适用)**:老版本 agent 没有新端点,应用侧调用必须**按「端点不存在」优雅降级**(收到 404/405 时显示「VPS 上的 agent 版本过低,请升级 Agent」而不是报错),否则用户升级了 Havline 但没升级 agent 时页面会直接报错。 + +--- + +## 三、P0 详细设计(不动 agent,纯应用侧) + +### 3.1 端口矩阵 + +- **数据**:`status.ports`(布尔映射)。实现前先以 agent 代码为准列出全部键(当前已知 `80` / `443` / `7000` / `8080`)。 +- **呈现**:AgentView「运行状态」段内加一张两列小表(端口 / 状态徽标),或把现有「8080 端口」那一项扩成完整列表。 +- **降级**:agent 版本过旧、`ports` 缺失时整块显示「当前 agent 版本不返回端口信息」。 +- **验收**:停掉监听 443 的服务后该行转异常色;旧 agent 上不报错。 + +### 3.2 部署漂移清单 + 一键全量重部署(价值最高) + +三个集合:`A` = 数据库里启用中的反代规则域名;`B` = `GET /api/v1/routes` 返回的域名;`C` = 其中 `loaded = true` 的域名。 + +| 类别 | 定义 | 含义 | +| --- | --- | --- | +| 未部署 | `A - B` | 库里要部署但 VPS 上没有(agent 重装、换机、手工删过) | +| 孤儿配置 | `B - A` | VPS 有但库里没有(手工加的 `havline-*.conf`、已删规则的残留) | +| 未生效 | `A ∩ (B - C)` | 文件在但未被 Nginx 加载(`syntax_ok=false`,或 include 未覆盖) | +| 内容不一致 | `RouteConf(domain)` 与「按当前部署设置生成的内容」规范化比对 | 手工改过上游 / 证书路径 / 安全项 | + +**一键全量重部署**:对 `A` 逐条 `SyncRouteSpec`(串行、逐条收集结果、失败不中断,沿用 `Service.Reload` 的隔离报错风格),结束后回读一次 `GET /routes` 复核,结果弹窗汇总「成功 N / 失败 M + 每条失败原因」。 + +**前端落点**:公网反代页顶部提示条(有差异时才出现)+「重新部署全部」按钮(二次确认,因为会覆盖手工改动)。 + +**验收**:手工删一个 `havline-*.conf` → 清单出现「未部署」,重部署后消失;手工改一条 conf 的上游 → 出现「内容不一致」;语法错误时归入「未生效」并给出原因。 + +### 3.3 frps 运行一致性核对 + +- **比对三项**:`frps_unit_exec`(systemd 实际执行)vs Havline 配置的 `frps_binary`;`frps_version` vs 期望版本;`frps_config_path` vs Havline 生成路径。 +- **呈现**:AgentView「frps 控制」段加一行「启动路径一致性」,不一致时并列显示两个路径。 +- **验收**:手工改过 systemd unit 的 VPS 上显示不一致;一致的机器显示绿色。 + +### 3.4 证书推送核对 + +- **数据**:已有 `RouteHealth` 的证书段(`cert_ok` / `cert_detail`)+ 推送结果。 +- **呈现**:公网反代页健康列已展示证书段;补一行「证书来源」(Havline 推送 / 待推送 / VPS 本地已有)。 +- **验收**:证书过期时健康列给出证书段失败与到期天数。 + +--- + +## 四、P1 详细设计(新增 agent 端点) + +> 改动流程见 §7.1;以下端点响应都带 `checked_at`,与现有 `status` 保持一致。 + +### 4.1 `GET /api/v1/metrics` —— VPS 资源采集 + +响应: + +```json +{ + "cpu_percent": 12.5, + "mem_used_bytes": 1288490188, + "mem_total_bytes": 4294967296, + "disk_used_bytes": 21474836480, + "disk_total_bytes": 53687091200, + "disk_path": "/", + "load_avg": [0.42, 0.51, 0.63], + "uptime_seconds": 864000, + "checked_at": "2026-09-23T19:00:00Z" +} +``` + +- **实现要点**:**直接复用仓库里已有的 `internal/sysinfo`**(同一 module,`cmd/havline-agent` 可直接引用)——`/proc/stat`、`/proc/meminfo`、`Statfs` 以及 Windows 的 kernel32 路径都已写好;agent 跑在 VPS 上,采到的天然是 VPS 自身数值。 +- **CPU 差值**:`sysinfo.Read(prev)` 需要上次采样;agent 进程内保存上次 `CPUSample`,并对间隔 < 1 秒的重复调用直接返回上次结果(避免连续刷新时数值抖动)。 +- **`load_avg` / `uptime_seconds`**:Linux 读 `/proc/loadavg`、`/proc/uptime`;非 Linux 省略这两个字段(前端按缺失处理)。 +- **应用侧链路**:`AgentClient.Metrics(ctx)` → `GET /api/frpmulti/servers/{id}/agent/metrics` → AgentView 新增「VPS 资源」卡(复用本地仪表盘「系统资源」的行样式:名称 / 用量明细 / 4px 进度条 / 百分比,≥75% 转橙、≥90% 转红)。 +- **降级**:404/405 → 卡片显示「VPS 上的 agent 版本过低(需 ≥ 0.10.0),请升级 Agent」。 +- **验收**:VPS 上跑 `dd` 或压测时 CPU / 磁盘数值随之上升;磁盘接近写满时进度条转红;旧 agent 上显示升级提示而不是报错。 + +### 4.2 `GET /api/v1/logs/nginx` —— 公网 Nginx 日志 + +请求:`GET /api/v1/logs/nginx?type=access|error&lines=200`(`lines` 上限 2000,默认 200) + +响应: + +```json +{ + "type": "access", + "path": "/var/log/nginx/access.log", + "lines": ["127.0.0.1 - - [23/Sep/2026:19:00:00 +0800] \"GET / HTTP/1.1\" 200 1234"], + "truncated": false, + "checked_at": "2026-09-23T19:00:00Z" +} +``` + +- **路径来源(白名单,不接受任意 path 参数)**: + 1. 优先从 `nginx -T` 的 dump 里解析 `access_log` / `error_log` 指令(agent 已有 `nginx -T` 解析与缓存经验,见 `resolveNginxConfDir`); + 2. 解析不到时回落默认路径 `/var/log/nginx/{access,error}.log`; + 3. 支持环境变量 `HAVLINE_AGENT_NGINX_LOG_DIR` 覆盖(与现有 `HAVLINE_AGENT_NGINX_CONF_DIR` 的回退风格一致)。 +- **只读尾部**:实现 `tailFile(path, n)`(与现有 `journalTail` 同思路),从文件末尾按块倒读,避免把整个日志读进内存;单行超长(如 4KB)截断。 +- **错误处理**:文件不存在 → 200 + `lines: []` + 原样返回 `path`(前端显示「未找到日志文件」);无权限 → 403 并给出明确原因。 +- **应用侧链路**:`AgentClient.NginxLogs(ctx, serverID, type, lines)` → `GET /api/frpmulti/servers/{id}/agent/logs/nginx?...`;日志中心新增「公网 Nginx」页签,复用现有 `LogToolbar` / `LogPagination` / `EmptyState` 与 `logTimeCell`,与「FRP 日志」页签同一套呈现。 +- **验收**:VPS 上产生一次访问后页签能看到该条 access 记录;agent 无读权限时显示明确错误;`lines` 超过 2000 被夹到上限。 + +### 4.3 `GET /api/v1/frps/config` —— frps.toml 回读 + +响应:`{ "path": "/etc/frp/frps.toml", "exists": true, "content": "...", "mod_time": "2026-09-23T18:00:00Z", "checked_at": "..." }` + +- **实现要点**:读 `s.cfg.FrpsConfigPath`,限制文件大小(如 256KB),超限返回 `truncated: true`;文件不存在返回 `exists: false` 而不是错误。 +- **应用侧**:在「公网服务端」的 frps 同源配置弹窗里加「与 VPS 实际文件比对」——把「Havline 即将下发的内容」与回读内容做规范化 diff(去注释与空白),不一致时提示「VPS 上的 frps.toml 已被手工修改」,并给「重新下发」按钮(复用已有 `PushFRPSConfig`)。 +- **验收**:在 VPS 上手工改一行 frps.toml → 弹窗提示不一致并高亮差异行;重新下发后恢复一致。 + +--- + +## 五、P2 详细设计 + +### 5.1 `GET /api/v1/certs` —— VPS 证书清单 + +响应:`{ "certs": [{ "domain": "7023.36800.cc", "cert_path": "...", "key_path": "...", "not_after": "2026-12-01T00:00:00Z", "days_left": 69 }], "warnings": [], "checked_at": "..." }` + +- **实现要点**:遍历 `s.cfg.CertsDir` 下的域名目录,用标准库 `crypto/x509` 解析 `fullchain.pem` 的 `NotAfter`;解析失败的单条跳过并记入 `warnings`。 +- **应用侧**:与 Havline 的证书列表交叉核对,输出「Havline 有 / VPS 有 / 两边都有但到期时间不同」三类;宝塔自行申请的证书也能被看到。 +- **验收**:过期证书 `days_left < 0` 并标红;Havline 未推送过的域名出现在「VPS 本地已有」分组。 + +### 5.2 reload 失败原因回传 + +- **做法(择一,倾向 A)**: + - **A**:`PUT /api/v1/routes` 与 `DELETE /api/v1/routes/{domain}` 的响应体增加 `reload_ok` / `reload_error` 字段——写入成功但 reload 失败时不再只回 200。 + - **B**:新增 `POST /api/v1/nginx/reload`,返回 `nginx -t` 与 reload 的 stderr。 +- **应用侧**:部署结果提示里带上 `reload_error`;漂移清单的「未生效」类别直接引用它。 +- **验收**:故意写一条语法错误片段 → 部署结果明确显示 reload 失败原因,而不是「部署成功」。 + +--- + +## 六、明确不做的 + +1. **VPS 防火墙规则读写**(ufw / firewalld / 宝塔面板差异大、需要 root,误操作会直接断网)——最多做只读展示。 +2. **任意命令执行端点**——agent 目前只有 token 鉴权,开放 shell 等于把 VPS 交出去。 +3. **agent 自升级**——现有链路(Havline 内嵌二进制 + SSH 上传 + 重启 systemd)已经闭环,自升级只省掉存 SSH 凭据,收益有限,还要额外处理下载源与签名校验。 + +--- + +## 七、改动流程与验收 + +### 7.1 agent 侧(每新增一个端点都要走完) + +1. `internal/agent/server.go`:加 handler + `mux.HandleFunc(...)`,并在 `AgentVersion` 上方按现有约定补一行版本说明(例如 `// 0.10.0:新增 /api/v1/metrics 资源采集与 /api/v1/logs/nginx 日志读取。`),把常量递增到 **`0.10.0`**。 +2. 重编两个架构的内嵌二进制(Havline 编译期嵌入,必须是真实二进制而不是占位文件): + +```powershell +$env:GOOS='linux'; $env:CGO_ENABLED='0'; $env:GOARCH='amd64' +go build -trimpath -o internal/frp/agentbin/havline-agent-linux-amd64.new ./cmd/havline-agent +$env:GOARCH='arm64' +go build -trimpath -o internal/frp/agentbin/havline-agent-linux-arm64.new ./cmd/havline-agent +Move-Item -Force internal/frp/agentbin/havline-agent-linux-amd64.new internal/frp/agentbin/havline-agent-linux-amd64 +Move-Item -Force internal/frp/agentbin/havline-agent-linux-arm64.new internal/frp/agentbin/havline-agent-linux-arm64 +``` + +3. 重启本机 Havline(加载新内嵌二进制)→ VPS 上「升级 Agent」→ 用 `GET /api/v1/status` 的 `agent_version` 确认已是 `0.10.0`。 + +### 7.2 应用侧(每个端点对应一条链路) + +`internal/frp/agent_client.go` 加方法 → `internal/frp/service.go` 加业务方法(含**端点不存在时的降级**:区分 404/405 与真实错误)→ `internal/api/frpmulti_handler.go` 加 handler → `internal/api/router.go` 注册(沿用现有 `agent/*` 路径风格)→ `web/src/api/{client,types}.ts` → 前端页面。 + +### 7.3 验收(每批的检查项) + +- **P0**:手工制造四类漂移各一次,清单分类正确;全量重部署在单条失败时其余仍继续并汇总报错;旧 agent 上端口矩阵降级不报错。 +- **P1**:`metrics` 数值随 VPS 负载变化且 CPU 不抖动;`logs/nginx` 尾部读取正确且超限被夹到 2000;`frps/config` 回读能检出手工改动。 +- **P2**:证书清单含宝塔自签证书与到期天数;语法错误片段部署时回传 reload 失败原因。 +- **通用**:新端点全部走 `s.auth`;`go vet` / `go test ./internal/...` / `vue-tsc` 全绿;agent 版本不匹配时前端一律显示升级提示而不是报错。 + +--- + +## 八、风险与回滚 + +| 风险 | 说明 | 应对 | +| --- | --- | --- | +| **新老版本错配** | Havline 已升级、VPS 上的 agent 仍是旧版 | 应用侧对新端点统一做 404/405 降级,前端显示「请升级 Agent」;不改动已有端点的行为 | +| **只读端点的权限** | `/var/log/nginx` 与证书目录可能不允许 agent 运行用户读取 | 端点返回明确的 403 + 路径,前端提示「agent 无读取权限」;安装脚本里补充读权限说明 | +| **`metrics` 采样抖动** | 高频调用时 CPU 差值失真 | 间隔 < 1 秒直接返回上次结果;前端按 15 秒轮询(与本地仪表盘一致) | +| **全量重部署覆盖手工改动** | 会覆盖 VPS 上手工编辑过的 `havline-*.conf` | 按钮带二次确认 + 差异预览;`RouteConf` 回读内容先展示再覆盖 | +| **回滚** | agent 行为变更后需要退回旧版本 | agent 二进制随 Havline 版本走:用旧版 Havline 的「升级 Agent」即可把旧二进制重新上传;新增端点均为**纯增量**,不改变已有端点语义,应用侧回滚也无需数据迁移 | + +**总原则**:新增端点一律只读或纯增量、不改变已有端点语义;所有 agent 交互都保持「Havline 主动拉取、agent 被动应答」的现有模型,不引入 agent 主动上报。 diff --git a/app-src/docs/agent-listen-strategy-plan.md b/app-src/docs/agent-listen-strategy-plan.md new file mode 100644 index 0000000..470cc05 --- /dev/null +++ b/app-src/docs/agent-listen-strategy-plan.md @@ -0,0 +1,757 @@ +# Agent 默认监听策略与自动安全接入方案 + +> 方案编号:P0-5 +> 制定日期:2026-09-28 +> 当前 AgentVersion:0.14.0 +> 目标:默认不暴露 Agent 管理接口,安装和升级过程自动建立安全连接,不要求用户手工配置端口、隧道或证书 + +> 实施状态(2026-09-28):阶段 1-5 主体已在 v1.5.0 完成,包括回环监听、SSH 隧道与后台重连、旧 HTTP 迁移、HTTPS 端口自动选择、证书自动轮换与双 pin 过渡、主机防火墙辅助、传输状态接口与审计记录。剩余:2.0.0 移除旧 HTTP 兼容分支,以及 Debian/Ubuntu/CentOS、宝塔、端口占用、云安全组受限等真机矩阵验证。 + +--- + +## 一、结论 + +推荐采用**自动为主、安全优先、旧模式显式兼容**的三层策略: + +1. **默认模式:SSH 隧道 + 回环监听** + - `havline-agent` 默认只监听 `127.0.0.1:7700`。 + - 中央 Havline 使用现有 SSH 凭据建立本地转发: + + ```text + 中央 Havline 127.0.0.1:<随机本地端口> + → SSH 加密连接 + → VPS 127.0.0.1:7700 + → havline-agent + ``` + + - 不开放公网 Agent 端口,不需要域名、证书、云安全组或防火墙修改。 + +2. **可选模式:自动 HTTPS 反代 + 证书固定** + - Agent 仍只监听回环地址。 + - HTTPS 不作为首次安装选项;Agent 安全安装完成后,用户可在「公网 Agent」页切换到该高级模式。 + - 切换时自动配置 Nginx HTTPS 反代,并使用自签名证书。 + - 中央 Havline 首次安装时记录证书指纹,后续按指纹校验。 + - 适用于希望免维护常驻 SSH 隧道、且愿意开放一个 HTTPS 端口的用户。 + +3. **兼容模式:显式公网明文 HTTP** + - 保留旧部署能力,但必须显式开启。 + - UI 持续显示高危警告。 + - 不允许自动降级到该模式。 + +默认安装与升级过程不需要用户设置 `HAVLINE_AGENT_LISTEN`、TLS 证书或防火墙规则。 + +--- + +## 二、现状 + +### 2.1 Agent 监听 + +当前入口: + +```go +listenv := flag.String("listen", envOr("HAVLINE_AGENT_LISTEN", ":7700"), ...) +``` + +`internal/agent.NewServer` 空配置时同样回落到: + +```go +cfg.ListenAddr = ":7700" +``` + +`:7700` 等价于监听所有 IPv4 / IPv6 网卡。 + +### 2.2 传输安全 + +当前 Agent 使用明文 HTTP: + +- Bearer Token 在网络上直接传输; +- Token 已采用常量时间比较; +- Agent 默认没有 TLS; +- 安装脚本打印: + + ```text + API: http://:7700 + ``` + +### 2.3 中央 Havline 连接 + +安装完成后,`AgentInstall` 写入: + +```go +AgentURL = fmt.Sprintf("http://%s:%d", server.SSHHost, 7700) +``` + +中央 Havline 后续直接访问 VPS 公网 IP 的 7700 端口。 + +### 2.4 当前问题 + +- 用户需要额外放行或确认 7700; +- 如果防火墙忘记放行,Agent 显示不可达; +- 如果放行,Token 暴露在明文网络中; +- 部署工具和容器环境可能改变监听行为; +- “默认安全”没有形成产品行为; +- 安全策略主要依赖用户理解和手工操作。 + +--- + +## 三、设计目标 + +### 3.1 必须实现 + +1. 新安装默认不监听公网网卡。 +2. 安装过程自动建立中央 Havline 到 Agent 的安全连接。 +3. 不需要用户手工配置网络或证书。 +4. Agent 升级时可自动迁移旧监听配置。 +5. 旧部署仍可显式使用公网 HTTP。 +6. 传输失败时给出可执行的诊断信息。 +7. 中央 Havline 重启后可自动恢复连接。 +8. Agent 与中央 Havline 的版本兼容策略保持清晰。 + +### 3.2 非目标 + +- 不尝试自动修改云厂商安全组; +- 不要求用户申请域名或公网 CA 证书; +- 不引入多租户权限模型; +- 不把 Agent 管理 API 暴露给家庭用户的普通浏览器; +- 不提供一个“自动回退到明文 HTTP”的危险路径。 + +--- + +## 四、方案对比 + +### 4.1 方案 A:公网 HTTP + Bearer Token(现状) + +| 项目 | 评价 | +| --- | --- | +| 自动化 | 高 | +| 安全性 | 低 | +| 防火墙 | 需要放行 7700 | +| 证书 | 不需要 | +| 复杂度 | 低 | +| 主要风险 | Token 明文、端口暴露、被动扫描 | + +结论:只保留为显式兼容模式,不再作为默认。 + +### 4.2 方案 B:SSH 隧道 + Agent 回环监听(推荐默认) + +| 项目 | 评价 | +| --- | --- | +| 自动化 | 很高 | +| 安全性 | 高 | +| 防火墙 | 不需要新增端口 | +| 证书 | 不需要 | +| 复杂度 | 中高,需要隧道生命周期管理 | +| 依赖 | SSH 可达;本功能安装 Agent 时已经要求 SSH | +| 主要优势 | 不暴露端口、不依赖域名和证书 | + +结论:作为默认模式。 + +### 4.3 方案 C:Nginx HTTPS + 自签名证书固定 + +| 项目 | 评价 | +| --- | --- | +| 自动化 | 高 | +| 安全性 | 高 | +| 防火墙 | 需要放行一个 HTTPS 端口 | +| 证书 | 自动生成和固定,不需要公网 CA | +| 复杂度 | 中 | +| 主要优势 | 中央 Havline 可直接连接,不依赖常驻 SSH | +| 主要限制 | 云安全组仍需用户放行端口 | + +结论:作为可选直连模式。 + +### 4.4 方案 D:公网域名 + ACME 证书 + +| 项目 | 评价 | +| --- | --- | +| 自动化 | 中 | +| 安全性 | 高 | +| 防火墙 | 需要 443 或 DNS-01 | +| 证书 | 需要域名、DNS 与 CA | +| 复杂度 | 高 | +| 用户成本 | 高 | + +结论:不作为默认。可作为未来面向高级用户的能力。 + +--- + +## 五、推荐架构 + +### 5.1 默认数据流 + +```mermaid +flowchart LR + Web[Havline Web] --> API[Havline 后端] + API --> Tunnel[SSH 隧道管理器] + Tunnel -->|127.0.0.1:自动分配端口| SSH[SSH 加密连接] + SSH --> VPSLoopback[VPS 127.0.0.1:7700] + VPSLoopback --> Agent[havline-agent] +``` + +### 5.2 角色职责 + +| 组件 | 职责 | +| --- | --- | +| Havline 后端 | 决定 transport、启动隧道、调用 AgentClient | +| 隧道管理器 | 建立 SSH 本地转发、保持连接、重连、健康检查 | +| havline-agent | 默认只监听回环,不再负责公网暴露 | +| 安装脚本 | 写入 `HAVLINE_AGENT_LISTEN=127.0.0.1:7700`,禁止默认可公开 | +| UI | 显示安全模式、连接状态、可选迁移和显式旧模式警告 | + +### 5.3 连接抽象 + +中央 Havline 不再把“公网 IP + 7700”当作 Agent 的唯一定义。新增 transport 概念: + +```text +tunnel SSH 本地转发,默认 +https-pin Nginx HTTPS + 证书指纹固定,可选 +http 公网明文,仅显式兼容 +``` + +`AgentClient` 仍使用普通 HTTP 客户端接口,但 baseURL 由 transport 管理: + +- tunnel:`http://127.0.0.1:` +- https-pin:`https://:` +- http:`http://:7700` + +--- + +## 六、默认模式:SSH 隧道 + +### 6.1 为什么默认走 SSH + +安装 Agent 已经必须使用 SSH,因此: + +- 不新增用户操作; +- 不需要公网开放 7700; +- 不需要证书和域名; +- 不依赖云安全组修改; +- SSH 主机密钥已经使用 TOFU 校验; +- Token 继续保留,形成双层认证。 + +### 6.2 本地端口分配 + +每个 VPS 服务端分配一个稳定的本地回环端口: + +```text +127.0.0.1: +``` + +分配方式建议: + +1. 首次安装时在 `frp_servers.agent_local_port` 中保存; +2. 可在 `17700-18700` 范围内分配; +3. 分配时检查: + - 未被其他服务端占用; + - 未被本机其他 SSH 隧道占用; + - 可以正常绑定。 +4. 保存后不再变化,便于重启恢复。 + +### 6.3 隧道管理器 + +新增: + +```text +internal/frp/agent_tunnel.go +``` + +核心能力: + +- `Ensure(serverID)`:确保隧道已启动; +- `Stop(serverID)`:停止隧道; +- `Restart(serverID)`:重建; +- `State(serverID)`:返回 connected / reconnecting / failed; +- 应用退出时统一关闭。 + +实现建议: + +- 使用现有 `golang.org/x/crypto/ssh`; +- 复用 `SSHInstaller` 的 known_hosts 校验; +- SSH 连接使用 keepalive; +- 本地监听使用 `127.0.0.1`,不绑定 `0.0.0.0`; +- 连接断开后指数退避重连; +- 每个服务端最多一个隧道实例; +- 同一 `AgentClient` 复用 HTTP 连接。 + +### 6.4 健康检查 + +隧道建立后执行: + +1. 本地端口可以连接; +2. `GET /api/v1/ping` 返回 200; +3. Bearer Token 验证通过; +4. `GET /api/v1/status` 返回 Agent 版本。 + +只有四项都通过,才把 transport 标记为 connected。 + +### 6.5 连接状态 + +前端需要显示: + +```text +传输方式:SSH 隧道 +本地端口:127.0.0.1:177xx +Agent 地址:127.0.0.1:7700(VPS 回环) +隧道状态:已连接 / 重连中 / 失败 +最后错误 +``` + +--- + +## 七、Agent 默认监听与防误开 + +### 7.1 默认值 + +修改: + +```go +const defaultListenAddr = "127.0.0.1:7700" +``` + +以下位置同步: + +- `cmd/havline-agent/main.go` +- `internal/agent.NewServer` +- `scripts/agent-install.sh` +- `internal/frp/agent_installer.go` 内嵌安装脚本 + +### 7.2 远程监听保护 + +如果监听地址不是回环: + +```text +0.0.0.0:7700 +:7700 +[::]:7700 +VPS公网IP:7700 +``` + +则必须显式设置: + +```dotenv +HAVLINE_AGENT_ALLOW_REMOTE=1 +``` + +否则 Agent 拒绝启动并输出: + +```text +检测到 Agent 将监听非回环地址,但未显式允许远程访问。 +默认安全模式只监听 127.0.0.1:7700。 +如确认需要公网明文访问,请设置 HAVLINE_AGENT_ALLOW_REMOTE=1。 +``` + +### 7.3 旧配置迁移 + +旧版本 `agent.env` 可能没有 `HAVLINE_AGENT_LISTEN`,此时: + +1. 新 Agent 默认监听回环; +2. 安装脚本显式写入: + + ```dotenv + HAVLINE_AGENT_LISTEN=127.0.0.1:7700 + ``` + +3. 中央 Havline 在执行「升级 Agent」时同步建立隧道; +4. 升级完成后切换 transport; +5. 旧公网地址从 `agent_url` 中移除; +6. 不停留手工维护窗口。 + +### 7.4 状态接口 + +Agent `/api/v1/status` 新增: + +```json +{ + "listen_addr": "127.0.0.1:7700", + "listen_scope": "loopback", + "remote_access_enabled": false, + "transport_security": "loopback" +} +``` + +--- + +## 八、自动安装流程 + +### 8.1 新安装 + +1. 用户填写 VPS SSH 信息。 +2. 中央 Havline 通过 SSH 探测系统与 Nginx。 +3. 分配 `agent_local_port`。 +4. 上传 Agent 二进制。 +5. 安装脚本写入: + - Token; + - `HAVLINE_AGENT_LISTEN=127.0.0.1:7700`; + - systemd 服务。 +6. 启动 Agent。 +7. 通过 SSH 本地转发建立隧道。 +8. 调用: + - `GET /api/v1/ping` + - `GET /api/v1/status` +9. 保存: + - token 密文; + - transport=`tunnel`; + - local port; + - AgentVersion。 +10. UI 显示“安全模式已启用”。 + +### 8.2 自动迁移旧安装 + +1. 检测现有 `agent_url` 是否为 `http://公网IP:7700`。 +2. 显示“可升级到安全模式”,但不强制立即迁移。 +3. 用户点击「升级 Agent」后: + - 安装新二进制; + - 重写 `agent.env` 为回环监听; + - 重启服务; + - 建立隧道; + - 验证连通; + - 切换 central transport。 +4. 只有隧道验证成功后,才关闭旧公网连接。 +5. 失败时保留旧 Agent 配置和旧连接,自动回滚。 + +### 8.3 应用重启恢复 + +1. 读取启用管理且 transport=tunnel 的服务端。 +2. 逐个调用 `Tunnel.Ensure`。 +3. 启动失败不阻塞整个 Havline 启动。 +4. UI 显示重连状态。 +5. 后台按指数退避继续重试。 + +--- + +## 九、可选模式:HTTPS + 证书固定 + +### 9.1 适用场景 + +- 不希望长期维护 SSH 隧道; +- 可以接受开放一个 HTTPS 端口; +- 没有域名或不想申请公网证书; +- 希望 Agent API 可被中央 Havline直接访问。 + +### 9.2 安装后切换流程 + +首次安装默认仍使用 SSH 隧道。只有用户在「公网 Agent」页明确切换到 HTTPS 直连时,才执行以下步骤: + +1. Agent 仍监听 `127.0.0.1:7700`。 +2. 切换流程生成自签名证书: + + ```text + /etc/havline-agent/tls/agent.crt + /etc/havline-agent/tls/agent.key + ``` + +3. 自动寻找可用端口: + - 优先 7443; + - 其次 8443、9443; + - 若 443 空闲且未占用可提供选项,但不默认抢占。 +4. 生成独立 Nginx server block: + + ```nginx + server { + listen 7443 ssl; + ssl_certificate /etc/havline-agent/tls/agent.crt; + ssl_certificate_key /etc/havline-agent/tls/agent.key; + + location /api/v1/ { + proxy_pass http://127.0.0.1:7700; + } + } + ``` + +5. 返回证书 SHA-256 指纹。 +6. 中央 Havline 保存 pin,并使用自定义校验。 + +### 9.3 TLS 固定 + +不能使用: + +```go +InsecureSkipVerify: true +``` + +应实现: + +```go +VerifyPeerCertificate(rawCerts [][]byte, _ [][]*x509.Certificate) error { + // 计算证书 DER 的 SHA-256 + // 与数据库中保存的 pin 做常量时间比较 + // 同时校验有效期与 SAN(如可校验) +} +``` + +首次安装必须通过 SSH 获取指纹,避免首次连接被中间人截获。 + +### 9.4 防火墙 + +> 实施更新(v1.5.0 / Agent 0.14.0):已支持检测 ufw / firewalld / nftables / iptables;对 ufw 与 firewalld 可在用户明确确认后自动放行,nftables / iptables 仅检测和提示,不会自动改规则。 + +自动放行仅在用户明确确认后执行,支持的工具: + +- ufw; +- firewalld; +- nftables / iptables(只提示,不自动改规则)。 + +云厂商安全组始终不能自动修改。UI 必须提示: + +```text +请在云控制台放行 TCP 7443。主机防火墙需要你确认后由 Havline 执行,Havline 不会修改云安全组。 +``` + +--- + +## 十、兼容模式:显式公网 HTTP + +仅用于旧部署迁移或用户明确要求: + +```dotenv +HAVLINE_AGENT_LISTEN=:7700 +HAVLINE_AGENT_ALLOW_REMOTE=1 +``` + +中央 Havline UI 必须: + +- 显示“公网明文传输”红色状态; +- 不在新安装中默认选择; +- 不支持由自动模式静默回退; +- 显示迁移到 SSH 隧道或 HTTPS 的按钮; +- 在变更时记录审计事件。 + +旧 transport 允许读取和继续连接,但所有新增安装默认使用 tunnel。 + +--- + +## 十一、数据模型变更 + +建议新增迁移: + +```sql +ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http'; +ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0; +ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT ''; +ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT ''; +ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown'; +ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT ''; +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `agent_transport` | `tunnel` / `https-pin` / `http` | +| `agent_local_port` | 隧道本地回环端口 | +| `agent_tls_pin` | HTTPS 证书 DER SHA-256 | +| `agent_listen_addr` | Agent 实际监听地址缓存 | +| `agent_transport_state` | `connected` / `reconnecting` / `failed` / `unknown` | +| `agent_transport_error` | 最近一次连接错误 | + +### 11.1 兼容迁移 + +旧数据: + +```text +agent_transport = http +``` + +新安装: + +```text +agent_transport = tunnel +``` + +--- + +## 十二、API 与前端变更 + +### 12.1 API + +`FrpServer` JSON 增加: + +```json +{ + "agent_transport": "tunnel", + "agent_transport_state": "connected", + "agent_local_port": 17701, + "agent_listen_addr": "127.0.0.1:7700", + "agent_transport_error": "" +} +``` + +新增管理端点: + +| 方法 | 路径 | 作用 | +| --- | --- | --- | +| POST | `/api/frpmulti/servers/{id}/agent/transport` | 切换 tunnel / https-pin / http | +| POST | `/api/frpmulti/servers/{id}/agent/tunnel/restart` | 重启隧道 | +| GET | `/api/frpmulti/servers/{id}/agent/transport` | 读取详细状态 | + +### 12.2 前端 + +「公网 Agent」页新增传输模式卡: + +- SSH 隧道,默认; +- HTTPS + 证书固定,可选; +- 公网 HTTP 明文,兼容。 + +显示: + +```text +传输方式:SSH 隧道 +隧道状态:已连接 +本地入口:127.0.0.1:17701 +Agent 监听:127.0.0.1:7700 +安全等级:安全 +``` + +旧模式显示: + +```text +公网明文 HTTP +风险:Token 会通过网络明文传输 +建议迁移到 SSH 隧道 +``` + +--- + +## 十三、自动回滚 + +迁移或切换 transport 失败时: + +1. 保留旧 transport 与旧 Agent 配置; +2. 不覆盖旧 Token; +3. 不删除旧公网端口配置; +4. 如果已经重写 `agent.env`,恢复旧文件; +5. 如果 Agent 已重启但新连接失败,重新启动旧服务; +6. 向 UI 返回完整错误和回滚结果。 + +任何自动迁移都必须满足: + +```text +新连接验证成功 +→ 才提交数据库 transport +→ 才停止旧监听 +``` + +--- + +## 十四、测试计划 + +### 14.1 单元测试 + +- 默认监听地址是回环; +- 非回环监听缺少 `HAVLINE_AGENT_ALLOW_REMOTE` 时拒绝启动; +- 回环监听无需额外变量; +- `agent_transport` 序列化与旧数据兼容; +- 隧道本地端口分配、冲突与释放; +- 隧道重连、退避和关闭; +- 证书 pin 正确、证书变化、pin 不匹配时拒绝; +- 旧 HTTP transport 继续可用并产生警告。 + +### 14.2 集成测试 + +- 在测试 SSH 服务上建立本地转发; +- Agent 只监听 127.0.0.1; +- 应用重启后隧道自动恢复; +- SSH 断开后重连; +- 多服务端并发隧道; +- 迁移前失败回滚; +- 迁移成功后旧公网端口关闭。 + +### 14.3 真机验证 + +至少覆盖: + +- Debian / Ubuntu / CentOS; +- fnOS 中的 Havline; +- 宝塔 Nginx; +- 已存在 80 / 443 / 7443 占用的 VPS; +- 云安全组只开放 SSH 的 VPS; +- 云安全组开放 7443 的可选 HTTPS 模式。 + +### 14.4 验收条件 + +- 新安装后 `ss -tlnp` 只能看到 Agent 监听 `127.0.0.1:7700`; +- 公网扫描不到 7700; +- 无需新增云安全组规则; +- UI 显示隧道已连接; +- Havline 重启后自动恢复; +- 「升级 Agent」可以自动迁移旧配置; +- 失败时不会丢失旧连接。 + +--- + +## 十五、实施阶段 + +### 阶段 1:默认回环与防误开 + +- Agent 默认监听改为 `127.0.0.1:7700`; +- 非回环监听要求 `HAVLINE_AGENT_ALLOW_REMOTE=1`; +- 安装脚本显式写入监听地址; +- UI 显示监听范围。 + +该阶段暂不自动切换中央连接,避免破坏旧部署。 + +### 阶段 2:SSH 隧道管理器 + +- 新增数据库字段; +- 新增隧道管理器和 AgentClient transport; +- 安装后自动建立隧道; +- 应用重启恢复; +- UI 显示 transport 状态。 + +### 阶段 3:自动迁移旧 Agent + +- 检测旧公网 HTTP; +- 「升级 Agent」时执行安全迁移; +- 成功切换后关闭旧入口; +- 失败自动回滚。 + +### 阶段 4:可选 HTTPS + pin + +- 自动生成证书; +- Nginx server block; +- TLS pin; +- 防火墙辅助; +- UI 可选切换。 + +### 阶段 5:收紧默认 + +- 新安装不再接受公网 HTTP; +- 兼容模式需要明确的高风险确认; +- 文档和通知引导迁移。 + +--- + +## 十六、决策结论 + +以下决策已于 2026-09-28 确定,后续实施按此执行。 + +| 编号 | 决策 | 执行设定 | 理由 | +| --- | --- | --- | --- | +| 1 | SSH 隧道作为唯一默认模式 | 首次安装不提供模式选择,统一安装为回环监听 + SSH 隧道;HTTPS 直连只作为安装后的高级切换项 | 现有安装流程已经依赖 SSH;默认路径必须最少输入、最少网络要求 | +| 2 | 隧道本地端口使用固定范围 | 固定为 `17700-18700`,绑定 `127.0.0.1`;分配结果持久化;启动时先复用持久化端口,再在范围内顺序查找空闲端口;范围耗尽时明确报错,不使用临时端口 | 固定范围便于排查和审计;持久化避免每次重启变化;不回退临时端口避免地址漂移 | +| 3 | 隧道跟随主应用生命周期 | 使用应用内 `agent_tunnel` 管理器,在 Havline 启动后按服务端恢复;应用退出时统一关闭;断线后指数退避重连;首版不新增独立 systemd 服务 | 当前项目是单进程控制面,调度器、FRP、监控均在同一生命周期内,新增守护进程会扩大部署复杂度 | +| 4 | 公网 HTTP 保留整个 1.x 兼容期 | 自 `1.4.0` 起不再允许新安装选择 HTTP;存量连接继续可用但显示高危警告并提供迁移;计划在 `2.0.0` 移除 | 需要给旧 Agent 和环境留出迁移窗口,同时从版本边界上明确技术债的截止点 | +| 5 | HTTPS 自签名证书使用短期自动轮换 | ECDSA P-256,有效期 365 天;剩余 30 天时自动轮换;轮换期间同时接受旧 pin 和新 pin,双 pin 窗口 7 天;优先通过现有 SSH 通道获取新指纹后再切换 | 避免长期证书失管;双 pin 防止轮换瞬间把所有中央连接锁死 | +| 6 | 首版不自动改主机防火墙 | 首先只检测、展示和提示;SSH 隧道模式本身不需要放行新端口;HTTPS 高级模式后续再提供显式的“一键放行主机防火墙”操作,并始终不修改云厂商安全组 | 防火墙和云安全组属于高风险外部状态;首版先把安全默认值落地,避免自动变更导致 SSH 或现有服务失联 | + +### 16.1 决策对实施阶段的影响 + +- 阶段 1 只做 Agent 默认回环和防误开。 +- 阶段 2 实现 `17700-18700` 的持久化端口分配与应用内隧道管理器。 +- 阶段 3 完成旧 Agent 自动迁移;迁移失败必须回到旧 transport。 +- 阶段 4 增加 HTTPS + pin 的高级模式、证书轮换和双 pin 窗口。 +- `1.4.0` 起前端不再允许新建 HTTP transport。 +- `2.0.0` 删除 `http` transport 和旧 Agent 兼容分支。 +- 防火墙变更仅在阶段 4 之后按用户明确操作执行,首版不实现自动写规则。 + +--- + +## 十七、最终建议 + +优先实现前三个阶段: + +1. Agent 默认回环; +2. SSH 隧道自动建立与恢复; +3. 旧 Agent 自动迁移并支持回滚。 + +这三步完成后,默认部署不再暴露公网 7700,也不需要用户修改 Composer 或防火墙。HTTPS + pin 与防火墙辅助放在后续阶段,作为需要直连用户的可选增强。这样可以在不扩大用户操作成本的前提下,完成 P0-5 的安全目标。 diff --git a/app-src/docs/agent-proxy-roadmap.md b/app-src/docs/agent-proxy-roadmap.md new file mode 100644 index 0000000..c88838b --- /dev/null +++ b/app-src/docs/agent-proxy-roadmap.md @@ -0,0 +1,163 @@ +# 公网反代(Agent 页「公网 Nginx 反代」区)功能增强规划 + +> 基于实际部署踩坑史(目录探测、证书缺失、端口漂移、类型错配均发生过)梳理, +> 每项标注:价值 / 实现成本 / 依赖。供后续逐项开发选用。 +> 更新日期:2026-09-21 + +--- + + +第一梯队(最推荐先做,1-2 天) +功能 解决什么 +① 部署后在线验证 部署成功后 agent 在 VPS 上 curl -H "Host: 域名" 127.0.0.1:9080,页面直接显示「回源自检 200 ✓」或「回源 502 ✗(隧道未通)」——你昨天遇到的“部署成功但打开 502”当时就能看见断在哪层 +③ 状态徽章实时化 「已部署」升级为「已生效 / 已写入未加载 / 未部署」三态(nginx -T 真实验证),tcp/ 目录那种“写了但不加载”的残留一眼可见 +⑥ WebSocket 开关 家庭影院等实时服务的标配,一个开关 + 模板片段,成本极低 +第二梯队(反代能力追平本地反代) +② 配置查看/自定义:每条规则可看生成的 nginx 配置、可改(WebSocket 头、特殊 location 不再需要 SSH)——本地反代已有 auto/custom 模式可直接复用; + +④ HTTP 强制跳转 HTTPS、⑤ IP 白名单 / Basic Auth——公网暴露面的安全标配。 + +第三/四梯队(多规则后有价值) +批量同步、部署历史回滚、域名健康检查面板(含证书剩余天数)、自定义 upstream、流量统计、错误日志快捷查看——文档里都有具体设计。 + +文档末尾附了配套 API 草案(4 个端点 + agent 侧新增项),实现时按这个走即可。说一声做哪几个,我按第一批开始。 + + + + + + + + +## 现状回顾(已具备) + +- 手动部署 / 移除单条规则的反代(部署时自动推证书、自动读最新 vhost 端口); +- VPS 当前已加载路由展示(`agentStatus.routes`); +- 反代文件按 `havline-<域名>.conf` 写入 Nginx 实际加载目录(`-T` 试探-验证)。 + +--- + +## 第一梯队:立刻有用(解决「部署了≠生效」的可见性) + +### 1. 部署后在线验证(强烈推荐,第一个做) + +部署成功后 agent 自动做一次真实探测并把结果返回给页面: + +```text +在 VPS 上执行: +curl -s -o /dev/null -w "%{http_code}" -H "Host: <域名>" http://127.0.0.1: +``` + +- 页面显示「部署成功 · 回源自检 200 ✓」或「部署成功 · 但回源 502 ✗(隧道未通)」; +- 直接区分「反代层 OK / 隧道层断」,消灭"部署成功但打开 502"这类排障; +- 成本:低(agent 加一个验证步骤 + 前端展示字段)。 + +### 2. Nginx 配置内容查看 / 自定义 + +每条规则提供「查看生成的 nginx 配置」弹窗(对齐本地反代已有的 auto/custom 模式): + +- auto:显示 agent 生成的配置(只读); +- custom:用户编辑后下发,agent 写入文件并 reload; +- 排障时可确认「文件里到底写了什么」;特殊需求(WebSocket 头、自定义 location、缓存策略)不再需要 SSH; +- 成本:中(agent 加 GET/PUT conf 接口;本地反代的 nginx_config.go 模式可直接复用)。 + +### 3. 反代状态徽章的实时化 + +「已部署 / 未部署」目前基于 `routes` 文件名推断,升级为真实状态: + +- 检查文件存在 ✓ + `nginx -t` 通过 ✓ + 域名出现在 `nginx -T` ✓ → 「已生效」; +- 文件在但 `nginx -T` 没有它 → 「已写入未加载」(目录不对的残留场景); +- 成本:低。 + +## 第二梯队:安全与可控性(公网暴露面的标配) + +### 4. 一键 HTTPS 强制跳转 + +生成的配置加一段 80 → 443 的 301 redirect(现在 http 和 443 是两个平行的 server)。 + +- 表单按规则勾选「HTTP 跳转 HTTPS」; +- 成本:低。 + +### 5. 访问控制(IP 白名单 / Basic Auth) + +公网服务(尤其路由器/摄像头等敏感入口)的标配: + +- IP 白名单:`allow x.x.x.x; deny all;` 列表来自表单; +- Basic Auth:agent 生成 htpasswd 并在 location 中引用; +- 成本:中。 + +### 6. WebSocket 支持开关 + +生成配置追加 `proxy_set_header Upgrade $http_upgrade; Connection "upgrade";`(群晖/家庭影院等实时服务需要)。 + +- 成本:低(一个开关 + 模板片段)。 + +## 第三梯队:运维效率(多规则后才有价值) + +### 7. 批量部署 / 全部同步 + +「一键同步全部规则」:对当前服务端所有候选规则循环部署,展示每条的成败。 +配合规则增删后的批量重建场景。 + +- 成本:低-中。 + +### 8. 部署历史与回滚 + +每次部署在 agent 侧留 `havline-<域名>.conf.bak-<时间戳>`(保留最近 N 份), +页面提供「查看历史 / 回滚到此版本」。 + +- 误操作(比如改坏了 upstream)30 秒恢复; +- 成本:中。 + +### 9. 域名健康检查面板 + +页面顶部一行:每个已部署域名的公网可达性(HTTPS 状态码 + 证书剩余天数), +由 Havline 侧定时探测(复用服务端诊断的 TTL 缓存模式)。 + +- 证书临期/隧道断链主动可见; +- 成本:中。 + +## 第四梯队:锦上添花 + +### 10. 自定义 upstream 覆盖 + +允许某条规则的反代目标不跟随穿透规则(例如指向本机 Nginx:80 做二次分流), +默认仍自动取穿透规则的 vhost 回源。 + +- 成本:低。 + +### 11. 流量统计接入 + +frps dashboard 已有连接数/流量数据(Havline 正在拉取),按域名聚合展示到反代列表, +可见每条反代的实际使用量。 + +- 成本:中。 + +### 12. Nginx 错误日志快捷查看 + +agent 提供 `tail /www/wwwlogs/<域名>.error.log`,规则行内展开最近 20 行。 + +- 成本:低。 + +--- + +## 建议路线 + +```text +第一批(1-2 天):① 部署后在线验证 + ③ 状态徽章实时化 + ⑥ WebSocket 开关 + → 直接消灭「部署成功但不通」的灰区 +第二批(2-3 天):② 配置查看/自定义 + ④ 强制跳转 + ⑤ 访问控制 + → 反代层能力追平本地反代 +第三批(按需):⑦⑧⑨⑩⑪⑫ +``` + +## 附:接口草案(第一/二批共用) + +```text +GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf # 查看生成配置 +PUT /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf # 自定义配置下发 +POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/verify # 部署后在线验证 +POST /api/frpmulti/servers/{id}/agent/routes/batch # 批量同步 +``` + +agent 侧新增:`GET/PUT /api/v1/routes/{domain}/conf`、验证逻辑复用部署链路的探测段。 diff --git a/app-src/docs/agent-proxy-settings-plan.md b/app-src/docs/agent-proxy-settings-plan.md new file mode 100644 index 0000000..c920f46 --- /dev/null +++ b/app-src/docs/agent-proxy-settings-plan.md @@ -0,0 +1,209 @@ +# 公网反代「部署设置」对齐本地反代 —— 方案设计 + +> 目标:把本地反向代理(`ProxyView.vue` 规则弹窗)中真正有价值的安全与运维设置,按公网反代的语义迁移到 +> 「公网反代」页(`/public-proxy`,`ProxyRemoteView.vue`)的「部署设置」中,由 havline-agent 生成对应的 Nginx vhost 配置。 +> +> 原则:**公网反代只管「公网入口」这一层**。凡是会破坏 frps vhost 路由、或本质属于「本机内网分流」的选项, +> 一律不迁移(见「明确不做」一节)。 +> +> 更新日期:2026-09-22 + +--- + +## 建议 +「部署设置」弹窗从现在的单页改为**三个 Tab(基础 / 安全 / 高级)**,与本地反代弹窗结构对齐: + +- +**基础**:启用 HTTPS、强制跳转、WebSocket、上传大小、代理超时 + +- +**安全**:IP 白名单 / 黑名单、Basic Auth(用户名 + 可选密码) + +- +**高级**:仅 TLS 1.3、安全响应头、限流、连接数限制 + +第一梯队 7 项都是 agent 模板内改动 + Havline 参数透传,风险低、见效快;第二梯队 3 项需要 agent 多写一个 http 上下文文件。说一声我从第一梯队开始做,或者指定要哪几项。 + + + +## 一、现状对照 + +### 本地反代(ProxyView.vue)规则弹窗现有设置项 + +| 分组 | 设置项 | +| --- | --- | +| 基础 | 名称、前端域名、目标地址、**启用 HTTPS**、**HTTP 跳转 HTTPS**、启用规则 | +| 安全 · IP 访问控制 | 仅中国大陆 IP、黑名单模式 + 列表、白名单模式 + 列表 | +| 安全 · 认证 | Basic Auth(用户名 + **自定义密码**) | +| 安全 · 流量控制 | 请求限流(速率 / 突发)、连接数限制(每 IP 并发) | +| 安全 · 高级 | 忽略后端 TLS 证书、使用目标 Host 头、仅 TLS 1.3、安全响应头 | +| Nginx | auto / custom 自定义配置 | + +### 公网反代(agent `nginxRoute` 模板)现有能力 + +`internal/agent/server.go` 的 `routeRequest` 与 `nginxRoute` 已支持: + +- `domain` / `upstream`(`127.0.0.1:`)/ `tls` + `cert_dir`; +- `websocket`(Upgrade / Connection 头); +- `redirect_https`(80 → 443 的 301 + 443 TLS server 两块); +- `allow_ips`(location 内 allow + deny all); +- `basic_auth_user`(密码由 agent 自动生成并落盘 htpasswd); +- 默认头:Host / X-Real-IP / X-Forwarded-For。 + +Havline 侧对应链路:`ProxyOptions`(`internal/frp/model.go`)→ `DeployAgentRoute` → `AgentClient.SyncRouteEx` +→ `PUT /api/v1/routes`。 + +--- + +## 二、迁移清单 + +### 第一梯队:纯模板级改动,无 Nginx 上下文限制(建议先做) + +| # | 功能 | 对应本地项 | 生成的 Nginx 指令 | +| --- | --- | --- | --- | +| 1 | **启用 HTTPS 开关** | 启用 HTTPS | 关闭时仅 `listen 80`;开启且证书已推送时 `listen 443 ssl` | +| 2 | **安全响应头** | 安全响应头 | `add_header Strict-Transport-Security / X-Frame-Options / X-Content-Type-Options / Referrer-Policy` | +| 3 | **仅 TLS 1.3** | 仅 TLS 1.3 | `ssl_protocols TLSv1.3;` | +| 4 | **客户端最大上传大小** | (本地为全局默认) | `client_max_body_size 50m;`(server 级,按规则可覆盖) | +| 5 | **代理超时** | (本地固定 3600s) | `proxy_read_timeout 3600s;`(长轮询 / 流媒体 / 大文件必需) | +| 6 | **IP 黑名单** | 黑名单模式 | location 内 `deny x.x.x.x;`(与现有白名单对称) | +| 7 | **Basic Auth 自定义密码** | Basic Auth 密码 | 用表单密码写 htpasswd;留空则保持「首次自动生成」 | + +### 第二梯队:需要 Nginx `http` 上下文或额外数据下发(成本中等) + +| # | 功能 | 关键约束 | +| --- | --- | --- | +| 8 | **请求限流** | `limit_req_zone` 只能出现在 `http` 块。agent 需额外生成 `havline-limits.conf`(宝塔将 vhost 目录 include 在 `http{}` 内,因此该文件顶层指令即 http 上下文),各域名 server 内 `limit_req zone=havline_<域名>_req burst=N nodelay;` | +| 9 | **连接数限制** | 同上,`limit_conn_zone` + `limit_conn havline_<域名>_conn N;` | +| 10 | **仅中国大陆 IP** | 需 `geo` 模块 + 中国 IP 段下发到 VPS(Havline 已有 china cidr 模块,7456 条 v4),下发体积最大、成本最高,建议最后做 | + +### 明确不做(字段 / 语义不匹配) + +| 功能 | 原因 | +| --- | --- | +| 使用目标 Host 头 | 公网回源目标是 frps 的 vhost 端口,**frps 靠 Host 头匹配隧道**。改成上游 Host 会让 vhost 无法路由,直接 404。必须保持 `$host` | +| 忽略后端 TLS 证书 | 公网 upstream 是 `http://127.0.0.1:`,不涉及上游 TLS 校验,无作用 | +| 自定义 upstream 覆盖 | 技术上可行,但会让流量绕过 frps 隧道(健康检查「隧道」必然 ✗),语义属于「本机内网分流」,只在本地反代页做 | + +--- + +## 三、字段契约 + +两侧 JSON key 必须严格一致(历史教训:字段未逐项对照导致多轮返工)。 + +| 显示名 | `ProxyOptions`(Go,`internal/frp/model.go`) | JSON key | TS(`web/src/api/types.ts`) | `routeRequest`(agent) | Nginx 指令 | +| --- | --- | --- | --- | --- | --- | +| 启用 HTTPS | `HTTPSDisabled bool` | `https_disabled` | `https_disabled?: boolean` | `HTTPSDisabled bool` | 关闭时不出 `listen 443` | +| 上传大小 | `ClientMaxBodySize string` | `client_max_body_size` | `client_max_body_size?: string` | `ClientMaxBodySize string` | `client_max_body_size 50m;` | +| 代理超时 | `ProxyReadTimeout string` | `proxy_read_timeout` | `proxy_read_timeout?: string` | `ProxyReadTimeout string` | `proxy_read_timeout 3600s;` | +| 安全响应头 | `SecurityHeaders bool` | `security_headers` | `security_headers?: boolean` | `SecurityHeaders bool` | `add_header ...` | +| 仅 TLS 1.3 | `TLS13Only bool` | `tls13_only` | `tls13_only?: boolean` | `TLS13Only bool` | `ssl_protocols TLSv1.3;` | +| IP 黑名单 | `DenyIPs []string` | `deny_ips` | `deny_ips?: string[]` | `DenyIPs []string` | `deny x;` | +| Basic Auth 密码 | `BasicAuthPassword string` | `basic_auth_password` | `basic_auth_password?: string` | `BasicAuthPassword string` | 写入 htpasswd | +| 请求限流(速率) | `RateLimitRate int` | `rate_limit_rate` | `rate_limit_rate?: number` | `RateLimitRate int` | `limit_req_zone ... rate=Nr/s;` | +| 请求限流(突发) | `RateLimitBurst int` | `rate_limit_burst` | `rate_limit_burst?: number` | `RateLimitBurst int` | `limit_req ... burst=N` | +| 连接数限制 | `ConnLimitMax int` | `conn_limit_max` | `conn_limit_max?: number` | `ConnLimitMax int` | `limit_conn_zone` / `limit_conn` | +| 仅中国大陆 | `ChinaOnly bool` | `china_only` | `china_only?: boolean` | `ChinaOnly bool` | `geo` + `allow`/`deny` | + +约束: + +- 这些字段**只作用于公网 Nginx 模板,绝不写入 frpc.toml**(与现有 `websocket` / `redirect_https` 等一致,在 `ProxyOptions` 上以注释标明); +- 「启用 HTTPS」采用**反向字段 `https_disabled`**:字段缺省时保持现有行为(证书推送成功即启用 TLS), + 保证新 Havline + 旧 agent、旧数据不出现行为突变。 + +--- + +## 四、实施步骤 + +### 阶段 1:第一梯队后端(agent + Havline) + +1. `internal/agent/server.go` + - `routeRequest` 增加第一梯队字段; + - `nginxRoute` 模板按字段输出指令:`listen 443` 受 `HTTPSDisabled` 控制;`redirect_https` 仅在 TLS 实际启用时生成; + - `client_max_body_size` / `proxy_read_timeout` / 安全响应头 / `ssl_protocols` / `deny` 列表; + - Basic Auth:传入密码时 `htpasswd -nbB` 写入,未传则沿用现有「首次自动生成 + 复用」。 +2. `internal/frp/model.go`:`ProxyOptions` 增加同名 Go 字段(含注释)。 +3. `internal/frp/agent_client.go`:`SyncRouteEx` 参数改为**传结构体**(当前 10 个位置参数已到上限,继续追加易错): + ```go + type RouteSpec struct { + Domain, Upstream, CertDir string + TLS, WebSocket, RedirectHTTPS, HTTPSDisabled bool + VhostPort int + AllowIPs, DenyIPs []string + BasicAuthUser, BasicAuthPassword string + ClientMaxBodySize, ProxyReadTimeout string + SecurityHeaders, TLS13Only bool + } + func (c *AgentClient) SyncRouteSpec(ctx, spec RouteSpec) (map[string]any, error) + ``` + 保留 `SyncRoute` / `SyncRouteEx` 作为兼容包装。 +4. `internal/frp/service.go`:`DeployAgentRoute` 从 `proxy.Options` 组装 `RouteSpec` 透传。 + +### 阶段 2:第一梯队前端 + +5. `web/src/api/types.ts` 补 `FrpProxyOptions` 字段; +6. `ProxyRemoteView.vue`「部署设置」弹窗改为三个 Tab(与本地反代结构对齐): + - **基础**:启用 HTTPS、HTTP 强制跳转、WebSocket、上传大小、代理超时; + - **安全**:IP 白名单、IP 黑名单、Basic Auth(用户名 + 可选密码); + - **高级**:仅 TLS 1.3、安全响应头(第二梯队上线后再加限流/连接数/中国 IP)。 + +### 阶段 3:第二梯队(按需) + +7. agent 增加 `havline-limits.conf` 生成器(http 上下文 zone 定义)+ 每域名 server 引用; +8. 中国 IP:Havline 将 china cidr 列表推送到 agent(`/var/lib/havline-agent/geo/china.conf`),agent 生成 `geo` 块。 + +--- + +## 五、验证方案 + +每次改动后全量跑(项目既有闭环): + +```powershell +cd f:\Havline-main\Havline-1.0.2 +gofmt -w internal/agent/server.go internal/frp/model.go internal/frp/agent_client.go internal/frp/service.go +go build ./... ; go vet ./internal/frp ./internal/agent ./internal/api +go test ./internal/frp ./internal/api +cd web ; npx vue-tsc --noEmit ; npx vite build +``` + +agent 二进制需按既有流程交叉编译(amd64 + arm64)并升级: + +```powershell +$env:GOOS='linux'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0' +go build -trimpath -o internal/frp/agentbin/havline-agent-linux-amd64.new ./cmd/havline-agent +# arm64 同理;Move-Item 覆盖后,在「公网 Agent」页执行「升级 Agent」 +``` + +VPS 侧实测(部署 7023 规则后): + +```bash +nginx -t # 语法 +cat /www/server/panel/vhost/nginx/havline-7023.36800.cc.conf # 配置内容(或页面「查看配置」) +curl -I http://7023.36800.cc # 强制跳转 → 301 +curl -sI -H 'Host: 7023.36800.cc' http://127.0.0.1:9080 # 回源自检 +curl -k -sI https://7023.36800.cc # 响应头(HSTS / X-Frame-Options) +openssl s_client -connect 7023.36800.cc:443 -tls1_2 **时效说明(2026-09-24)**:文中「顶部下拉选服务端」的结构已被 v1.2.6 的**左列表 + 右详情**取代(页面标题也改为「公网服务端」),现状见 `docs/frp-server-list-merge-plan.md`。 + +> 本文按 `breezell-design` 两遍流程产出,核心是**先列清功能、再谈设计**: +> 第二节把本页应有的 **27 项能力**逐条落位(含 5 处当前页面看不到的能力),第四节按功能分区。 +> +> 取代 `docs/agent-view-design.md`(v1 只做了结构搬家,未解决"功能不可见"问题); +> 也取代 `docs/agent-view-refactor-plan.md`。更新日期:2026-09-23 + +--- + +## 一、Pass 1a:栈与设计系统检测 + +| 检测项 | 实际值 | +| --- | --- | +| 框架 | Vue 3 + ` + + diff --git a/app-src/docs/havline-logo-v2-preview.html b/app-src/docs/havline-logo-v2-preview.html new file mode 100644 index 0000000..ac2e0f3 --- /dev/null +++ b/app-src/docs/havline-logo-v2-preview.html @@ -0,0 +1,905 @@ + + + + + + + + Havline Logo V2 概念预览 + + + + + +
+
+ + + +
+ Havline V2 + Refined mark / harbor gate +
+
+
+ + + +
+
+ +
+
+
+

Logo evolution / 02

+

Havline

+

从“一个 H”变成“一个港湾入口”。

+

V2 保留 H 的识别骨架,但不再用两个端点圆点做连接。线路改为从 H 后方穿过的连续波浪,视觉上像一条服务路径穿过港湾入口,也更适合侧栏的小尺寸使用。

+
+ NO EYES / CONTINUOUS ROUTE + 40PX SIDEBAR SAFE +
+
+ +
+
+ + Havline V2 横向标志 + 左侧为 Havline V2 标志,右侧为 havline 字体组合。 + + havline + SAFE LINE FOR EVERY SERVICE + + +
+
+
+ +
+
+
+

这版改了什么

+

问题不在 H,而在连接方式。旧版的端点圆点在 40px 侧栏里会形成类似“眼睛”的视觉焦点,抢掉了 H 和线路的主次关系。V2 让线路后退,保留一个干净的入口。

+
+
+
+ 01 / REMOVE +

去掉端点圆点

+

不再在左右立柱上放节点,避免小尺寸下像双眼或天线,让 H 的主体轮廓更干净。

+
+
+ 02 / LAYER +

线路退到 H 后方

+

波浪线路从双柱背后穿过,只在中间形成连接,保留“服务穿过港湾入口”的叙事。

+
+
+ 03 / FLOW +

改用连续波浪

+

斜线改成更平滑的水波路径,既有链路感,也接近港湾与水面意象。

+
+
+ 04 / SIDEBAR +

为 40px 优化

+

主结构只保留两根立柱和一条线路,缩放后仍能读出 H,不依赖额外细节。

+
+
+
+
+ +
+
+
+

前后对比

+

左侧是上一版,右侧是 V2。对比重点不是“更复杂”,而是让标志在侧栏尺寸下更稳定、更耐看。

+
+
+
+ + + + + + + + + + V1 / endpoint nodes +
+
+ + + + V2 / continuous route +
+
+
+
+ +
+
+
+

三个方向

+

推荐主案是左侧的“连续路线 H”。另外两个方向可以用于周边、活动或后续品牌迭代,不适合直接替代主标志。

+
+
+ +
+ + + +
+

港湾拱门

+

更抽象、画面感更强

+
+
+
+ + + +
+

小写 h 路线

+

更亲和、更偏产品化

+
+
+
+
+
+ +
+
+
+

侧栏模拟

+

在当前的 40px 品牌区里,V2 不再出现两个高亮圆点,视觉重心重新回到 H 和品牌名称上。

+
+ +
+
+ +
+
+
+

尺寸检查

+

主标志建议最小使用 16px。侧栏品牌区 40px,应用图标 64px,登录页主标志 120px 以上。

+
+
+ + + 16 + + + + 24 + + + + 40 + + + + 64 + + + + 96 + +
+
+
+ +
+
+
+

SVG 源文件

+

这是 V2 主案的可下载 SVG,后续可以直接替换侧栏中的现有标志,不需要再调整图片尺寸。

+
+
+

+          
+ + +
+
+
+
+
+ +
+ Havline V2 · 主案:连续路线 H · 设计目标:更干净、更有港湾入口感、在 40px 侧栏里不产生“表情”错觉。 +
+ + + + diff --git a/app-src/docs/improvement-roadmap-2026-09-27.md b/app-src/docs/improvement-roadmap-2026-09-27.md new file mode 100644 index 0000000..b82b21f --- /dev/null +++ b/app-src/docs/improvement-roadmap-2026-09-27.md @@ -0,0 +1,2103 @@ +# Fonu 改进路线实施方案(P0-P3) + +> 制定日期:2026-09-27 +> 适用版本:v1.2.19 之后的迭代 +> 依据文档:`docs/project-analysis-2026-09-27.md` +> 文档性质:实施方案,不代表本轮已经修改代码 +> 目标:把改进清单拆成可评审、可实施、可验证、可回滚的工程任务 + +--- + +## 0. 执行摘要 + +### 0.1 优先级与目标 + +| 优先级 | 目标 | 建议周期 | 预计工作量 | 产出 | +| --- | --- | --- | ---: | --- | +| P0 | 修复明确问题与安全边界 | 1 个短迭代 | 3-6 人日 | v1.2.x 修复版 | +| P1 | 降低 FRP、Agent、前端巨型模块风险 | 2-4 个迭代 | 15-25 人日 | 架构重构版 | +| P2 | 工程质量收口与测试补强 | 与 P1 并行,后续收尾 | 10-18 人日 | 稳定维护版 | +| P3 | 能力增强 | 独立产品迭代 | 40-70 人日 | v1.3/v1.4 功能版 | + +预计工作量为熟悉本项目的一名工程师估算,不包含需求反复、真机环境等待和外部依赖调整。 + +### 0.2 推荐实施顺序 + +```text +M1:P0 修复与安全收口 + → M2:P1-4 测试基线与 P1-2 文件拆分 + → M3:P1-1 共享 Nginx 模板 + → M4:P1-3 前端拆分 + P1-5 轮询统一 + → M5:P2 工程收口 + → M6:P3 能力按单项排期 +``` + +不推荐一次性同时推进 P1、P2、P3。FRP / Agent、前端大组件、数据库性能、迁移包和 2FA 都属于高回归风险区域,必须分批交付。 + +### 0.3 总体原则 + +1. **每一步都保持可运行**:先补测试和特征化断言,再做重构。 +2. **优先移动而不是重写**:拆分大文件时先保持函数体不变,只改变文件归属。 +3. **Agent 行为变更必须递增 `AgentVersion`**:并同步重编 amd64、arm64 内嵌二进制。 +4. **数据库变更只向前迁移**:新增迁移从 `028` 开始,不修改已经发布的迁移。 +5. **敏感数据默认不回传**:新增接口必须遵守现有的掩码与“只显示一次”约定。 +6. **新增接口必须有测试**:至少覆盖无鉴权、正常路径、失败路径和兼容路径。 +7. **重构不得顺手改业务语义**:同一 PR 只做一类事情,便于审查和回滚。 +8. **删除前先确认引用**:P2 清理旧代码前,先用 `rg` 证明无运行时引用;删除归档内容需单独确认。 + +--- + +## 1. 基线与约束 + +### 1.1 当前代码基线 + +| 项目 | 当前值 | +| --- | --- | +| 版本 | `1.2.19` | +| Agent 版本 | `0.12.7` | +| Go 模块 | `github.com/fonu/fonu` | +| Go 声明版本 | 1.23 | +| Go 文件 | 235 个,其中 90 个测试文件 | +| 内部包 | 29 个 | +| 数据库迁移 | 27 个 | +| 受保护 API | 约 113 条 | +| Agent API | 20 条 `/api/v1/*` | +| 前端源文件 | 52 个 TypeScript / Vue 文件 | +| 前端测试 | 2 个 spec、13 条测试 | + +### 1.2 当前验证门禁 + +每次合并前至少执行: + +```bash +go test ./... -count=1 +go vet ./... + +cd web +npm run typecheck +npm test +npm run build +``` + +涉及 Agent、Docker、Nginx 或跨平台路径时,还需要补: + +```bash +go build ./... +``` + +必要时使用 `scripts/build-linux.ps1` 验证 Linux 安装包。 + +### 1.3 当前已知工程状态 + +| 项目 | 当前状态 | 影响 | +| --- | --- | --- | +| `gofmt -l internal cmd` | 12 个文件未格式化 | 无法立即开启格式化 CI 卡口 | +| 前端硬编码颜色 | 约 287 处匹配 `#hex`、`rgb()`、`rgba()` | 主题一致性与维护成本 | +| FRP 主服务 | `internal/frp/service.go` 约 1805 行 | 修改风险集中 | +| Agent 主服务 | `internal/agent/server.go` 约 1551 行 | HTTP、Nginx、frps、配置写盘耦合 | +| 前端大页面 | `ProxyView.vue` 约 3150 行 | 组件复用、测试和样式隔离困难 | +| 工作区 | 有未提交的删除、二进制与品牌图片修改 | 开始迭代前需确认基线提交 | + +### 1.4 开始实施前的仓库准备 + +1. 确认 `Fonu-1.0.2` 内当前未提交改动是否要保留。 +2. 将品牌图片、内嵌 Agent 二进制更新拆成独立提交,避免和功能改动混在一起。 +3. 把 `internal/frp-1.0.2` 与 `FrpView-1.0.2.vue` 的删除单独提交,不与其他重构同批。 +4. 从最新 `main` 建分支,建议前缀 `codex/`。 +5. 所有方案项都从当前 `origin/main` 的已提交状态开始,不基于未确认的工作区改动。 + +--- + +## 2. 全局完成定义 + +### 2.1 单个任务的完成定义 + +一个任务只有同时满足以下条件才算完成: + +- 代码已实现,并保持现有接口兼容; +- 新增或更新了测试; +- `go test ./... -count=1` 通过; +- `go vet ./...` 通过; +- 前端改动执行 `npm run typecheck`、`npm test`、`npm run build`; +- Agent 行为变化已递增 `AgentVersion` 并重编双架构二进制; +- 数据库变化已新增迁移且验证旧库升级; +- 文档与 `CHANGELOG.md` 已更新; +- 明确记录回滚方式; +- 未把无关格式化、品牌资源、二进制替换混入同一提交。 + +### 2.2 单个 PR 的边界 + +| PR 类型 | 允许包含 | 不允许包含 | +| --- | --- | --- | +| P0 修复 | 单一缺陷、对应测试、必要文档 | 大范围重构、格式化全仓 | +| 拆分重构 | 文件移动、符号迁移、必要的包内适配 | 行为变化、顺手改接口 | +| 模板收敛 | 共享纯函数、两组调用方改造、golden 测试 | 顺手改变 Nginx 输出语义 | +| 前端拆分 | 组件搬迁、props/emits、样式同步搬迁 | 同时重做视觉样式 | +| 性能优化 | 基准、索引、确认后的连接策略 | 未量化就改 SQLite 配置 | +| P3 功能 | 迁移、接口、前端、测试、文档 | 多个 P3 功能混在一个 PR | + +--- + +## 3. P0:先处理明确问题 + +P0 的目标是修复已经确认的缺陷与安全边界,不进行架构大改。 + +--- + +## 3.1 P0-1:修正 DDNS 页旧 `/api/frp` 调用 + +> 实施状态:2026-09-27 已完成。 + +### 3.1.1 现状 + +- `web/src/views/DdnsView.vue` 的 `init()` 调用 `api.getFRP()`。 +- `web/src/api/client.ts` 仍保留旧方法: + - `getFRP` + - `saveFRP` + - `getFRPStatus` + - `syncFRPDomains` + - `getFRPLogs` +- 后端 `internal/api/router.go` 当前不注册 `/api/frp`,只注册 `/api/frpmulti/*`。 +- `api.getFRP()` 因此失败,进入 `catch`,`frpEnabled.value = false`。 +- DDNS 页顶部“FRP 内网穿透已启用”提示不会正确出现。 + +### 3.1.2 目标 + +1. DDNS 页使用当前 FRP 多服务端接口。 +2. 提示只在确实存在可用穿透配置时显示。 +3. 不再依赖已退役的旧 API。 + +### 3.1.3 实施步骤 + +1. 将 `DdnsView.vue` 的调用改为: + + ```ts + const status = await api.getFrpStatus() + frpEnabled.value = status.configured && status.enabled_proxies > 0 + ``` + +2. 如产品语义希望“配置了服务端就提示”,可调整为: + + ```ts + frpEnabled.value = status.configured + ``` + + 推荐使用 `configured && enabled_proxies > 0`,避免用户刚建服务端但没有任何穿透规则时看到误导提示。 + +3. 将 `DdnsView.vue` 中 `api.getFRP` 的导入或调用彻底替换。 +4. 暂时保留旧方法不做删除,删除工作纳入 P2-2,避免 P0 同时扩大改动范围。 + +### 3.1.4 测试 + +- 前端单元测试: + - 新增 `DdnsView` 的辅助纯函数测试,或抽出 `shouldShowFrpHint(status)`。 + - 覆盖 `configured=false`、`enabled_proxies=0`、`configured=true && enabled_proxies>0`。 +- 手工验证: + - 无 FRP 服务端时无提示; + - 有服务端但无启用规则时无提示; + - 有启用规则时出现提示; + - 接口失败时不阻塞 DDNS 页面。 + +### 3.1.5 验收标准 + +- `rg -n "api\\.getFRP" web/src/views/DdnsView.vue` 无结果。 +- 页面不再向 `/api/frp` 发请求。 +- DDNS 主要功能在接口异常时仍可加载。 + +### 3.1.6 风险 + +| 风险 | 处理 | +| --- | --- | +| `getFrpStatus` 返回状态与旧字段不同 | 使用明确映射函数,禁止直接把旧 `enabled` 字段改名为新字段 | +| 状态接口超时影响页面初始化 | 提示查询与主数据加载分离,失败只影响提示 | + +### 3.1.7 实施记录 + +- `web/src/views/DdnsView.vue` 已改用 `api.getFrpStatus()`。 +- 新增 `web/src/utils/frp.ts`,由 `shouldShowFrpHint` 统一判断“已配置且有启用规则”。 +- 新增 `web/src/utils/frp.spec.ts`,覆盖配置、未配置、无启用规则和空状态。 +- 旧 `/api/frp` 客户端方法尚未删除,按要求留到 P2-2 统一清理。 +- 验证通过:`npm test` 17 条、`npm run typecheck`、`npm run build`。 + +--- + +## 3.2 P0-2:初始管理员密码不再写入持久应用日志 + +### 3.2.1 现状 + +- `internal/auth/auth.go` 的 `EnsureDefaultAdmin` 使用 `slog` 记录用户名和密码。 +- `internal/app/app.go` 的日志同时写入 stdout 和 `/logs/app.log`。 +- `internal/logstore/display.go` 的 `appendSystemAttrs` 专门把初始密码渲染进系统日志。 +- `internal/api/logs_handler.go` 的 `/api/logs/system` 会读取 `app.log`。 +- `scripts/fonu-install.sh` 当前从 `app.log` 中提取初始密码。 + +这意味着“初始密码只出现一次”的现实边界并不成立,密码会长期留在数据目录日志中。 + +### 3.2.2 目标 + +1. 初始密码只在首次启动时输出一次。 +2. 默认输出到进程 stdout/stderr,不写入 `app.log`。 +3. Docker 用户可通过 `docker logs fonu` 获取。 +4. systemd 用户可通过 `journalctl -u fonu` 获取。 +5. 安装脚本改为从 journal 或服务日志读取,不再读取 `app.log`。 +6. 日志中心不再能读到初始密码。 + +### 3.2.3 推荐设计 + +修改 `auth.Service.EnsureDefaultAdmin`: + +```go +type BootstrapAdmin struct { + Username string + Password string + Created bool +} + +func (s *Service) EnsureDefaultAdmin(ctx context.Context) (BootstrapAdmin, error) +``` + +- `Created=false` 时不返回密码。 +- 不在 `auth` 包内部写密码日志。 +- `app.New` 收到 `Created=true` 后,使用独立的 `os.Stderr` 或标准输出直接打印,不经过 `slog` 的 `MultiWriter`。 + +示例输出: + +```text +Fonu 首次启动已创建管理员账户 +用户名:admin +初始密码:<一次性密码> +请登录后立即修改密码。 +``` + +### 3.2.4 实施步骤 + +1. 重构 `EnsureDefaultAdmin` 返回值。 +2. `app.New` 调整调用与一次性输出。 +3. 删除 `logstore.appendSystemAttrs` 中初始密码的特殊拼接分支。 +4. 更新 `logstore/display_test.go`,断言日志中出现初始密码构建消息时不再拼接 `password`。 +5. 更新 `scripts/fonu-install.sh`: + - 删除从 `app.log` grep 密码的逻辑; + - 改为读取 `journalctl -u fonu` 最近日志; + - 如果服务尚未写入日志,则提示用户执行 `journalctl` 或 `docker logs`。 +6. 更新 Docker、原生安装和 FAQ 文档。 + +### 3.2.5 测试 + +- `internal/auth/auth_test.go` + - 首次创建返回 `Created=true` 且有密码; + - 第二次调用返回 `Created=false` 且无密码。 +- `internal/app` 测试或日志测试: + - `app.log` 不包含初始密码; + - 标准输出可以包含一次初始密码。 +- `internal/logstore/display_test.go` + - 系统日志渲染不再输出 `password`。 +- shell 验证: + - 原生安装后 `journalctl -u fonu` 能读到初始密码; + - `app.log` 中 grep 不到初始密码。 + +### 3.2.6 验收标准 + +- `/logs/app.log` 中不出现初始密码。 +- `/api/logs/system` 响应中不出现初始密码。 +- 安装脚本仍能提示首次密码。 +- 已初始化实例不受影响。 + +### 3.2.7 兼容与回滚 + +- 已存在的日志不会被自动清理;可增加“安全清理旧日志”的手工建议,不在代码中自动删除。 +- 回滚仅恢复日志输出方式,不影响管理员数据。 +- 该修复不要求 Agent 升级。 + +--- + +## 3.3 P0-3:Agent Token 使用常量时间比较 + +> 实施状态:2026-09-27 已完成。 + +### 3.3.1 现状 + +`internal/agent/server.go` 的 `auth` 方法使用普通字符串比较: + +```go +if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token { +``` + +这不是直接可利用漏洞,但不符合安全敏感的 Token 比较约定。 + +### 3.3.2 目标 + +将 Bearer Token 解析成独立函数,并使用 `crypto/subtle.ConstantTimeCompare`。 + +### 3.3.3 推荐实现 + +```go +func bearerToken(r *http.Request) string { + header := strings.TrimSpace(r.Header.Get("Authorization")) + const prefix = "Bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return "" + } + return strings.TrimSpace(header[len(prefix):]) +} + +func secureEqual(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} +``` + +`auth` 方法: + +```go +token := bearerToken(r) +if token == "" || !secureEqual(token, s.cfg.Token) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return +} +``` + +### 3.3.4 测试 + +新增 `internal/agent/auth_test.go`: + +- 正确 Token 返回 200; +- 错误 Token 返回 401; +- 缺失 Authorization 返回 401; +- `bearer` 大小写兼容; +- 多空格处理; +- 长度不同时不 panic。 + +### 3.3.5 验收标准 + +- 所有 Agent 接口仍使用相同 Bearer Token。 +- 正确 Token 通过,错误 Token 拒绝。 +- Agent 不依赖应用侧 API 中间件代码。 + +### 3.3.6 实施记录 + +- `internal/agent/server.go` 新增 `bearerToken` 与 `secureTokenEqual`,认证路径改用 `crypto/subtle.ConstantTimeCompare`。 +- `AgentVersion` 已由 `0.12.7` 提升到 `0.12.8`。 +- 新增 `internal/agent/auth_test.go`,覆盖正确 Token、大小写 Bearer、多余空白、错误 Token、长度不同、缺失 Authorization 和错误认证方案。 +- 内嵌 amd64 / arm64 二进制已重编;VPS 需要执行一次「升级 Agent」。 +- 验证通过:`go test ./internal/agent -count=1`、`go test ./... -count=1`、`go vet ./...`、`npm test`(17 条)。 + +--- + +## 3.4 P0-4:限流状态解析失败时拒绝覆盖原状态 + +> 实施状态:2026-09-28 已完成。 + +### 3.4.1 现状 + +`internal/agent/server.go` 的 `readLimitState`: + +```go +_ = json.Unmarshal(data, &state) +return state +``` + +如果 `route-limits.json` 损坏: + +1. 返回空 map; +2. `applyLimitState` 只写入当前域名; +3. `writeLimitState` 用不完整状态覆盖原文件; +4. 其他域名的限流配置丢失。 + +### 3.4.2 目标 + +- 文件不存在:返回空状态,不报错。 +- 文件存在但读取失败或 JSON 损坏:返回错误,不继续写。 +- 调用方把错误回传到 HTTP 502/500,保留原文件和现有 Nginx zone。 + +### 3.4.3 推荐接口 + +```go +func readLimitState() (map[string]routeLimit, error) + +func (s *Server) applyLimitState(req routeRequest) error +func (s *Server) clearLimitState(domain string) error +``` + +建议错误文案: + +```text +读取限流状态失败: /var/lib/fonu-agent/route-limits.json: <原因> +``` + +### 3.4.4 实施步骤 + +1. 修改 `readLimitState` 返回 `(map, error)`。 +2. `os.IsNotExist` 返回空 map 和 nil。 +3. `os.ReadFile` 其他错误返回错误。 +4. `json.Unmarshal` 错误返回错误。 +5. `applyLimitState`、`clearLimitState` 直接传播错误。 +6. 新增测试: + - 文件不存在; + - 空对象; + - 正常对象; + - 截断 JSON; + - 类型错误; + - 解析失败时原文件内容不变。 +7. 可选:在损坏时先生成 `.corrupt-` 快照,但不得自动删除原文件。 + +### 3.4.5 验收标准 + +- 损坏状态不会导致其他域名限流配置被清空。 +- 错误能显示在“应用配置”或路由保存响应中。 +- `nginx -t` 仍以真实生成结果为准。 + +### 3.4.6 实施记录 + +- `readLimitState` 改为返回 `(map[string]routeLimit, error)`,新增可测试的 `readLimitStateFile`。 +- 文件不存在返回空状态;读取失败、空文件、`null` 或 JSON 损坏返回错误。 +- `applyLimitState` / `clearLimitState` 传播错误并保留原文件,不再用空状态覆盖。 +- 新增 `internal/agent/limits_test.go`,覆盖缺失、正常、空文件、截断 JSON、null 和原文件不变。 +- `AgentVersion` 提升到 `0.12.9`,双架构内嵌二进制已重编。 +- 验证通过:`go test ./internal/agent -count=1`、`go test ./... -count=1`、`go vet ./...`、`npm test`。 + +--- + +## 3.5 P0-5:明确 Agent 默认监听策略 + +> 方案文档:`agent-listen-strategy-plan.md`(2026-09-28,待实施) + +> 实施状态:阶段 1-4 已随 v1.4.0 实施(默认回环、SSH 隧道、旧 HTTP 自动迁移、HTTPS + pin);仅剩 2.0.0 兼容清理。 + +### 3.5.1 现状 + +- `cmd/fonu-agent/main.go` 默认 `:7700`。 +- `internal/agent.NewServer` 空配置时也回落到 `:7700`。 +- `scripts/agent-install.sh` 的 systemd 单位只设置环境文件,不显式限制监听地址。 +- Agent 当前为明文 HTTP,Bearer Token 在网络中裸传。 +- 安装脚本打印 `http://:7700`,鼓励直接公网访问。 + +### 3.5.2 策略选择 + +推荐采用两阶段策略,避免一次性破坏现有部署: + +#### 阶段 A:先警告与显式标记 + +适用于下一个修复版本: + +1. Agent 状态接口增加: + - `listen_addr` + - `transport_scheme` + - `transport_secure` + - `security_warning` +2. 当监听非环回地址且未启用 TLS 时: + - 启动日志打印醒目的中文警告; + - 前端“公网服务端”页展示警告; + - 安装脚本提示必须使用 Nginx HTTPS 或 SSH 隧道; + - 不立即阻止启动。 + +#### 阶段 B:默认仅本机,显式允许远程 + +适用于一个明确的 minor 版本: + +1. 默认监听改为 `127.0.0.1:7700`。 +2. 新增 `FONU_AGENT_ALLOW_REMOTE=1` 或 `-allow-remote`: + - 没有该显式选项时,拒绝监听非环回地址。 +3. 新增可选直接 TLS: + - `FONU_AGENT_TLS_CERT` + - `FONU_AGENT_TLS_KEY` +4. 非环回监听满足以下任一条件才允许: + - TLS 证书和私钥已配置; + - 明确使用 `--insecure-allow-http` 并打印高危警告。 +5. 安装脚本默认写入: + + ```dotenv + FONU_AGENT_LISTEN=127.0.0.1:7700 + ``` + +6. 如用户选择远程模式,安装脚本引导配置 Nginx HTTPS 反向代理,或写入显式不安全开关。 + +### 3.5.3 中央 Fonu 的连接方式 + +推荐远程连接路径: + +```text +Fonu 控制面 + → https://agent.example.com + → VPS Nginx 443 + → 127.0.0.1:7700 fonu-agent +``` + +备选路径: + +```text +Fonu 控制面 + → SSH 本地转发或 WireGuard + → 127.0.0.1:7700 fonu-agent +``` + +不推荐: + +```text +Fonu 控制面 + → http://公网IP:7700 +``` + +### 3.5.4 前端与接口变化 + +1. `AgentView.vue` 状态卡增加“传输安全”字段。 +2. 当 `transport_secure=false` 且监听地址非本机时,显示橙色警告。 +3. 安装脚本输出改为: + + ```text + Agent 默认仅监听 127.0.0.1:7700。 + 如需远程连接,请先配置 HTTPS 反向代理,再设置 FONU_AGENT_ALLOW_REMOTE=1。 + ``` + +### 3.5.5 兼容迁移 + +升级已有 Agent 时: + +1. 保留 `/etc/fonu-agent/agent.env`。 +2. 旧配置未设置 `FONU_AGENT_LISTEN` 时: + - 阶段 A 继续使用旧默认值; + - 阶段 B 给出迁移提示,并在一个过渡版本内保持旧行为。 +3. 新安装默认 loopback。 +4. 如果旧部署使用公网 `:7700`,升级说明必须明确如何加 Nginx HTTPS。 +5. `agent_url` 当前是 `http://` 时,前端显示不安全提示。 + +### 3.5.6 测试 + +- 默认启动监听 loopback。 +- 非环回监听无显式允许时拒绝启动。 +- `--insecure-allow-http` 时启动并输出警告。 +- 配置 TLS 时启动 HTTPS。 +- 安装脚本生成的环境文件符合所选模式。 +- 旧环境文件升级不丢 Token。 + +### 3.5.7 验收标准 + +- 新安装不会默认暴露明文 Agent 到公网。 +- 用户仍可通过明确配置保留远程管理能力。 +- 文档给出 Nginx HTTPS 与 SSH 隧道两种方案。 +- 安全状态在前端可见。 + +--- + +## 4. P1:降低核心模块风险 + +P1 的核心是控制复杂度,不改变可见功能。 + +--- + +## 4.1 P1-1:抽共享 Nginx 模板包 + +> 实施状态:2026-09-28 已完成(v1.5.1)。共享包 `internal/nginxtmpl` 已落地,本机与 Agent 的 TLS、安全响应头、allow/deny、WebSocket、限流指令与 zone 命名均已迁出;仅保留中国 IP geo 变量名与 Agent 限流文件路径的适配层。 + +### 4.1.1 现状 + +本机与公网 Agent 各有一套 Nginx 生成逻辑: + +| 位置 | 用途 | +| --- | --- | +| `internal/nginx/generate.go` | 本机反代主配置与规则块 | +| `internal/nginx/security.go` | 本机安全、限流、geo、TLS | +| `internal/agent/server.go` 的 `nginxRoute` | VPS vhost | +| `internal/agent/server.go` 的 `sslDirectives` | VPS TLS | +| `internal/agent/server.go` 的 `zoneKey` / `writeLimitState` | VPS 限流 zone | + +同一类安全问题需要改两处,容易漂移。 + +### 4.1.2 共享边界 + +新建纯生成包,建议命名: + +```text +internal/nginxtmpl +``` + +该包只负责把结构化参数渲染成字符串,不依赖: + +- `internal/proxy` +- `internal/config` +- `internal/frp` +- `internal/agent` +- 文件系统 +- 进程执行 + +这样 `internal/nginx` 和 `internal/agent` 都可以安全导入,不会循环依赖。 + +### 4.1.3 建议 API + +```go +package nginxtmpl + +type TLSConfig struct { + CertPath string + KeyPath string + TLS13Only bool +} + +type AccessControl struct { + Allow []string + Deny []string + ChinaOnly bool +} + +type LimitConfig struct { + Zone string + Rate int + Burst int + Conn int +} + +type SecurityHeaders struct { + HTTPS bool + Enabled bool + HSTSMaxAgeDays int +} + +func SSLDirectives(cfg TLSConfig) string +func ServerSecurityHeaders(cfg SecurityHeaders) string +func AccessDirectives(cfg AccessControl, indent string) string +func LimitDirectives(cfg LimitConfig, indent string) string +func RateLimitZone(zone string, rate int) string +func ConnLimitZone(zone string) string +func ZoneName(prefix string, parts ...string) string +``` + +### 4.1.4 先做特征化测试 + +在改动前,为两份输出增加或固定以下测试: + +- HTTPS 证书指令; +- TLS 1.3 开关; +- HSTS 与安全响应头; +- allow / deny 顺序; +- 白名单末尾 `deny all`; +- WebSocket Upgrade 头; +- 限流与连接数指令; +- 中国 IP geo 变量; +- 错误页 location; +- 空参数不输出多余指令。 + +这些测试先锁定现有语义,再替换为共享函数。 + +### 4.1.5 实施步骤 + +1. 新增 `internal/nginxtmpl`。 +2. 先迁出无业务依赖的函数: + - TLS 指令; + - 安全响应头; + - allow / deny; + - WebSocket 头; + - zone 命名。 +3. 本机 `internal/nginx` 改为调用共享函数,保持输出逐字节一致。 +4. Agent `nginxRoute` 分阶段改为调用共享函数。 +5. 为中国 IP 仅大陆规则保留差异适配层: + - 本机使用 `geo $fonu_client_ip` 与规则 ID 变量; + - Agent 使用 `$fonu_china_client`; + - 两者共享 allow/deny 与返回 403 结构,不强行共享变量名。 +6. 删除重复实现,但保留本机与 Agent 各自的上下文拼装函数。 + +### 4.1.6 验收标准 + +- 同一参数在两端的 TLS、安全头、WebSocket、限流输出一致。 +- `internal/agent` 不再自行实现通用 TLS 指令。 +- 两端测试均可独立覆盖共享模板。 +- 本机 Nginx 与 Agent 生成内容没有未解释的语义变化。 + +### 4.1.7 风险 + +| 风险 | 处理 | +| --- | --- | +| 两端缩进与上下文不同 | 共享函数接受 `indent`,不强制服务块结构 | +| Agent 使用域名 zone,本机使用规则 ID | `ZoneName` 只负责合法化,不负责决定命名规则 | +| 修改模板导致现有 vhost 重写 | 先做 golden 对比,再在部署时保留版本回滚 | + +--- + +## 4.2 P1-2:拆分 `internal/frp/service.go` 与 `internal/agent/server.go` + +### 4.2.1 拆分原则 + +1. 同一 Go 包内拆文件,不改变外部接口。 +2. 只移动函数、类型和常量,不重写逻辑。 +3. 每个文件目标 300-500 行以内。 +4. 每个函数继续遵守函数长度不超过 50 行的仓库约定。 +5. 拆分后先跑全量测试,再做后续优化。 + +### 4.2.2 `internal/frp` 目标文件 + +| 新文件 | 责任 | +| --- | --- | +| `service.go` | Service 结构、构造、公共装配 | +| `service_servers.go` | 服务端 CRUD、配置保存、凭证与 TLS 资产 | +| `service_proxies.go` | 穿透规则 CRUD、列表、日志 | +| `service_runtime.go` | frpc 启动、停止、重载、状态、自动恢复 | +| `service_download.go` | 版本列表、下载任务、二进制激活与清理 | +| `service_diagnostics.go` | 服务端五项诊断与延迟记录 | +| `service_agent_lifecycle.go` | SSH 安装、升级、卸载、Probe、急救诊断 | +| `service_agent_routes.go` | vhost 部署、删除、漂移、版本与证书推送 | +| `service_health.go` | 规则健康、可用率、汇总 | +| `service_config.go` | frpc.toml / frps.toml 渲染与原子写 | +| `service_traffic.go` | frps dashboard 流量适配 | + +### 4.2.3 `internal/agent` 目标文件 + +| 新文件 | 责任 | +| --- | --- | +| `server.go` | Config、Server、NewServer、路由注册、ListenAndServe | +| `auth.go` | Bearer Token 解析与常量时间比较 | +| `handlers_status.go` | ping、status、metrics 状态输出 | +| `handlers_routes.go` | route CRUD、conf、版本、回滚 | +| `handlers_nginx.go` | Nginx 探测、安装、校验、重载、日志 | +| `handlers_frps.go` | frps 配置、安装、启停、日志 | +| `handlers_certs.go` | 证书写入与列表 | +| `handlers_china.go` | 中国 IP 段写入与 geo 文件生成 | +| `limits.go` | route-limits.json、zone 文件、限流状态 | +| `nginx_template.go` | Agent vhost 模板拼装 | +| `path_detect.go` | Nginx 目录、可执行文件与版本探测 | +| `basic_auth.go` | htpasswd 生成与复用 | + +### 4.2.4 拆分顺序 + +1. 先拆 `internal/frp` 中低耦合的下载、诊断、健康文件。 +2. 再拆服务端 CRUD 与运行管理。 +3. 最后拆 Agent 生命周期与路由部署。 +4. `internal/agent` 先拆 auth、limits、handlers_nginx,再拆 routes。 +5. 每拆一组文件执行: + + ```bash + go test ./internal/frp ./internal/agent -count=1 + go test ./... -count=1 + ``` + +### 4.2.5 验收标准 + +- 包外 API 没有变化。 +- 没有新增循环依赖。 +- 测试结果与拆分前一致。 +- 文件行数显著下降,主文件不再承担所有职责。 +- 行为变更与文件移动没有混在同一 PR。 + +--- + +## 4.3 P1-3:拆分前端巨型页面 + +### 4.3.1 拆分原则 + +1. 组件只接收 props,通过 emits 通知父页面。 +2. 网络请求留在页面或 composable 中,避免弹窗组件私自保存。 +3. scoped 样式必须随模板一起搬迁。 +4. 每次只拆一个页面,先拆纯展示区域,再拆表单。 +5. 拆分后视觉应与原页面一致。 + +### 4.3.2 `ProxyView.vue` 拆分建议 + +| 新组件 / composable | 责任 | +| --- | --- | +| `ProxyRuleFormModal.vue` | 新建/编辑表单、基础/安全/Nginx 三个页签 | +| `ProxyRuleDetailModal.vue` | 规则详情、概览与日志 | +| `ProxyClientsPanel.vue` | 当前连接与客户端 IP | +| `ProxyNginxPanel.vue` | 自动/自定义配置、版本与回滚 | +| `useProxyRules.ts` | 列表、保存、删除、排序 | +| `useProxyTraffic.ts` | 流量与在线连接轮询 | + +### 4.3.3 `SettingsView.vue` 拆分建议 + +| 新组件 / composable | 责任 | +| --- | --- | +| `SettingsGeneralPanel.vue` | 主题、时区、日志、DDNS、证书默认值 | +| `SettingsNotificationsPanel.vue` | 通知渠道与告警事件 | +| `SettingsSecurityPanel.vue` | 登录失败、全局 IP 策略、信任代理 | +| `SettingsAdvancedPanel.vue` | 中国 IP、Nginx 主配置、状态页、指标 | +| `BackupPanel.vue` | 立即备份、归档、恢复、删除 | +| `ApiTokensPanel.vue` | Token 创建、范围、有效期、撤销 | +| `useSettingsForm.ts` | 设置加载、脏状态、保存与掩码字段 | + +### 4.3.4 `AgentView.vue` 拆分建议 + +| 新组件 / composable | 责任 | +| --- | --- | +| `AgentServerFormModal.vue` | 服务端、Agent、SSH、frps 配置表单 | +| `AgentServerDetailModal.vue` | 详情、frps 同源配置、控制入口 | +| `AgentServerLogsModal.vue` | Agent / frps 日志 | +| `AgentDiagnosticsPanel.vue` | 五项诊断与可用率 | +| `AgentMetricsPanel.vue` | 资源、延迟、日志和证书 | +| `useAgentServers.ts` | 列表、选择、加载、生命周期调用 | +| `useAgentPolling.ts` | 状态与诊断轮询 | + +### 4.3.5 测试与验证 + +- 拆分后保留原有交互路径。 +- 为 props/emits 增加 `@vue/test-utils` 测试。 +- 使用 Playwright 对桌面与移动视口截图: + - `/proxies` + - `/settings` + - `/frp-agent` +- 检查弹窗尺寸、滚动区域、按钮位置、移动端文本溢出。 +- 检查 `vue-tsc` 与 `vite build`。 + +### 4.3.6 验收标准 + +- 单个页面文件不再超过约 1200 行。 +- 新组件职责单一,父页面只负责协调。 +- 原页面的交互和视觉不变。 +- 拆分过程中未丢失 scoped 样式。 + +--- + +## 4.4 P1-4:补路由鉴权、配置回滚、Agent 版本兼容测试 + +> 实施状态:2026-09-28 已完成(v1.5.3)。API 与 Agent 路由均收敛为可遍历路由表;新增 `router_auth_test.go` 遍历全部受保护路由验证匿名 401;补齐本机与 Agent 的配置回滚测试;新增 `agent_versions_compat_test.go` 与前端版本字段降级测试。 + +### 4.4.1 路由鉴权覆盖测试 + +目标:防止新增 API 时漏掉 `protect()`。 + +建议改造 `internal/api/router.go`: + +```go +type routeSpec struct { + Method string + Pattern string + Handler http.HandlerFunc + Public bool + TokenDeny bool +} +``` + +- 构造统一路由表; +- 注册循环只负责挂载与包中间件; +- 测试直接遍历路由表; +- 对受保护路由发匿名请求,期望 401; +- 对公开路由断言不因鉴权而 401。 + +测试文件: + +```text +internal/api/router_auth_test.go +``` + +Agent 同样增加: + +```text +internal/agent/router_auth_test.go +``` + +遍历 20 个 `/api/v1/*` 路径,无 Bearer Token 必须返回 401。 + +### 4.4.2 配置回滚测试 + +覆盖本机: + +- `internal/nginx/versions.go` 的版本列表、读取、回滚; +- 非法版本名拒绝路径上跳; +- 回滚时先校验语法; +- 新配置校验失败时不覆盖当前配置; +- `fsutil.BackupVersioned` 去重逻辑。 + +覆盖 Agent: + +- 写 vhost 前保存版本; +- `nginx -t` 失败后恢复上一可用版本; +- 首次部署失败时删除新文件而不破坏已有文件; +- 回滚接口调用后执行 `nginx -s reload`; +- 旧 Agent 无版本接口时返回升级提示。 + +建议测试文件: + +```text +internal/nginx/versions_test.go +internal/agent/versions_test.go +internal/frp/agent_versions_compat_test.go +``` + +### 4.4.3 Agent 版本兼容测试 + +建立最低版本矩阵: + +| 能力 | 最低 Agent 版本 | +| --- | --- | +| 基础状态 / 路由 | 现有最低版本 | +| metrics / logs / frps-config | 0.11.0 | +| route versions / rollback | 0.12.2 | +| 新版安全监听策略 | 新版本 | + +测试内容: + +- `AgentClient` 遇到 404 / 405 返回 `ErrAgentEndpointMissing`; +- 错误信息包含最低版本和升级提示; +- 其他 4xx / 5xx 不误判为版本过低; +- 状态接口返回 `agent_version` 后可正确展示; +- 新字段缺失时前端降级,不显示空白页。 + +### 4.4.4 验收标准 + +- 新增受保护路由漏鉴权时,测试会失败。 +- 配置回滚失败不会覆盖当前可用配置。 +- 旧 Agent 与新控制端组合时有明确提示,不崩溃。 + +--- + +## 4.5 P1-5:统一前端轮询组合式函数 + +> 实施状态:2026-09-28 已完成(v1.5.4)。`useVisibilityPolling` 增加 in-flight 互斥、`enabled` / `immediate` / `onError` 选项与 `busy` / `lastError` 返回值;Dashboard、Proxy、Logs、Settings、Frp、UpdateModal、Agent 的轮询已全部迁移,仅 AppLayout 时钟保留原生计时器。 + +### 4.5.1 现状 + +已有 `web/src/composables/useVisibilityPolling.ts`,并被: + +- `ProxyRemoteView.vue` +- `FrpView.vue` + +使用,但其他页面仍自行维护计时器: + +- `DashboardView.vue` +- `ProxyView.vue` +- `LogsView.vue` +- `AgentView.vue` +- `SettingsView.vue` 的中国 IP 状态轮询 + +当前 composable 没有 in-flight 互斥,慢请求仍可能堆积。 + +### 4.5.2 目标 + +统一行为: + +1. 页面隐藏时暂停; +2. 回到前台立即补一次; +3. 上一轮未结束时跳过下一轮; +4. 支持 `enabled` 开关; +5. 组件卸载时清理; +6. 可选 `immediate` 立即执行; +7. 返回 `busy` 状态,便于 UI 展示。 + +### 4.5.3 建议 API + +```ts +export interface VisibilityPollingOptions { + enabled?: Ref + immediate?: boolean + onError?: (error: unknown) => void +} + +export function useVisibilityPolling( + fn: () => void | Promise, + intervalMs: number, + options: VisibilityPollingOptions = {}, +) { + // return { start, stop, busy, lastError } +} +``` + +### 4.5.4 实施步骤 + +1. 更新 composable,增加 in-flight 锁。 +2. `tick()` 中: + + ```ts + if (busy.value) return + busy.value = true + Promise.resolve(fn()) + .catch(onError) + .finally(() => { busy.value = false }) + ``` + +3. 将页面手动计时器替换为 composable。 +4. 页面已有的“首次加载”和“轮询”分开: + - 首次加载立即执行; + - 轮询只刷新增量或状态。 +5. SSE 日志不纳入轮询 composable。 + +### 4.5.5 测试 + +- 隐藏时不执行; +- 回到前台执行一次; +- 请求未结束时跳过下一轮; +- `enabled=false` 不执行; +- 卸载后无计时器; +- `onError` 被调用且不影响后续轮询。 + +### 4.5.6 验收标准 + +- 各页面不再重复实现 `setInterval + visibilitychange`。 +- 慢接口不会产生请求堆积。 +- 页面前后台切换行为一致。 + +--- + +## 5. P2:工程质量提升 + +P2 的重点是减少噪音、建立长期质量门禁,不改变大功能。 + +--- + +## 5.1 P2-1:全仓 `gofmt` 与 CI 卡口 + +### 5.1.1 当前结果 + +执行: + +```bash +gofmt -l internal cmd +``` + +当前仍有未格式化文件,包括: + +- `internal/acme/job.go` +- `internal/api/auth_handler_test.go` +- `internal/api/logs_handler_test.go` +- `internal/api/version_handler.go` +- `internal/ddns/store.go` +- `internal/ddns/tencentcloud_test.go` +- `internal/frp/diagnostics.go` +- `internal/frp/store.go` +- `internal/nginx/generate_multi_test.go` +- `internal/nginx/generate_seed_mix_test.go` +- `internal/nginx/realip.go` +- `internal/nginx/security.go` + +### 5.1.2 实施步骤 + +1. 单独创建“格式化”提交。 +2. 执行: + + ```bash + gofmt -w ./cmd ./internal + ``` + +3. 检查 diff,确认只有格式变化。 +4. 运行: + + ```bash + go test ./... -count=1 + go vet ./... + ``` + +5. 在 `.github/workflows/ci.yml` 的 `go vet` 前增加: + + ```yaml + - name: gofmt + run: test -z "$(gofmt -l ./cmd ./internal)" + ``` + +### 5.1.3 验收标准 + +- `gofmt -l internal cmd` 无输出。 +- CI 对后续未格式化文件立即失败。 +- 格式化 PR 不包含业务逻辑变化。 + +--- + +## 5.2 P2-2:清理旧 FRP 类型、旧 API 方法与未使用样式 + +### 5.2.1 旧 API 清理 + +目标: + +- `web/src/api/client.ts` 删除: + - `getFRP` + - `saveFRP` + - `getFRPStatus` + - `syncFRPDomains` + - `getFRPLogs` +- `web/src/api/types.ts` 删除不再使用的旧接口: + - `FRPConfig` + - `FRPResponse` + - `FRPSaveResponse` + - `FRPSavePayload` + - 以及只服务旧 `/api/frp` 的 `FRPStatus` + +注意:`FrpStatus` 这个名字与多服务端 `FrpStatus` 有潜在冲突,清理时先确认类型引用。 + +### 5.2.2 前端样式清理流程 + +1. 枚举模板中的 class; +2. 与 ` + + +
+%s +
%d
+

%s

+

%s

+
+ +`, code, title, bgPos, imgBlock, code, title, message) +} diff --git a/app-src/internal/nginx/errors_test.go b/app-src/internal/nginx/errors_test.go new file mode 100644 index 0000000..00f816d --- /dev/null +++ b/app-src/internal/nginx/errors_test.go @@ -0,0 +1,45 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/config" +) + +func TestEnsureErrorPagesWritesAssets(t *testing.T) { + dir := t.TempDir() + cfg := config.Config{DataDir: dir} + + if err := EnsureErrorPages(cfg); err != nil { + t.Fatal(err) + } + + errorsDir := cfg.ErrorsDir() + for _, name := range []string{"403.html", "404.html", "error.png", "429.png"} { + path := filepath.Join(errorsDir, name) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("missing %s: %v", name, err) + } + if info.Size() == 0 { + t.Fatalf("%s is empty", name) + } + } + + html, err := os.ReadFile(filepath.Join(errorsDir, "403.html")) + if err != nil { + t.Fatal(err) + } + body := string(html) + for _, want := range []string{ + "/havline-errors/error.png", + "background:url(/havline-errors/error.png)", + } { + if !strings.Contains(body, want) { + t.Fatalf("403.html missing %q:\n%s", want, body) + } + } +} diff --git a/app-src/internal/nginx/fileperm.go b/app-src/internal/nginx/fileperm.go new file mode 100644 index 0000000..6fc4cb0 --- /dev/null +++ b/app-src/internal/nginx/fileperm.go @@ -0,0 +1,21 @@ +package nginx + +import ( + "os" + "runtime" +) + +// Debian/Ubuntu nginx worker user. +const nginxWorkerUID = 33 +const nginxWorkerGID = 33 + +func ensureNginxReadable(path string, dir bool) { + mode := os.FileMode(0o644) + if dir { + mode = 0o755 + } + _ = os.Chmod(path, mode) + if runtime.GOOS == "linux" { + _ = os.Chown(path, nginxWorkerUID, nginxWorkerGID) + } +} diff --git a/app-src/internal/nginx/generate.go b/app-src/internal/nginx/generate.go new file mode 100644 index 0000000..32d844b --- /dev/null +++ b/app-src/internal/nginx/generate.go @@ -0,0 +1,277 @@ +package nginx + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/nginxtmpl" + "github.com/havline/havline/internal/proxy" +) + +func Generate(cfg config.Config, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) { + var b strings.Builder + + b.WriteString(`worker_processes auto; +error_log ` + absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")) + ` warn; +pid ` + absNginxPath(cfg.NginxPIDFile) + `; + +events { + worker_connections 1024; +} + +http { + include ` + absNginxPath(cfg.NginxMimeTypes) + `; + default_type application/octet-stream; + + log_format havline_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent'; + access_log ` + absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")) + ` havline_access; + + sendfile on; + keepalive_timeout 65; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + limit_req_status 429; + limit_conn_status 503; + +`) + + writeRealIPDirectives(&b, opts.TrustedProxy) + writeGlobalAllowList(&b, opts.GlobalIPWhitelist) + writeGlobalDenyList(&b, opts.GlobalIPBlacklist) + writeLimitZones(&b, rules) + writeChinaGeoBlocks(&b, cfg, opts, rules) + writeChinaOnlyBypassGeo(&b, rules, opts) + writeGlobalCustomInclude(&b, cfg, opts) + + for _, rule := range rules { + if !rule.Enabled { + continue + } + if rule.NginxMode == "custom" { + if err := writeCustomRuleBlocks(&b, cfg, rule.ID, opts); err != nil { + return "", err + } + continue + } + if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil { + return "", err + } + } + + b.WriteString("}\n") + content := b.String() + if err := assertValidSSLBlocks(content); err != nil { + return "", err + } + return content, nil +} + +func GenerateRuleBlocks(cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) { + var b strings.Builder + writeRulePreambleComments(&b, rule) + if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil { + return "", err + } + content := b.String() + if err := assertValidSSLBlocks(content); err != nil { + return "", err + } + return content, nil +} + +func writeGlobalCustomInclude(b *strings.Builder, cfg config.Config, opts GenerateOptions) { + if strings.TrimSpace(opts.GlobalCustomOverride) != "" { + writeIndentedHTTPSnippet(b, opts.GlobalCustomOverride) + return + } + if globalCustomShouldInclude(cfg, opts) { + path := globalCustomPath(cfg, opts) + b.WriteString(fmt.Sprintf(" include %s;\n\n", absNginxPath(path))) + return + } + writeIndentedHTTPSnippet(b, DefaultGlobalHTTPSnippet()) +} + +func writeIndentedHTTPSnippet(b *strings.Builder, content string) { + for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString("\n") +} + +func writeCustomRuleBlocks(b *strings.Builder, cfg config.Config, ruleID int64, opts GenerateOptions) error { + content := strings.TrimSpace(ruleCustomContent(cfg, ruleID, opts)) + if content == "" { + return fmt.Errorf("规则 %d 的自定义 Nginx 配置为空", ruleID) + } + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + b.WriteString(content) + return nil +} + +func writeRuleBlocks(b *strings.Builder, cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) error { + for _, group := range rule.PortGroups() { + if len(group.Hostnames) == 0 { + continue + } + serverNames := strings.Join(group.Hostnames, " ") + cert := findCertificateForHosts(cfg.CertsDir(), group.Hostnames, certs) + useHTTPS := rule.HTTPSEnabled && certUsable(cert) + + wroteBlock := false + if useHTTPS { + if !appendSSLServerBlock(b, cfg, group.Port, serverNames, cert, rule, opts) { + return fmt.Errorf("HTTPS 证书文件不可用,请重新导入证书或关闭 HTTPS") + } + wroteBlock = true + } + if rule.HTTPRedirect && useHTTPS { + writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "redirect") + b.WriteString("server {\n") + writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames)) + writeErrorPages(b, cfg) + writeCommentLine(b, 4, "301 跳转至 HTTPS") + b.WriteString(" return 301 https://$host:$server_port$request_uri;\n") + b.WriteString("}\n") + wroteBlock = true + } + + if !wroteBlock { + writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "http") + b.WriteString("server {\n") + writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6) + b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames)) + writeErrorPages(b, cfg) + writeLocationWithSecurity(b, cfg, rule, opts) + b.WriteString("}\n") + } + } + return nil +} + +func appendSSLServerBlock(b *strings.Builder, cfg config.Config, port int, serverNames string, cert *certFiles, rule proxy.Rule, opts GenerateOptions) bool { + if !certUsable(cert) { + return false + } + var block strings.Builder + writeServerBlockHeaderComment(&block, rule, serverNames, port, "https") + block.WriteString("server {\n") + writeListenComments(&block, port, true, rule.ListenIPv4, rule.ListenIPv6) + writeListenDirectives(&block, port, true, rule.ListenIPv4, rule.ListenIPv6) + block.WriteString(fmt.Sprintf(" server_name %s;\n\n", serverNames)) + writeCommentLine(&block, 4, "SSL 证书") + writeTLSProtocolComment(&block, rule.Security) + block.WriteString(nginxtmpl.SSLDirectives(nginxtmpl.TLSConfig{ + CertPath: absNginxPath(cert.CertPath), + KeyPath: absNginxPath(cert.KeyPath), + TLS13Only: rule.Security.TLSMin13Only, + })) + block.WriteString("\n") + writeServerSecurityHeaders(&block, rule.Security, true) + writeErrorPages(&block, cfg) + writeLocationWithSecurity(&block, cfg, rule, opts) + block.WriteString("}\n") + b.WriteString(block.String()) + return true +} + +func writeLocationWithSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) { + writeLocationHeaderComment(b, rule.Upstream) + b.WriteString(" location / {\n") + writeLocationSecurity(b, cfg, rule, opts) + writeProxyLocationBody(b, rule.Upstream, rule.Security) + b.WriteString(" }\n") +} + +func writeProxyLocationBody(b *strings.Builder, upstream string, sec proxy.SecurityConfig) { + sec = sec.Normalize() + writeProxyBodyComments(b, upstream, sec) + hostHeader := "$host" + if sec.ProxyHostUpstream { + hostHeader = "$proxy_host" + } + sslBlock := "" + if strings.HasPrefix(upstream, "https://") { + if sec.ProxySSLVerifyOff { + sslBlock = ` proxy_ssl_verify off; + proxy_ssl_server_name on; +` + } else { + sslBlock = ` proxy_ssl_server_name on; +` + } + } + b.WriteString(fmt.Sprintf(`%s proxy_pass %s; + proxy_http_version 1.1; + proxy_set_header Host %s; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Real-Proto $scheme; +%s + proxy_buffering off; + proxy_read_timeout 3600s; +`, sslBlock, upstream, hostHeader, nginxtmpl.UpgradeHeaders(" ", "$connection_upgrade"))) +} + +func writeListenDirectives(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) { + sslSuffix := "" + if ssl { + sslSuffix = " ssl" + } + if ipv4 { + b.WriteString(fmt.Sprintf(" listen %d%s;\n", port, sslSuffix)) + } + if ipv6 { + b.WriteString(fmt.Sprintf(" listen [::]:%d%s;\n", port, sslSuffix)) + } +} + +type certFiles struct { + CertPath string + KeyPath string +} + +func findCertificate(certsDir, domain string) *certFiles { + parts := strings.Split(domain, ".") + for i := 0; i < len(parts)-1; i++ { + wildcard := "*." + strings.Join(parts[i:], ".") + if c := certAt(certsDir, wildcard); c != nil { + return c + } + } + if c := certAt(certsDir, domain); c != nil { + return c + } + return nil +} + +func certAt(certsDir, name string) *certFiles { + safeName := strings.ReplaceAll(name, "*", "wildcard") + dir := filepath.Join(certsDir, safeName) + certPath := filepath.Join(dir, "fullchain.pem") + keyPath := filepath.Join(dir, "privatekey.pem") + if fileExists(certPath) && fileExists(keyPath) { + return &certFiles{CertPath: absNginxPath(certPath), KeyPath: absNginxPath(keyPath)} + } + return nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/app-src/internal/nginx/generate_china_bypass_test.go b/app-src/internal/nginx/generate_china_bypass_test.go new file mode 100644 index 0000000..811daf2 --- /dev/null +++ b/app-src/internal/nginx/generate_china_bypass_test.go @@ -0,0 +1,56 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestChinaOnlyBypassGeoWithoutUserWhitelist(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf") + if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ID: 3, + ListenPort: 8080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:3000", + Enabled: true, + HTTPSEnabled: false, + Security: proxy.SecurityConfig{ChinaOnly: true}, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{ChinaCIDRAvailable: true}) + if err != nil { + t.Fatalf("generate: %v", err) + } + for _, want := range []string{ + "geo $havline_client_ip $havline_rule_3_china_bypass", + "192.168.0.0/16 1;", + "10.0.0.0/8 1;", + "if ($havline_rule_3_china_bypass = 1)", + } { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in:\n%s", want, content) + } + } +} diff --git a/app-src/internal/nginx/generate_china_cidr_path_test.go b/app-src/internal/nginx/generate_china_cidr_path_test.go new file mode 100644 index 0000000..fafcb3c --- /dev/null +++ b/app-src/internal/nginx/generate_china_cidr_path_test.go @@ -0,0 +1,59 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateChinaCIDRUsesFinalPathWhenOverrideCleared(t *testing.T) { + dir := t.TempDir() + cidrDir := filepath.Join(dir, "nginx") + if err := os.MkdirAll(cidrDir, 0o755); err != nil { + t.Fatal(err) + } + finalPath := filepath.Join(cidrDir, "china_cidr.conf") + if err := os.WriteFile(finalPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + if err := os.WriteFile(cfg.NginxMimeTypes, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + + rules := []proxy.Rule{{ + ID: 1, + ListenPort: 8080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:3000", + Enabled: true, + Security: proxy.SecurityConfig{ChinaOnly: true}, + }} + + opts := GenerateOptions{ + ChinaCIDRAvailable: true, + ChinaCIDRPathOverride: filepath.Join(cidrDir, "china_cidr.conf.tmp"), + } + opts.ChinaCIDRPathOverride = "" + + content, err := Generate(cfg, rules, nil, opts) + if err != nil { + t.Fatal(err) + } + if strings.Contains(content, "china_cidr.conf.tmp") { + t.Fatalf("expected final china cidr path, got tmp reference:\n%s", content) + } + if !strings.Contains(content, "china_cidr.conf") { + t.Fatalf("missing china_cidr.conf include:\n%s", content) + } +} diff --git a/app-src/internal/nginx/generate_comments_test.go b/app-src/internal/nginx/generate_comments_test.go new file mode 100644 index 0000000..c76222b --- /dev/null +++ b/app-src/internal/nginx/generate_comments_test.go @@ -0,0 +1,49 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateRuleBlocksIncludesComments(t *testing.T) { + cfg := config.Config{DataDir: t.TempDir()} + rule := proxy.Rule{ + ID: 3, + Name: "havline", + Upstream: "http://127.0.0.1:8080", + ListenPort: 8011, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "s.example.com"}}, + HTTPSEnabled: false, + Enabled: true, + Security: proxy.SecurityConfig{ + IPBlacklist: []string{"192.168.8.2"}, + IPWhitelistMode: true, + IPWhitelist: []string{"192.168.8.1"}, + RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20}, + ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20}, + }, + } + out, err := GenerateRuleBlocks(cfg, rule, nil, GenerateOptions{}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "# 规则:havline", + "# 上游:http://127.0.0.1:8080", + "# 限流 zone 在 http 块中自动生成", + "# IP 黑名单", + "# IP 白名单模式", + "# 请求限流:10 次/秒,突发 20", + "# 连接数限制:每 IP 最多 20 并发", + "# 反代 location /", + "# 反向代理与 WebSocket 头", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing comment %q in:\n%s", want, out) + } + } +} diff --git a/app-src/internal/nginx/generate_https_no_cert_test.go b/app-src/internal/nginx/generate_https_no_cert_test.go new file mode 100644 index 0000000..a12a5e7 --- /dev/null +++ b/app-src/internal/nginx/generate_https_no_cert_test.go @@ -0,0 +1,38 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateHTTPSWithoutCertUsesHTTPOnly(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), + NginxPIDFile: t.TempDir() + "/nginx.pid", + NginxMimeTypes: t.TempDir() + "/mime.types", + } + rules := []proxy.Rule{{ + ListenPort: 8011, + ListenIPv4: true, + ListenIPv6: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://192.168.8.3:6893", + HTTPSEnabled: true, + HTTPRedirect: true, + Enabled: true, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + if strings.Contains(content, " ssl") { + t.Fatalf("expected no ssl listen without certificate:\n%s", content) + } + if !strings.Contains(content, "proxy_pass http://192.168.8.3:6893") { + t.Fatalf("expected http proxy fallback:\n%s", content) + } +} diff --git a/app-src/internal/nginx/generate_https_with_cert_test.go b/app-src/internal/nginx/generate_https_with_cert_test.go new file mode 100644 index 0000000..0b7e8f3 --- /dev/null +++ b/app-src/internal/nginx/generate_https_with_cert_test.go @@ -0,0 +1,56 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateHTTPSWithCertIncludesCertificateDirectives(t *testing.T) { + dir := t.TempDir() + certDir := filepath.Join(dir, "certs", "wildcard.example.com") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ListenPort: 8011, + ListenIPv4: true, + ListenIPv6: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://192.168.8.3:6893", + HTTPSEnabled: true, + HTTPRedirect: true, + Enabled: true, + }} + + content, err := Generate(cfg, rules, []CertSource{{ + Domains: []string{"*.example.com", "example.com"}, + CertPath: filepath.Join(certDir, "fullchain.pem"), + KeyPath: filepath.Join(certDir, "privatekey.pem"), + }}, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + if !strings.Contains(content, "listen 8011 ssl;") { + t.Fatalf("expected ssl listen:\n%s", content) + } + if !strings.Contains(content, "ssl_certificate") || !strings.Contains(content, "ssl_certificate_key") { + t.Fatalf("expected certificate directives:\n%s", content) + } +} diff --git a/app-src/internal/nginx/generate_multi_test.go b/app-src/internal/nginx/generate_multi_test.go new file mode 100644 index 0000000..a9eb8f5 --- /dev/null +++ b/app-src/internal/nginx/generate_multi_test.go @@ -0,0 +1,51 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateMultiHostCustomPort(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), + NginxPIDFile: t.TempDir() + "/nginx.pid", + NginxMimeTypes: t.TempDir() + "/mime.types", + } + port6893 := 6893 + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://192.168.8.3:6893", + ListenPort: 8011, + ListenIPv4: true, + ListenIPv6: true, + Hosts: []proxy.Host{ + {Hostname: "1.example.com"}, + {Hostname: "2.example.com"}, + {Hostname: "example.org"}, + {Hostname: "example.com", ListenPort: &port6893}, + }, + HTTPSEnabled: false, + HTTPRedirect: false, + Enabled: true, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + for _, want := range []string{ + "listen 8011;", + "listen [::]:8011;", + "server_name 1.example.com 2.example.com example.org", + "listen 6893;", + "listen [::]:6893;", + "server_name example.com", + } { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in generated config:\n%s", want, content) + } + } +} diff --git a/app-src/internal/nginx/generate_order_test.go b/app-src/internal/nginx/generate_order_test.go new file mode 100644 index 0000000..f59d6c3 --- /dev/null +++ b/app-src/internal/nginx/generate_order_test.go @@ -0,0 +1,61 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateHTTPSBlockBeforeHTTPRedirect(t *testing.T) { + dir := t.TempDir() + certDir := filepath.Join(dir, "certs", "wildcard.example.com") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ListenPort: 8017, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:1", + HTTPSEnabled: true, + HTTPRedirect: true, + Enabled: true, + }} + certs := []CertSource{{ + Domains: []string{"*.example.com"}, + CertPath: filepath.Join(certDir, "fullchain.pem"), + KeyPath: filepath.Join(certDir, "privatekey.pem"), + }} + + content, err := Generate(cfg, rules, certs, GenerateOptions{}) + if err != nil { + t.Fatalf("generate: %v", err) + } + sslIdx := strings.Index(content, "listen 8017 ssl;") + redirectIdx := strings.Index(content, "return 301 https://") + if sslIdx < 0 || redirectIdx < 0 { + t.Fatalf("missing expected directives:\n%s", content) + } + if sslIdx > redirectIdx { + t.Fatalf("expected ssl server block before http redirect:\n%s", content) + } +} diff --git a/app-src/internal/nginx/generate_security_test.go b/app-src/internal/nginx/generate_security_test.go new file mode 100644 index 0000000..896fc02 --- /dev/null +++ b/app-src/internal/nginx/generate_security_test.go @@ -0,0 +1,95 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateSecurityDirectives(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf") + if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + rules := []proxy.Rule{{ + ID: 7, + ListenPort: 8080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + Upstream: "http://127.0.0.1:3000", + Enabled: true, + HTTPSEnabled: false, + Security: proxy.SecurityConfig{ + IPBlacklist: []string{"1.2.3.4", "10.0.0.0/8"}, + IPWhitelistMode: true, + IPWhitelist: []string{"192.168.1.0/24"}, + ChinaOnly: true, + BasicAuth: &proxy.BasicAuthConfig{ + Enabled: true, + Username: "user", + PasswordHash: "$2a$10$abcdefghijklmnopqrstuv", + }, + RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20}, + ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20}, + }, + }} + + opts := GenerateOptions{ + GlobalIPWhitelist: []string{"203.0.113.10"}, + GlobalIPBlacklist: []string{"8.8.8.8"}, + ChinaCIDRAvailable: true, + } + content, err := Generate(cfg, rules, nil, opts) + if err != nil { + t.Fatalf("generate: %v", err) + } + checks := []string{ + "allow 10.0.0.0/8;", + "allow 203.0.113.10;", + "deny 8.8.8.8;", + "limit_req_zone $binary_remote_addr zone=havline_rule_7_req", + "limit_conn_zone $binary_remote_addr zone=havline_rule_7_conn", + "deny 1.2.3.4;", + "geo $havline_client_ip $havline_is_private", + "geo $havline_client_ip $havline_rule_7_china_bypass", + "192.168.0.0/16 1;", + "192.168.1.0/24 1;", + "203.0.113.10 1;", + "set $havline_china_block 0;", + "if ($havline_is_private = 1)", + "if ($havline_rule_7_china_bypass = 1)", + "allow 192.168.1.0/24;", + "deny all;", + "auth_basic", + "limit_req zone=havline_rule_7_req burst=20", + "limit_conn havline_rule_7_conn 20", + "error_page 403 /havline-errors/403.html", + "location = /havline-errors/error.png", + "location = /havline-errors/429.png", + "X-Forwarded-Host $host", + "X-Forwarded-Port $server_port", + "X-Real-Proto $scheme", + } + for _, want := range checks { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in:\n%s", want, content) + } + } +} diff --git a/app-src/internal/nginx/generate_seed_mix_test.go b/app-src/internal/nginx/generate_seed_mix_test.go new file mode 100644 index 0000000..2af76b5 --- /dev/null +++ b/app-src/internal/nginx/generate_seed_mix_test.go @@ -0,0 +1,45 @@ +package nginx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateMixedRulesWithoutCerts(t *testing.T) { + dir := t.TempDir() + mimePath := filepath.Join(dir, "mime.types") + if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := config.Config{ + DataDir: dir, + NginxPIDFile: filepath.Join(dir, "nginx.pid"), + NginxMimeTypes: mimePath, + } + + rules := []proxy.Rule{ + { + ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: true, HTTPRedirect: true, Enabled: true, + }, + { + ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: true, + Hosts: []proxy.Host{{Hostname: "app.example.com"}}, + HTTPSEnabled: true, HTTPRedirect: true, Enabled: true, + }, + } + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + if err := assertValidSSLBlocks(content); err != nil { + t.Fatalf("invalid ssl blocks: %v\n%s", err, content) + } +} diff --git a/app-src/internal/nginx/generate_test.go b/app-src/internal/nginx/generate_test.go new file mode 100644 index 0000000..ee424e5 --- /dev/null +++ b/app-src/internal/nginx/generate_test.go @@ -0,0 +1,44 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestGenerateIncludesWebSocketAndProxy(t *testing.T) { + cfg := config.Config{ + DataDir: t.TempDir(), + NginxPIDFile: t.TempDir() + "/nginx.pid", + NginxMimeTypes: t.TempDir() + "/mime.types", + } + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://192.168.1.10:5666", + ListenPort: 80, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: false, + HTTPRedirect: false, + Enabled: true, + }} + + content, err := Generate(cfg, rules, nil, GenerateOptions{}) + if err != nil { + t.Fatalf("generate failed: %v", err) + } + for _, want := range []string{ + "client_max_body_size 50m;", + "listen 80;", + "server_name nas.example.com", + "proxy_pass http://192.168.1.10:5666", + "proxy_set_header Upgrade $http_upgrade", + "proxy_buffering off", + } { + if !strings.Contains(content, want) { + t.Fatalf("missing %q in generated config", want) + } + } +} diff --git a/app-src/internal/nginx/global_framework_test.go b/app-src/internal/nginx/global_framework_test.go new file mode 100644 index 0000000..bd7d62a --- /dev/null +++ b/app-src/internal/nginx/global_framework_test.go @@ -0,0 +1,37 @@ +package nginx + +import ( + "strings" + "testing" + + "github.com/havline/havline/internal/config" +) + +func TestGenerateGlobalFrameworkIncludesComments(t *testing.T) { + cfg := config.Config{DataDir: t.TempDir()} + out := GenerateGlobalFramework(cfg) + for _, want := range []string{ + "# 以下部分由 Havline 自动生成", + "# 主进程", + "# MIME 类型", + "# 访问日志格式与路径", + "# 传输优化", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing comment %q in:\n%s", want, out) + } + } + if strings.Contains(out, "client_max_body_size") { + t.Fatalf("framework should not include client_max_body_size:\n%s", out) + } +} + +func TestDefaultGlobalHTTPSnippet(t *testing.T) { + out := DefaultGlobalHTTPSnippet() + if !strings.Contains(out, "client_max_body_size 50m;") { + t.Fatalf("missing upload limit in snippet:\n%s", out) + } + if !strings.Contains(out, "# 上传大小限制") { + t.Fatalf("missing comment in snippet:\n%s", out) + } +} diff --git a/app-src/internal/nginx/htpasswd.go b/app-src/internal/nginx/htpasswd.go new file mode 100644 index 0000000..4abc57d --- /dev/null +++ b/app-src/internal/nginx/htpasswd.go @@ -0,0 +1,70 @@ +package nginx + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +// nginxHtpasswdHash normalizes bcrypt hashes for nginx/apr auth_basic. +// Go's bcrypt uses the $2a$ prefix; some nginx builds only accept $2y$. +func nginxHtpasswdHash(hash string) string { + if strings.HasPrefix(hash, "$2a$") { + return "$2y$" + hash[4:] + } + return hash +} + +func HtpasswdDir(cfg config.Config) string { + return filepath.Join(cfg.NginxDir(), "htpasswd") +} + +func HtpasswdPath(cfg config.Config, ruleID int64) string { + return filepath.Join(HtpasswdDir(cfg), fmt.Sprintf("rule_%d.conf", ruleID)) +} + +func SyncHtpasswdFiles(cfg config.Config, rules []proxy.Rule) error { + dir := HtpasswdDir(cfg) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + ensureNginxReadable(dir, true) + + active := map[int64]bool{} + for _, rule := range rules { + path := HtpasswdPath(cfg, rule.ID) + if rule.Security.BasicAuthEnabled() { + active[rule.ID] = true + line := fmt.Sprintf("%s:%s\n", rule.Security.BasicAuth.Username, nginxHtpasswdHash(rule.Security.BasicAuth.PasswordHash)) + if err := os.WriteFile(path, []byte(line), 0o644); err != nil { + return err + } + ensureNginxReadable(path, false) + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + var id int64 + if _, err := fmt.Sscanf(entry.Name(), "rule_%d.conf", &id); err != nil { + continue + } + if !active[id] { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + return nil +} diff --git a/app-src/internal/nginx/htpasswd_hash_test.go b/app-src/internal/nginx/htpasswd_hash_test.go new file mode 100644 index 0000000..a245e10 --- /dev/null +++ b/app-src/internal/nginx/htpasswd_hash_test.go @@ -0,0 +1,19 @@ +package nginx + +import "testing" + +func TestNginxHtpasswdHashNormalizesBcryptPrefix(t *testing.T) { + in := "$2a$10$abcdefghijklmnopqrstuv" + got := nginxHtpasswdHash(in) + want := "$2y$10$abcdefghijklmnopqrstuv" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestNginxHtpasswdHashLeavesOtherFormats(t *testing.T) { + in := "$apr1$abc123" + if got := nginxHtpasswdHash(in); got != in { + t.Fatalf("got %q want %q", got, in) + } +} diff --git a/app-src/internal/nginx/htpasswd_test.go b/app-src/internal/nginx/htpasswd_test.go new file mode 100644 index 0000000..40ff86d --- /dev/null +++ b/app-src/internal/nginx/htpasswd_test.go @@ -0,0 +1,60 @@ +package nginx + +import ( + "os" + "runtime" + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestSyncHtpasswdFilesWritesBasicAuth(t *testing.T) { + cfg := config.Config{DataDir: t.TempDir()} + rules := []proxy.Rule{{ + ID: 3, + Enabled: true, + Security: proxy.SecurityConfig{ + BasicAuth: &proxy.BasicAuthConfig{ + Enabled: true, + Username: "admin", + PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwx.yz012345678901234567890", + }, + }, + }} + + if err := SyncHtpasswdFiles(cfg, rules); err != nil { + t.Fatalf("sync htpasswd: %v", err) + } + + path := HtpasswdPath(cfg, 3) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read htpasswd: %v", err) + } + if !strings.HasPrefix(string(data), "admin:$2y$10$") { + t.Fatalf("unexpected htpasswd content: %q", data) + } + if runtime.GOOS == "linux" { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat htpasswd: %v", err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("expected htpasswd mode 0644, got %o", info.Mode().Perm()) + } + } + + stripped := rules[0].Security.ForAPI() + rules[0].Security = stripped + if rules[0].Security.BasicAuthEnabled() { + t.Fatal("ForAPI should strip password hash for API responses") + } + if err := SyncHtpasswdFiles(cfg, rules); err != nil { + t.Fatalf("sync stripped htpasswd: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected htpasswd removed when hash stripped, stat err=%v", err) + } +} diff --git a/app-src/internal/nginx/nginx.go b/app-src/internal/nginx/nginx.go new file mode 100644 index 0000000..86a62fb --- /dev/null +++ b/app-src/internal/nginx/nginx.go @@ -0,0 +1,426 @@ +package nginx + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/fsutil" + "github.com/havline/havline/internal/proxy" +) + +const ( + nginxCmdTimeout = 8 * time.Second + nginxStartWait = 5 * time.Second + nginxTerminateWait = 3 * time.Second +) + +type Manager struct { + cfg config.Config + logger *slog.Logger + opts GenerateOptions +} + +type ApplyResult struct { + Reloaded bool + Message string +} + +func NewManager(cfg config.Config, logger *slog.Logger) *Manager { + return &Manager{cfg: cfg, logger: logger} +} + +func (m *Manager) SetGenerateOptions(opts GenerateOptions) { + m.opts = opts +} + +func (m *Manager) CurrentOptions() GenerateOptions { + return m.opts +} + +func (m *Manager) EnsureDirs() error { + for _, dir := range []string{m.cfg.NginxDir(), m.cfg.LogsDir(), m.cfg.CertsDir()} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + workDirs := []string{ + "logs", + "client_body_temp", + "proxy_temp", + "fastcgi_temp", + "uwsgi_temp", + "scgi_temp", + filepath.Join("temp", "client_body_temp"), + filepath.Join("temp", "proxy_temp"), + filepath.Join("temp", "fastcgi_temp"), + filepath.Join("temp", "uwsgi_temp"), + filepath.Join("temp", "scgi_temp"), + } + for _, name := range workDirs { + if err := os.MkdirAll(filepath.Join(m.cfg.NginxDir(), name), 0o755); err != nil { + return err + } + } + return nil +} + +func (m *Manager) nginxGlobalArgs() []string { + prefix := absNginxPath(m.cfg.NginxDir()) + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + errorLog := absNginxPath(filepath.Join(m.cfg.LogsDir(), "error.log")) + return []string{"-p", prefix, "-e", errorLog} +} + +func (m *Manager) available() bool { + _, err := exec.LookPath(m.cfg.NginxBin) + return err == nil +} + +func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSource) (ApplyResult, error) { + if err := m.EnsureDirs(); err != nil { + return ApplyResult{}, err + } + + if err := EnsureErrorPages(m.cfg); err != nil { + return ApplyResult{}, err + } + if err := EnsureCustomDirs(m.cfg); err != nil { + return ApplyResult{}, err + } + if err := SyncHtpasswdFiles(m.cfg, rules); err != nil { + return ApplyResult{}, err + } + + opts := m.opts + // Path override is only for pre-rename validation during China CIDR updates. + opts.ChinaCIDRPathOverride = "" + opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg) + + content, err := Generate(m.cfg, rules, certs, opts) + if err != nil { + return ApplyResult{}, err + } + + tmpPath := m.cfg.NginxConfigPath() + ".tmp" + if err := fsutil.WriteFileAtomic(tmpPath, []byte(content), 0o644); err != nil { + return ApplyResult{}, err + } + + if err := ValidateConfigSyntax(content); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + if m.available() { + if err := m.validate(ctx, tmpPath); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + } else { + m.logger.Warn("nginx binary not found, applied syntax-only validation", "bin", m.cfg.NginxBin) + } + + currentPath := m.cfg.NginxConfigPath() + // 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),隐藏的 .versions 目录保留最近 10 份(供版本对比与回滚) + if err := fsutil.Backup(currentPath); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + + if err := os.Rename(tmpPath, currentPath); err != nil { + _ = os.Remove(tmpPath) + return ApplyResult{}, err + } + + return m.activate(ctx) +} + +// activate 让磁盘上的主配置在 nginx 里生效:未运行则启动,运行中优先 reload、失败再重启。 +// Apply 与回滚共用这段,保证「写回磁盘」与「让它生效」在两条路径上行为一致。 +func (m *Manager) activate(ctx context.Context) (ApplyResult, error) { + if !m.available() { + return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil + } + + running, err := m.isRunning() + if err != nil { + return ApplyResult{}, err + } + + if !running { + if err := m.start(ctx); err != nil { + return ApplyResult{}, err + } + return ApplyResult{Reloaded: true, Message: "Nginx 已启动"}, nil + } + + if err := m.reload(ctx); err != nil { + m.logger.Warn("nginx reload failed, trying restart", "error", err) + if err := m.forceRestart(ctx); err != nil { + return ApplyResult{}, err + } + return ApplyResult{Reloaded: true, Message: "Nginx 已重新启动"}, nil + } + return ApplyResult{Reloaded: true, Message: "Nginx 已重载"}, nil +} + +func (m *Manager) ValidateOnly(ctx context.Context, rules []proxy.Rule, certs []CertSource) error { + return m.ValidateOnlyWithOptions(ctx, rules, certs, m.opts) +} + +func (m *Manager) ValidateOnlyWithOptions(ctx context.Context, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) error { + if opts.ChinaCIDRPathOverride != "" { + opts.ChinaCIDRAvailable = true + } else { + opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg) + } + content, err := Generate(m.cfg, rules, certs, opts) + if err != nil { + return err + } + if err := ValidateConfigSyntax(content); err != nil { + return err + } + if !m.available() { + m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin) + return nil + } + tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf") + if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil { + return err + } + defer os.Remove(tmpPath) + return m.validate(ctx, tmpPath) +} + +func (m *Manager) validate(ctx context.Context, configPath string) error { + tmpErr := filepath.Join(m.cfg.NginxDir(), "validate.error.log") + args := []string{"-e", absNginxPath(tmpErr), "-t", "-c", configPath} + err := m.runNginx(ctx, args, "Nginx 配置校验失败") + _ = os.Remove(tmpErr) + return err +} + +func (m *Manager) start(ctx context.Context) error { + running, err := m.isRunning() + if err != nil { + return err + } + if running { + return m.reload(ctx) + } + if m.trySilentReload(ctx) { + m.logger.Info("nginx reloaded existing master") + return nil + } + prepareStartPlatform(m, ctx) + if err := m.runNginxStart(ctx, []string{"-c", m.cfg.NginxConfigPath()}, "Nginx 启动失败"); err != nil { + if strings.Contains(err.Error(), "conflicting server name") && m.trySilentReload(ctx) { + m.logger.Warn("nginx start skipped duplicate master, reloaded instead") + return nil + } + return err + } + m.logger.Info("nginx started") + return nil +} + +func (m *Manager) trySilentReload(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout) + defer cancel() + + fullArgs := append(m.nginxGlobalArgs(), "-c", m.cfg.NginxConfigPath(), "-s", "reload") + cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...) + return cmd.Run() == nil +} + +func (m *Manager) reload(ctx context.Context) error { + pid, err := readPIDFile(m.cfg.NginxPIDFile) + if err == nil && isPIDAlive(pid) { + if err := reloadProcess(pid); err == nil { + m.logger.Info("nginx reloaded") + return nil + } + m.logger.Warn("nginx signal reload failed, falling back to cli", "pid", pid, "error", err) + } else if err != nil && !os.IsNotExist(err) { + removePIDFile(m.cfg.NginxPIDFile) + } + + if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "reload"}, "Nginx 重载失败"); err != nil { + return err + } + m.logger.Info("nginx reloaded") + return nil +} + +func (m *Manager) forceRestart(ctx context.Context) error { + m.terminateMaster() + removePIDFile(m.cfg.NginxPIDFile) + prepareStartPlatform(m, ctx) + return m.start(ctx) +} + +func (m *Manager) terminateMaster() { + pid, err := readPIDFile(m.cfg.NginxPIDFile) + if err != nil || !isPIDAlive(pid) { + return + } + _ = terminateProcess(pid, syscall.SIGTERM) + waitProcessExit(pid, nginxTerminateWait) + if isPIDAlive(pid) { + _ = terminateProcess(pid, syscall.SIGKILL) + waitProcessExit(pid, time.Second) + } +} + +func (m *Manager) Stop(ctx context.Context) error { + if !m.isRunningQuick() { + return nil + } + if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "Nginx 停止失败"); err != nil { + return err + } + m.logger.Info("nginx stopped") + return nil +} + +func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error { + ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout) + defer cancel() + + fullArgs := append(m.nginxGlobalArgs(), args...) + cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + if ctx.Err() == context.DeadlineExceeded { + msg = "操作超时" + } + m.logger.Error("nginx command failed", "args", strings.Join(fullArgs, " "), "error", msg) + return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg)) + } + return nil +} + +// runNginxStart launches nginx without waiting for the master process to exit. +// On Windows the master stays in the foreground and would block cmd.Run(). +func (m *Manager) runNginxStart(ctx context.Context, args []string, prefix string) error { + fullArgs := append(m.nginxGlobalArgs(), args...) + cmd := exec.Command(m.cfg.NginxBin, fullArgs...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s:%s", prefix, err.Error()) + } + + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + + deadline := time.Now().Add(nginxStartWait) + for time.Now().Before(deadline) { + if running, err := m.isRunning(); err == nil && running { + return nil + } + select { + case err := <-waitDone: + if running, checkErr := m.isRunning(); checkErr == nil && running { + return nil + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" && err != nil { + msg = err.Error() + } + if msg == "" { + msg = "进程已退出" + } + m.logger.Error("nginx start failed", "args", strings.Join(fullArgs, " "), "error", msg) + return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg)) + default: + } + if ctx.Err() != nil { + return fmt.Errorf("%s:%s", prefix, "操作超时") + } + time.Sleep(100 * time.Millisecond) + } + if running, err := m.isRunning(); err == nil && running { + return nil + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = "未检测到运行中的进程" + } + return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg)) +} + +func (m *Manager) isRunning() (bool, error) { + pid, err := readPIDFile(m.cfg.NginxPIDFile) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + removePIDFile(m.cfg.NginxPIDFile) + return false, nil + } + if !isPIDAlive(pid) { + removePIDFile(m.cfg.NginxPIDFile) + return false, nil + } + return true, nil +} + +func (m *Manager) isRunningQuick() bool { + ok, _ := m.isRunning() + return ok +} + +func (m *Manager) ValidateContent(ctx context.Context, content string) error { + if err := ValidateConfigSyntax(content); err != nil { + return err + } + if !m.available() { + m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin) + return nil + } + tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf") + if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil { + return err + } + defer os.Remove(tmpPath) + return m.validate(ctx, tmpPath) +} + +func ChinaCIDRExists(cfg config.Config) bool { + return chinaCIDRExists(cfg) +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func sanitizeNginxError(msg string) string { + msg = strings.ReplaceAll(msg, "\r\n", " ") + msg = strings.ReplaceAll(msg, "\n", " ") + return strings.TrimSpace(msg) +} diff --git a/app-src/internal/nginx/nginx_integration_test.go b/app-src/internal/nginx/nginx_integration_test.go new file mode 100644 index 0000000..fd58adb --- /dev/null +++ b/app-src/internal/nginx/nginx_integration_test.go @@ -0,0 +1,58 @@ +package nginx + +import ( + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestApplyWithNginxIfAvailable(t *testing.T) { + if _, err := exec.LookPath("nginx"); err != nil { + t.Skip("nginx not installed") + } + + dir := t.TempDir() + mimePath := filepath.Join(dir, "mime.types") + if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatalf("write mime.types: %v", err) + } + + cfg := config.Config{ + DataDir: dir, + NginxBin: "nginx", + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: mimePath, + } + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + mgr := NewManager(cfg, logger) + + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://127.0.0.1:65535", + ListenPort: 18080, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: false, + HTTPRedirect: false, + Enabled: true, + }} + + if _, err := mgr.Apply(context.Background(), rules, nil); err != nil { + t.Fatalf("apply failed: %v", err) + } + defer mgr.Stop(context.Background()) + + tmp := filepath.Join(dir, "nginx", "bad.conf") + if err := os.WriteFile(tmp, []byte("this is not valid nginx config"), 0o644); err != nil { + t.Fatalf("write bad config: %v", err) + } + if err := mgr.validate(context.Background(), tmp); err == nil { + t.Fatal("expected invalid config to fail validation") + } +} diff --git a/app-src/internal/nginx/nginx_unavailable_test.go b/app-src/internal/nginx/nginx_unavailable_test.go new file mode 100644 index 0000000..aafcaa0 --- /dev/null +++ b/app-src/internal/nginx/nginx_unavailable_test.go @@ -0,0 +1,50 @@ +package nginx + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/proxy" +) + +func TestApplyWithoutNginxBinary(t *testing.T) { + dir := t.TempDir() + mimePath := filepath.Join(dir, "mime.types") + if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil { + t.Fatalf("write mime.types: %v", err) + } + + cfg := config.Config{ + DataDir: dir, + NginxBin: "havline-missing-nginx-binary", + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: mimePath, + } + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + mgr := NewManager(cfg, logger) + + rules := []proxy.Rule{{ + ID: 1, + Upstream: "http://127.0.0.1:65535", + ListenPort: 8011, + ListenIPv4: true, + Hosts: []proxy.Host{{Hostname: "nas.example.com"}}, + HTTPSEnabled: false, + Enabled: true, + }} + + result, err := mgr.Apply(context.Background(), rules, nil) + if err != nil { + t.Fatalf("apply failed: %v", err) + } + if result.Message == "" { + t.Fatal("expected skip message") + } + if _, err := os.Stat(cfg.NginxConfigPath()); err != nil { + t.Fatalf("config not written: %v", err) + } +} diff --git a/app-src/internal/nginx/paths.go b/app-src/internal/nginx/paths.go new file mode 100644 index 0000000..8e515d1 --- /dev/null +++ b/app-src/internal/nginx/paths.go @@ -0,0 +1,14 @@ +package nginx + +import "path/filepath" + +func absNginxPath(path string) string { + if path == "" { + return "" + } + abs, err := filepath.Abs(path) + if err != nil { + return filepath.ToSlash(path) + } + return filepath.ToSlash(abs) +} diff --git a/app-src/internal/nginx/pid.go b/app-src/internal/nginx/pid.go new file mode 100644 index 0000000..30176af --- /dev/null +++ b/app-src/internal/nginx/pid.go @@ -0,0 +1,39 @@ +package nginx + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +func readPIDFile(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + pidStr := strings.TrimSpace(string(data)) + if pidStr == "" { + return 0, fmt.Errorf("empty pid file") + } + pid, err := strconv.Atoi(pidStr) + if err != nil || pid <= 0 { + return 0, fmt.Errorf("invalid pid: %q", pidStr) + } + return pid, nil +} + +func removePIDFile(path string) { + _ = os.Remove(path) +} + +func waitProcessExit(pid int, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !isPIDAlive(pid) { + return + } + time.Sleep(100 * time.Millisecond) + } +} diff --git a/app-src/internal/nginx/pid_alive_test.go b/app-src/internal/nginx/pid_alive_test.go new file mode 100644 index 0000000..4b62565 --- /dev/null +++ b/app-src/internal/nginx/pid_alive_test.go @@ -0,0 +1,13 @@ +package nginx + +import ( + "os" + "testing" +) + +func TestIsPIDAliveCurrentProcess(t *testing.T) { + pid := os.Getpid() + if !isPIDAlive(pid) { + t.Fatalf("expected current pid %d to be alive", pid) + } +} diff --git a/app-src/internal/nginx/pid_export.go b/app-src/internal/nginx/pid_export.go new file mode 100644 index 0000000..0e01587 --- /dev/null +++ b/app-src/internal/nginx/pid_export.go @@ -0,0 +1,11 @@ +package nginx + +// ReadPID reads a PID from the given pid file. +func ReadPID(path string) (int, error) { + return readPIDFile(path) +} + +// IsPIDAlive reports whether the process with the given PID is running. +func IsPIDAlive(pid int) bool { + return isPIDAlive(pid) +} diff --git a/app-src/internal/nginx/pid_other.go b/app-src/internal/nginx/pid_other.go new file mode 100644 index 0000000..46a8716 --- /dev/null +++ b/app-src/internal/nginx/pid_other.go @@ -0,0 +1,25 @@ +//go:build !linux && !darwin && !freebsd && !windows + +package nginx + +import ( + "fmt" + "os" + "syscall" +) + +func reloadProcess(pid int) error { + return fmt.Errorf("signal reload unsupported") +} + +func terminateProcess(pid int, sig syscall.Signal) error { + return fmt.Errorf("signal terminate unsupported") +} + +func isPIDAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/app-src/internal/nginx/pid_test.go b/app-src/internal/nginx/pid_test.go new file mode 100644 index 0000000..cd7fa7b --- /dev/null +++ b/app-src/internal/nginx/pid_test.go @@ -0,0 +1,53 @@ +package nginx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/havline/havline/internal/config" +) + +func TestReadPIDFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nginx.pid") + + if _, err := readPIDFile(path); !os.IsNotExist(err) { + t.Fatalf("expected not exist, got %v", err) + } + + if err := os.WriteFile(path, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if _, err := readPIDFile(path); err == nil { + t.Fatal("expected error for empty pid file") + } + + if err := os.WriteFile(path, []byte("12345\n"), 0o644); err != nil { + t.Fatal(err) + } + pid, err := readPIDFile(path) + if err != nil || pid != 12345 { + t.Fatalf("expected pid 12345, got %d err=%v", pid, err) + } +} + +func TestIsRunningStalePIDFile(t *testing.T) { + dir := t.TempDir() + pidPath := filepath.Join(dir, "nginx.pid") + if err := os.WriteFile(pidPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + mgr := &Manager{cfg: config.Config{NginxPIDFile: pidPath}} + running, err := mgr.isRunning() + if err != nil { + t.Fatal(err) + } + if running { + t.Fatal("expected stale empty pid file to be treated as not running") + } + if _, err := os.Stat(pidPath); !os.IsNotExist(err) { + t.Fatal("expected stale pid file to be removed") + } +} diff --git a/app-src/internal/nginx/pid_unix.go b/app-src/internal/nginx/pid_unix.go new file mode 100644 index 0000000..505ca38 --- /dev/null +++ b/app-src/internal/nginx/pid_unix.go @@ -0,0 +1,24 @@ +//go:build linux || darwin || freebsd + +package nginx + +import ( + "os" + "syscall" +) + +func reloadProcess(pid int) error { + return syscall.Kill(pid, syscall.SIGHUP) +} + +func terminateProcess(pid int, sig syscall.Signal) error { + return syscall.Kill(pid, sig) +} + +func isPIDAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/app-src/internal/nginx/pid_windows.go b/app-src/internal/nginx/pid_windows.go new file mode 100644 index 0000000..9fc8bb2 --- /dev/null +++ b/app-src/internal/nginx/pid_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package nginx + +import ( + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "golang.org/x/sys/windows" +) + +const stillActive = 259 + +func reloadProcess(pid int) error { + return fmt.Errorf("signal reload unsupported on windows") +} + +func terminateProcess(pid int, sig syscall.Signal) error { + args := []string{"/PID", strconv.Itoa(pid)} + if sig == syscall.SIGKILL { + args = append([]string{"/F"}, args...) + } + cmd := exec.Command("taskkill", args...) + if err := cmd.Run(); err != nil { + return fmt.Errorf("taskkill: %w", err) + } + return nil +} + +func isPIDAlive(pid int) bool { + if pid <= 0 { + return false + } + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(handle) + + var exitCode uint32 + if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil { + return false + } + return exitCode == stillActive +} + +func stopOrphanMasters(nginxDir string) { + marker := strings.ToLower(filepath.ToSlash(absNginxPath(nginxDir))) + marker = strings.ReplaceAll(marker, "'", "''") + script := fmt.Sprintf( + `Get-CimInstance Win32_Process -Filter "Name='nginx.exe'" | Where-Object { $_.CommandLine -like '*%s*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`, + marker, + ) + _ = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Run() +} diff --git a/app-src/internal/nginx/realip.go b/app-src/internal/nginx/realip.go new file mode 100644 index 0000000..967f3c6 --- /dev/null +++ b/app-src/internal/nginx/realip.go @@ -0,0 +1,99 @@ +package nginx + +import ( + "encoding/json" + "strings" +) + +type TrustedProxyConfig struct { + Enabled bool `json:"enabled"` + Preset string `json:"preset"` // "", "cloudflare", "custom" + CIDRs []string `json:"cidrs,omitempty"` + Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For + Recursive bool `json:"recursive,omitempty"` +} + +var cloudflareIPv4 = []string{ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", +} + +var cloudflareIPv6 = []string{ + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", +} + +func ParseTrustedProxyJSON(raw string) TrustedProxyConfig { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "{}" { + return TrustedProxyConfig{} + } + var cfg TrustedProxyConfig + _ = json.Unmarshal([]byte(raw), &cfg) + return cfg +} + +func (c TrustedProxyConfig) effectiveCIDRs() []string { + if !c.Enabled { + return nil + } + switch c.Preset { + case "cloudflare": + out := make([]string, 0, len(cloudflareIPv4)+len(cloudflareIPv6)) + out = append(out, cloudflareIPv4...) + out = append(out, cloudflareIPv6...) + return out + default: + return c.CIDRs + } +} + +func (c TrustedProxyConfig) effectiveHeader() string { + if c.Header != "" { + return c.Header + } + if c.Preset == "cloudflare" { + return "CF-Connecting-IP" + } + return "X-Forwarded-For" +} + +func writeRealIPDirectives(b *strings.Builder, cfg TrustedProxyConfig) { + writeRealIPComment(b, cfg) + cidrs := cfg.effectiveCIDRs() + if len(cidrs) == 0 { + b.WriteString(" map $remote_addr $havline_client_ip {\n") + b.WriteString(" default $remote_addr;\n") + b.WriteString(" }\n\n") + return + } + for _, cidr := range cidrs { + b.WriteString(" set_real_ip_from " + cidr + ";\n") + } + header := cfg.effectiveHeader() + b.WriteString(" real_ip_header " + header + ";\n") + if cfg.Recursive || header == "X-Forwarded-For" { + b.WriteString(" real_ip_recursive on;\n") + } + b.WriteString("\n map $remote_addr $havline_client_ip {\n") + b.WriteString(" default $remote_addr;\n") + b.WriteString(" }\n\n") +} diff --git a/app-src/internal/nginx/security.go b/app-src/internal/nginx/security.go new file mode 100644 index 0000000..5df6fcf --- /dev/null +++ b/app-src/internal/nginx/security.go @@ -0,0 +1,253 @@ +package nginx + +import ( + "fmt" + "os" + "strings" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/nginxtmpl" + "github.com/havline/havline/internal/proxy" +) + +type GenerateOptions struct { + TrustedProxy TrustedProxyConfig + GlobalIPBlacklist []string + GlobalIPWhitelist []string + ChinaCIDRAvailable bool + ChinaCIDRPathOverride string + GlobalCustomOverride string + GlobalCustomPathOverride string + RuleCustomOverrides map[int64]string +} + +func writeGlobalAllowList(b *strings.Builder, cidrs []string) { + merged := proxy.ChinaBypassCIDRs(cidrs) + if len(merged) == 0 { + return + } + writeGlobalAllowListComment(b) + for _, cidr := range merged { + b.WriteString(fmt.Sprintf(" allow %s;\n", cidr)) + } + b.WriteString("\n") +} + +func writeGlobalDenyList(b *strings.Builder, cidrs []string) { + if len(cidrs) == 0 { + return + } + writeGlobalDenyListComment(b) + for _, cidr := range cidrs { + b.WriteString(fmt.Sprintf(" deny %s;\n", cidr)) + } + b.WriteString("\n") +} + +func writeLimitZones(b *strings.Builder, rules []proxy.Rule) { + for _, rule := range rules { + if !rule.Enabled { + continue + } + if rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled() { + writeLimitZoneComments(b, rule) + } + if rule.Security.RateLimitEnabled() { + zone := fmt.Sprintf("havline_rule_%d_req", rule.ID) + writeIndentedZone(b, nginxtmpl.RateLimitZone(zone, rule.Security.RateLimit.Rate)) + } + if rule.Security.ConnLimitEnabled() { + writeIndentedZone(b, nginxtmpl.ConnLimitZone(fmt.Sprintf("havline_rule_%d_conn", rule.ID))) + } + } + if hasLimitZones(rules) { + b.WriteString("\n") + } +} + +func writeIndentedZone(b *strings.Builder, line string) { + if strings.TrimSpace(line) == "" { + return + } + b.WriteString(" " + line) +} + +func hasLimitZones(rules []proxy.Rule) bool { + for _, rule := range rules { + if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) { + return true + } + } + return false +} + +func writePrivateIPGeo(b *strings.Builder) { + b.WriteString(` geo $havline_client_ip $havline_is_private { + default 0; + 10.0.0.0/8 1; + 127.0.0.0/8 1; + 172.16.0.0/12 1; + 192.168.0.0/16 1; + ::1/128 1; + fc00::/7 1; + } + +`) +} + +func writeChinaGeoBlocks(b *strings.Builder, cfg config.Config, opts GenerateOptions, rules []proxy.Rule) { + needsChina := false + for _, rule := range rules { + if rule.Enabled && rule.Security.ChinaOnly { + needsChina = true + break + } + } + if !needsChina { + return + } + writeChinaGeoComment(b) + writePrivateIPGeo(b) + if !opts.ChinaCIDRAvailable { + return + } + cidrPath := absNginxPath(cfg.ChinaCIDRPath()) + if opts.ChinaCIDRPathOverride != "" { + cidrPath = absNginxPath(opts.ChinaCIDRPathOverride) + } + b.WriteString(fmt.Sprintf(` geo $havline_client_ip $havline_is_china { + default 0; + include %s; + } + +`, cidrPath)) +} + +func writeChinaOnlyBypassGeo(b *strings.Builder, rules []proxy.Rule, opts GenerateOptions) { + if !opts.ChinaCIDRAvailable { + return + } + for _, rule := range rules { + if !rule.Enabled || !rule.Security.ChinaOnly { + continue + } + sec := rule.Security.Normalize() + cidrs := chinaBypassCIDRs(sec, opts.GlobalIPWhitelist) + writeChinaBypassComment(b, rule) + b.WriteString(fmt.Sprintf(" geo $havline_client_ip $havline_rule_%d_china_bypass {\n", rule.ID)) + b.WriteString(" default 0;\n") + for _, cidr := range cidrs { + b.WriteString(fmt.Sprintf(" %s 1;\n", cidr)) + } + b.WriteString(" }\n\n") + } +} + +func chinaOnlyCheckDirectives(rule proxy.Rule, opts GenerateOptions) string { + sec := rule.Security.Normalize() + if !sec.ChinaOnly { + return "" + } + if !opts.ChinaCIDRAvailable { + return ` if ($havline_is_private = 0) { + return 403; + } +` + } + var b strings.Builder + b.WriteString(` set $havline_china_block 0; + if ($havline_is_china = 0) { + set $havline_china_block 1; + } + if ($havline_is_private = 1) { + set $havline_china_block 0; + } +`) + b.WriteString(fmt.Sprintf(` if ($havline_rule_%d_china_bypass = 1) { + set $havline_china_block 0; + } +`, rule.ID)) + b.WriteString(` if ($havline_china_block = 1) { + return 403; + } +`) + return b.String() +} + +func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) { + if !https || !sec.SecurityHeaders { + return + } + writeSecurityHeadersComment(b) + b.WriteString(nginxtmpl.ServerSecurityHeaders(nginxtmpl.SecurityHeaders{Enabled: true, HTTPS: true})) + b.WriteString("\n") +} + +func writeErrorPages(b *strings.Builder, cfg config.Config) { + errorsDir := absNginxPath(cfg.ErrorsDir()) + writeErrorPagesComment(b) + // HTML is served via internal error_page redirect; images are fetched by the browser + // as separate requests and must not use the internal-only location. + b.WriteString(` error_page 403 /havline-errors/403.html; + error_page 404 /havline-errors/404.html; + error_page 429 /havline-errors/429.html; + error_page 500 /havline-errors/500.html; + error_page 502 503 /havline-errors/503.html; + + location = /havline-errors/error.png { + alias ` + errorsDir + `/error.png; + } + location = /havline-errors/429.png { + alias ` + errorsDir + `/429.png; + } + + location ^~ /havline-errors/ { + internal; + alias ` + errorsDir + `/; + } + +`) +} + +func writeLocationSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) { + sec := rule.Security.Normalize() + writeLocationSecurityComments(b, rule, opts) + + acl := nginxtmpl.AccessControl{ + Deny: sec.IPBlacklist, + ChinaDeny: chinaOnlyCheckDirectives(rule, opts), + } + if sec.WhitelistEnabled() { + acl.Allow = sec.IPWhitelist + } + b.WriteString(nginxtmpl.AccessDirectives(acl, " ")) + + limits := nginxtmpl.LimitConfig{Zone: fmt.Sprintf("havline_rule_%d", rule.ID)} + if sec.ConnLimitEnabled() { + limits.Conn = sec.ConnLimit.Max + } + if sec.RateLimitEnabled() { + limits.Rate = sec.RateLimit.Rate + limits.Burst = sec.RateLimit.Burst + } + b.WriteString(nginxtmpl.LimitDirectives(limits, " ")) + + if sec.BasicAuthEnabled() { + path := absNginxPath(HtpasswdPath(cfg, rule.ID)) + b.WriteString(fmt.Sprintf(` auth_basic "Restricted"; + auth_basic_user_file %s; +`, path)) + } +} + +func chinaBypassCIDRs(sec proxy.SecurityConfig, globalWhitelist []string) []string { + extra := make([]string, 0, len(globalWhitelist)+len(sec.IPWhitelist)) + extra = append(extra, globalWhitelist...) + extra = append(extra, sec.IPWhitelist...) + return proxy.ChinaBypassCIDRs(extra) +} + +func chinaCIDRExists(cfg config.Config) bool { + info, err := os.Stat(cfg.ChinaCIDRPath()) + return err == nil && !info.IsDir() && info.Size() > 0 +} diff --git a/app-src/internal/nginx/syntax.go b/app-src/internal/nginx/syntax.go new file mode 100644 index 0000000..a60e5f9 --- /dev/null +++ b/app-src/internal/nginx/syntax.go @@ -0,0 +1,68 @@ +package nginx + +import ( + "fmt" + "strings" +) + +// ValidateConfigSyntax performs a lightweight syntax check when nginx -t is unavailable. +func ValidateConfigSyntax(content string) error { + depth := 0 + for i, raw := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + line := stripInlineComment(trimmed) + if line == "" { + continue + } + depth += strings.Count(line, "{") - strings.Count(line, "}") + if depth < 0 { + return fmt.Errorf("第 %d 行:多余的 `}`", i+1) + } + if err := validateConfigLine(line, i+1); err != nil { + return err + } + } + if depth != 0 { + return fmt.Errorf("配置块大括号未配对") + } + return nil +} + +func stripInlineComment(line string) string { + inSingle := false + inDouble := false + for i := 0; i < len(line); i++ { + ch := line[i] + switch ch { + case '\'': + if !inDouble { + inSingle = !inSingle + } + case '"': + if !inSingle { + inDouble = !inDouble + } + case '#': + if !inSingle && !inDouble { + return strings.TrimSpace(line[:i]) + } + } + } + return strings.TrimSpace(line) +} + +func validateConfigLine(line string, lineNo int) error { + if line == "}" || line == "{" { + return nil + } + if strings.HasSuffix(line, "{") { + return nil + } + if strings.HasSuffix(line, ";") { + return nil + } + return fmt.Errorf("第 %d 行:无效的 Nginx 语法(应以 ; 或 { 结尾)", lineNo) +} diff --git a/app-src/internal/nginx/syntax_test.go b/app-src/internal/nginx/syntax_test.go new file mode 100644 index 0000000..b37d3b6 --- /dev/null +++ b/app-src/internal/nginx/syntax_test.go @@ -0,0 +1,44 @@ +package nginx + +import "testing" + +func TestValidateConfigSyntax(t *testing.T) { + valid := `server { + listen 80; + location / { + proxy_pass http://127.0.0.1:8080; + } +}` + if err := ValidateConfigSyntax(valid); err != nil { + t.Fatalf("expected valid config: %v", err) + } + + cases := []struct { + name string + content string + }{ + {"garbage line", "server {\n 1 1\n}\n"}, + {"missing semicolon", "server {\n listen 80\n}\n"}, + {"unbalanced brace", "server {\n listen 80;\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateConfigSyntax(tc.content); err == nil { + t.Fatalf("expected error for %q", tc.name) + } + }) + } + + t.Run("allows comment lines", func(t *testing.T) { + content := `# 规则:测试 +server { + # 监听 80 + listen 80; +} +# HTTP 跳转 +` + if err := ValidateConfigSyntax(content); err != nil { + t.Fatalf("expected comments to pass: %v", err) + } + }) +} diff --git a/app-src/internal/nginx/versions.go b/app-src/internal/nginx/versions.go new file mode 100644 index 0000000..1307338 --- /dev/null +++ b/app-src/internal/nginx/versions.go @@ -0,0 +1,104 @@ +package nginx + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/havline/havline/internal/fsutil" +) + +// maxConfigVersions 主配置保留的版本份数。Apply 每次保存都会留一份,fsutil 会跳过内容相同的版本, +// 因此这里对应的是最近 10 次「配置真的变了」的改动。 +const maxConfigVersions = 10 + +// ConfigVersion 是主配置(nginx.conf)的一个历史版本。 +type ConfigVersion struct { + Name string `json:"name"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` +} + +// ReadConfig 读取当前主配置内容,供前端与历史版本做差异对比。 +func (m *Manager) ReadConfig() (string, error) { + data, err := os.ReadFile(m.cfg.NginxConfigPath()) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return string(data), nil +} + +// ListConfigVersions 列出主配置的历史版本(新的在前);从未写过配置时返回空列表。 +func (m *Manager) ListConfigVersions() ([]ConfigVersion, error) { + versions, err := fsutil.ListVersions(m.cfg.NginxConfigPath()) + if err != nil { + return nil, err + } + out := make([]ConfigVersion, 0, len(versions)) + for _, version := range versions { + out = append(out, ConfigVersion{Name: version.Name, Size: version.Size, CreatedAt: version.CreatedAt}) + } + return out, nil +} + +// ReadConfigVersion 读取某个历史版本的内容。 +func (m *Manager) ReadConfigVersion(name string) (string, error) { + data, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name) + if err != nil { + return "", err + } + return string(data), nil +} + +// RollbackConfigVersion 把主配置回退到指定版本。 +// +// 与 Apply 同构:先语法校验、再 nginx -t 校验,两关都过才写回并重载;校验失败保持当前配置不动。 +// 写回前把当前内容也留成一个版本,所以回退本身同样可以再回退。 +func (m *Manager) RollbackConfigVersion(ctx context.Context, name string) (ApplyResult, error) { + if name == "" { + return ApplyResult{}, fmt.Errorf("请选择要回滚的版本") + } + content, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name) + if err != nil { + return ApplyResult{}, err + } + if err := m.EnsureDirs(); err != nil { + return ApplyResult{}, err + } + if err := ValidateConfigSyntax(string(content)); err != nil { + return ApplyResult{}, fmt.Errorf("该版本无法通过语法校验,已保持当前配置:%s", err) + } + if m.available() { + tmpPath := m.cfg.NginxConfigPath() + ".rollback.tmp" + if err := fsutil.WriteFileAtomic(tmpPath, content, 0o644); err != nil { + return ApplyResult{}, err + } + validateErr := m.validate(ctx, tmpPath) + _ = os.Remove(tmpPath) + if validateErr != nil { + return ApplyResult{}, fmt.Errorf("该版本无法通过 nginx 校验,已保持当前配置:%s", validateErr) + } + } + + currentPath := m.cfg.NginxConfigPath() + if err := fsutil.Backup(currentPath); err != nil { + return ApplyResult{}, err + } + if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil { + return ApplyResult{}, err + } + if err := fsutil.WriteFileAtomic(currentPath, content, 0o644); err != nil { + return ApplyResult{}, err + } + + result, err := m.activate(ctx) + if err != nil { + return ApplyResult{}, err + } + result.Message = "已回滚到所选版本," + result.Message + return result, nil +} diff --git a/app-src/internal/nginx/versions_test.go b/app-src/internal/nginx/versions_test.go new file mode 100644 index 0000000..ac3ffa3 --- /dev/null +++ b/app-src/internal/nginx/versions_test.go @@ -0,0 +1,112 @@ +package nginx + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/havline/havline/internal/config" + "github.com/havline/havline/internal/fsutil" +) + +func newVersionTestManager(t *testing.T) (*Manager, config.Config) { + t.Helper() + dir := t.TempDir() + cfg := config.Config{ + DataDir: dir, + NginxBin: "havline-nginx-not-installed", + NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"), + NginxMimeTypes: filepath.Join(dir, "mime.types"), + } + mgr := NewManager(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err := mgr.EnsureDirs(); err != nil { + t.Fatal(err) + } + return mgr, cfg +} + +func TestConfigVersionsListAndRead(t *testing.T) { + mgr, cfg := newVersionTestManager(t) + path := cfg.NginxConfigPath() + if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil { + t.Fatal(err) + } + versions, err := mgr.ListConfigVersions() + if err != nil || len(versions) != 1 { + t.Fatalf("应列出 1 个版本:%v %v", versions, err) + } + content, err := mgr.ReadConfigVersion(versions[0].Name) + if err != nil || content != "worker_processes 1;\n" { + t.Fatalf("版本内容不符:%q %v", content, err) + } +} + +func TestRollbackConfigVersionRejectsTraversal(t *testing.T) { + mgr, _ := newVersionTestManager(t) + if _, err := mgr.RollbackConfigVersion(context.Background(), "../nginx.conf"); err == nil { + t.Fatal("带路径上跳的版本名应被拒绝") + } +} + +func TestRollbackConfigVersionKeepsCurrentOnSyntaxError(t *testing.T) { + mgr, cfg := newVersionTestManager(t) + path := cfg.NginxConfigPath() + if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + dir := fsutil.VersionsDir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + badName := "20260101T000000.000-bad.conf" + if err := os.WriteFile(filepath.Join(dir, badName), []byte("server {\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := mgr.RollbackConfigVersion(context.Background(), badName); err == nil { + t.Fatal("语法错误的版本应拒绝回滚") + } + current, err := os.ReadFile(path) + if err != nil || string(current) != "worker_processes 1;\n" { + t.Fatalf("校验失败时不应覆盖当前配置:%q %v", current, err) + } +} + +func TestRollbackConfigVersionRestoresAndKeepsCurrent(t *testing.T) { + mgr, cfg := newVersionTestManager(t) + path := cfg.NginxConfigPath() + if err := os.WriteFile(path, []byte("worker_processes 1;\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := fsutil.BackupVersioned(path, maxConfigVersions); err != nil { + t.Fatal(err) + } + versions, err := mgr.ListConfigVersions() + if err != nil || len(versions) != 1 { + t.Fatalf("准备版本失败:%v %v", versions, err) + } + if err := os.WriteFile(path, []byte("worker_processes 2;\n"), 0o644); err != nil { + t.Fatal(err) + } + result, err := mgr.RollbackConfigVersion(context.Background(), versions[0].Name) + if err != nil { + t.Fatalf("回滚失败: %v", err) + } + if !strings.Contains(result.Message, "已回滚") { + t.Fatalf("回滚结果缺少提示:%q", result.Message) + } + current, err := os.ReadFile(path) + if err != nil || string(current) != "worker_processes 1;\n" { + t.Fatalf("回滚后内容不符:%q %v", current, err) + } + after, err := mgr.ListConfigVersions() + if err != nil || len(after) < 2 { + t.Fatalf("回滚前的当前配置也应留存为版本:%v %v", after, err) + } +} diff --git a/app-src/internal/nginxtmpl/nginxtmpl.go b/app-src/internal/nginxtmpl/nginxtmpl.go new file mode 100644 index 0000000..03bcfe2 --- /dev/null +++ b/app-src/internal/nginxtmpl/nginxtmpl.go @@ -0,0 +1,176 @@ +// Package nginxtmpl 提供本机 Nginx 与公网 Agent 共用的纯文本模板函数。 +// +// 这里只做字符串渲染,不读文件、不执行进程、不依赖业务模型, +// 因此 internal/nginx 与 internal/agent 都可以安全导入而不产生循环依赖。 +package nginxtmpl + +import ( + "fmt" + "strings" +) + +// DefaultIndent 是 Nginx server 块内指令的默认缩进。 +const DefaultIndent = " " + +// TLSConfig 描述证书与 TLS 协议版本偏好。 +type TLSConfig struct { + CertPath string + KeyPath string + TLS13Only bool +} + +// SecurityHeaders 描述安全响应头;HTTPS 为 false 时不输出 HSTS。 +type SecurityHeaders struct { + Enabled bool + HTTPS bool + HSTSMaxAgeSeconds int +} + +// AccessControl 描述 allow / deny 顺序;Allow 非空时末尾自动追加 deny all。 +// ChinaDeny 是可选的完整指令片段,插在 deny 黑名单之后、allow 白名单之前, +// 让两端共享顺序结构,同时保留各自的 geo 变量名。 +type AccessControl struct { + Allow []string + Deny []string + ChinaDeny string +} + +// LimitConfig 描述 location 级限流指令;Zone 是已合法化的 zone 前缀。 +type LimitConfig struct { + Zone string + Rate int + Burst int + Conn int +} + +// SSLDirectives 生成证书与 TLS 协议指令,缩进固定为 server 块内层级。 +func SSLDirectives(cfg TLSConfig) string { + var b strings.Builder + fmt.Fprintf(&b, "%sssl_certificate %s;\n", DefaultIndent, cfg.CertPath) + fmt.Fprintf(&b, "%sssl_certificate_key %s;\n", DefaultIndent, cfg.KeyPath) + if cfg.TLS13Only { + fmt.Fprintf(&b, "%sssl_protocols TLSv1.3;\n", DefaultIndent) + return b.String() + } + fmt.Fprintf(&b, "%sssl_protocols TLSv1.2 TLSv1.3;\n", DefaultIndent) + return b.String() +} + +// ServerSecurityHeaders 生成统一的安全响应头;HSTS 只在 HTTPS 且 Enabled 时输出。 +func ServerSecurityHeaders(cfg SecurityHeaders) string { + if !cfg.Enabled || !cfg.HTTPS { + return "" + } + maxAge := cfg.HSTSMaxAgeSeconds + if maxAge <= 0 { + maxAge = 31536000 + } + var b strings.Builder + fmt.Fprintf(&b, "%sadd_header Strict-Transport-Security \"max-age=%d; includeSubDomains\" always;\n", DefaultIndent, maxAge) + fmt.Fprintf(&b, "%sadd_header X-Content-Type-Options \"nosniff\" always;\n", DefaultIndent) + fmt.Fprintf(&b, "%sadd_header X-Frame-Options \"SAMEORIGIN\" always;\n", DefaultIndent) + fmt.Fprintf(&b, "%sadd_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n", DefaultIndent) + return b.String() +} + +// AccessDirectives 生成 allow / deny:先 deny 黑名单,再 allow 白名单,最后 deny all。 +func AccessDirectives(cfg AccessControl, indent string) string { + var b strings.Builder + for _, ip := range cfg.Deny { + if ip = strings.TrimSpace(ip); ip != "" { + fmt.Fprintf(&b, "%sdeny %s;\n", indent, ip) + } + } + b.WriteString(strings.TrimRight(cfg.ChinaDeny, "\n")) + if strings.TrimSpace(cfg.ChinaDeny) != "" { + b.WriteString("\n") + } + allowCount := 0 + for _, ip := range cfg.Allow { + if ip = strings.TrimSpace(ip); ip != "" { + fmt.Fprintf(&b, "%sallow %s;\n", indent, ip) + allowCount++ + } + } + if allowCount > 0 { + fmt.Fprintf(&b, "%sdeny all;\n", indent) + } + return b.String() +} + +// LimitDirectives 生成 location 级限流指令:连接数在前,请求速率在后。 +func LimitDirectives(cfg LimitConfig, indent string) string { + zone := strings.TrimSpace(cfg.Zone) + if zone == "" { + return "" + } + var b strings.Builder + if cfg.Conn > 0 { + fmt.Fprintf(&b, "%slimit_conn %s_conn %d;\n", indent, zone, cfg.Conn) + } + if cfg.Rate > 0 { + if cfg.Burst > 0 { + fmt.Fprintf(&b, "%slimit_req zone=%s_req burst=%d nodelay;\n", indent, zone, cfg.Burst) + } else { + fmt.Fprintf(&b, "%slimit_req zone=%s_req nodelay;\n", indent, zone) + } + } + return b.String() +} + +// UpgradeHeaders 生成 WebSocket 代理头;connectionExpr 由调用方提供(map 变量或字面量)。 +// proxy_http_version 由调用方保留在原有位置,避免改变既有输出顺序。 +func UpgradeHeaders(indent, connectionExpr string) string { + connectionExpr = strings.TrimSpace(connectionExpr) + if connectionExpr == "" { + connectionExpr = "$connection_upgrade" + } + var b strings.Builder + fmt.Fprintf(&b, "%sproxy_set_header Upgrade $http_upgrade;\n", indent) + fmt.Fprintf(&b, "%sproxy_set_header Connection %s;\n", indent, connectionExpr) + return b.String() +} + +// RateLimitZone 生成 http 上下文的请求速率 zone 定义;zone 需包含完整后缀。 +func RateLimitZone(zone string, rate int) string { + zone = strings.TrimSpace(zone) + if zone == "" || rate <= 0 { + return "" + } + return fmt.Sprintf("limit_req_zone $binary_remote_addr zone=%s:10m rate=%dr/s;\n", zone, rate) +} + +// ConnLimitZone 生成 http 上下文的连接数 zone 定义;zone 需包含完整后缀。 +func ConnLimitZone(zone string) string { + zone = strings.TrimSpace(zone) + if zone == "" { + return "" + } + return fmt.Sprintf("limit_conn_zone $binary_remote_addr zone=%s:10m;\n", zone) +} + +// ZoneName 把前缀与片段合法化为 zone 名:只保留小写字母与数字,其余换成下划线。 +func ZoneName(prefix string, parts ...string) string { + var b strings.Builder + writeZonePart(&b, prefix) + for _, part := range parts { + if strings.TrimSpace(part) == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('_') + } + writeZonePart(&b, part) + } + return b.String() +} + +func writeZonePart(b *strings.Builder, part string) { + for _, r := range strings.ToLower(part) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + continue + } + b.WriteByte('_') + } +} diff --git a/app-src/internal/nginxtmpl/nginxtmpl_test.go b/app-src/internal/nginxtmpl/nginxtmpl_test.go new file mode 100644 index 0000000..19c95f2 --- /dev/null +++ b/app-src/internal/nginxtmpl/nginxtmpl_test.go @@ -0,0 +1,104 @@ +package nginxtmpl + +import ( + "strings" + "testing" +) + +func TestSSLDirectives(t *testing.T) { + base := SSLDirectives(TLSConfig{CertPath: "/certs/fullchain.pem", KeyPath: "/certs/privkey.pem"}) + for _, want := range []string{ + "ssl_certificate /certs/fullchain.pem;", + "ssl_certificate_key /certs/privkey.pem;", + "ssl_protocols TLSv1.2 TLSv1.3;", + } { + if !strings.Contains(base, want) { + t.Fatalf("默认 TLS 缺少 %q:%s", want, base) + } + } + only13 := SSLDirectives(TLSConfig{CertPath: "/c", KeyPath: "/k", TLS13Only: true}) + if !strings.Contains(only13, "ssl_protocols TLSv1.3;") || strings.Contains(only13, "TLSv1.2") { + t.Fatalf("TLS 1.3 only 输出不正确:%s", only13) + } +} + +func TestServerSecurityHeaders(t *testing.T) { + if got := ServerSecurityHeaders(SecurityHeaders{Enabled: true, HTTPS: false}); got != "" { + t.Fatalf("HTTP 不应输出 HSTS:%s", got) + } + if got := ServerSecurityHeaders(SecurityHeaders{Enabled: false, HTTPS: true}); got != "" { + t.Fatalf("关闭安全头时不应输出:%s", got) + } + got := ServerSecurityHeaders(SecurityHeaders{Enabled: true, HTTPS: true}) + for _, want := range []string{ + `Strict-Transport-Security "max-age=31536000; includeSubDomains"`, + `X-Content-Type-Options "nosniff"`, + `X-Frame-Options "SAMEORIGIN"`, + `Referrer-Policy "strict-origin-when-cross-origin"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("安全头缺少 %q:%s", want, got) + } + } +} + +func TestAccessDirectives(t *testing.T) { + got := AccessDirectives(AccessControl{ + Deny: []string{"8.8.8.8", " "}, + ChinaDeny: " if ($is_china = 0) { return 403; }\n", + Allow: []string{"192.168.1.0/24"}, + }, " ") + want := " deny 8.8.8.8;\n if ($is_china = 0) { return 403; }\n allow 192.168.1.0/24;\n deny all;\n" + if got != want { + t.Fatalf("AccessDirectives() = %q, 期望 %q", got, want) + } + if got := AccessDirectives(AccessControl{}, " "); got != "" { + t.Fatalf("空参数不应输出指令:%q", got) + } +} + +func TestLimitDirectives(t *testing.T) { + got := LimitDirectives(LimitConfig{Zone: "havline_rule_7", Rate: 10, Burst: 20, Conn: 30}, " ") + for _, want := range []string{ + "limit_conn havline_rule_7_conn 30;", + "limit_req zone=havline_rule_7_req burst=20 nodelay;", + } { + if !strings.Contains(got, want) { + t.Fatalf("限流指令缺少 %q:%s", want, got) + } + } + noBurst := LimitDirectives(LimitConfig{Zone: "z", Rate: 5}, " ") + if !strings.Contains(noBurst, "limit_req zone=z_req nodelay;") { + t.Fatalf("无 burst 时输出不正确:%s", noBurst) + } +} + +func TestZoneDirectives(t *testing.T) { + if got := RateLimitZone("havline_app_req", 10); got != "limit_req_zone $binary_remote_addr zone=havline_app_req:10m rate=10r/s;\n" { + t.Fatalf("RateLimitZone() = %q", got) + } + if got := ConnLimitZone("havline_app_conn"); got != "limit_conn_zone $binary_remote_addr zone=havline_app_conn:10m;\n" { + t.Fatalf("ConnLimitZone() = %q", got) + } + if got := RateLimitZone("", 10); got != "" { + t.Fatalf("空 zone 不应输出:%q", got) + } +} + +func TestZoneName(t *testing.T) { + if got := ZoneName("havline", "App.Example.com"); got != "havline_app_example_com" { + t.Fatalf("ZoneName() = %q, 期望 havline_app_example_com", got) + } +} + +func TestUpgradeHeaders(t *testing.T) { + got := UpgradeHeaders(" ", "$connection_upgrade") + for _, want := range []string{ + "proxy_set_header Upgrade $http_upgrade;", + "proxy_set_header Connection $connection_upgrade;", + } { + if !strings.Contains(got, want) { + t.Fatalf("WebSocket 头缺少 %q:%s", want, got) + } + } +} diff --git a/app-src/internal/notify/config.go b/app-src/internal/notify/config.go new file mode 100644 index 0000000..1583b43 --- /dev/null +++ b/app-src/internal/notify/config.go @@ -0,0 +1,188 @@ +package notify + +const ( + EventDDNSIPChange = "ddns_ip_change" + EventDDNSFailure = "ddns_failure" + EventCertExpiry = "cert_expiry" + EventCertRenewSuccess = "cert_renew_success" + EventCertRenewFailure = "cert_renew_failure" + EventIPFrequentAccess = "ip_frequent_access" + EventLoginFailure = "login_failure" + EventNginxReloadFailure = "nginx_reload_failure" + EventResourceThreshold = "resource_threshold" + EventTunnelDown = "tunnel_down" + EventFrpsServerDown = "frps_server_down" + EventAgentUnreachable = "agent_unreachable" + EventRouteDrift = "route_drift" + EventBackupFailure = "backup_failure" + + MaskedSecret = "********" + + DefaultBarkServer = "https://api.day.app" + DefaultNtfyServer = "https://ntfy.sh" + DefaultWecomServer = "https://qyapi.weixin.qq.com" + DefaultDingtalkServer = "https://oapi.dingtalk.com" + DefaultFeishuServer = "https://open.feishu.cn" + DefaultIPFrequentThreshold = 100 + DefaultIPFrequentWindowSec = 60 + DefaultIPFrequentAlertCooldown = 15 * 60 // seconds + DefaultLoginFailureThreshold = 5 + DefaultLoginFailureWindowSec = 300 + DefaultLoginFailureAlertCooldown = 15 * 60 // seconds + DefaultCPUThreshold = 90 + DefaultMemoryThreshold = 90 + DefaultDiskThreshold = 85 + DefaultTunnelFailThreshold = 3 + DefaultFrpsFailThreshold = 2 + DefaultAgentFailThreshold = 3 + DefaultDriftCooldownHours = 24 + // DefaultResourceAlertCooldown 是资源类告警的静默期:持续高占用时每半小时提醒一次, + // 期间恢复正常会清掉冷却,再次超标可立即再报。 + DefaultResourceAlertCooldown = 30 * 60 // seconds +) + +type NotifyType string + +const ( + NotifyTypeEmail NotifyType = "email" + NotifyTypeWebhook NotifyType = "webhook" + NotifyTypeTelegram NotifyType = "telegram" +) + +type WebhookProvider string + +const ( + WebhookBark WebhookProvider = "bark" + WebhookNtfy WebhookProvider = "ntfy" + WebhookGotify WebhookProvider = "gotify" + WebhookWecom WebhookProvider = "wecom" + WebhookDingtalk WebhookProvider = "dingtalk" + WebhookFeishu WebhookProvider = "feishu" + WebhookCustom WebhookProvider = "custom" +) + +// providerUsesKey 表示该预设把凭据放在 Webhook.Key(而不是 WebhookSecret)里。 +// 保存时的「已配置」判定与密钥落库都按这份名单走,新增预设时只需改这一处。 +func providerUsesKey(provider WebhookProvider) bool { + switch provider { + case WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu: + return true + default: + return false + } +} + +type EmailConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + From string `json:"from"` + To []string `json:"to"` + TLS bool `json:"tls"` + HasPassword bool `json:"has_password"` +} + +type WebhookConfig struct { + Provider WebhookProvider `json:"provider"` + Server string `json:"server"` + Key string `json:"key"` + Topic string `json:"topic"` + URL string `json:"url"` + HasSecret bool `json:"has_secret"` +} + +type TelegramConfig struct { + ChatID string `json:"chat_id"` + ProxyURL string `json:"proxy_url"` + HasBotToken bool `json:"has_bot_token"` +} + +type Config struct { + Type NotifyType `json:"type"` + Email EmailConfig `json:"email"` + Webhook WebhookConfig `json:"webhook"` + Telegram TelegramConfig `json:"telegram"` + OnDDNSIPChange bool `json:"on_ddns_ip_change"` + OnDDNSFailure bool `json:"on_ddns_failure"` + OnCertExpiry bool `json:"on_cert_expiry"` + OnCertRenewSuccess bool `json:"on_cert_renew_success"` + OnCertRenewFailure bool `json:"on_cert_renew_failure"` + OnIPFrequentAccess bool `json:"on_ip_frequent_access"` + OnLoginFailure bool `json:"on_login_failure"` + OnNginxReloadFailure bool `json:"on_nginx_reload_failure"` + IPFrequentThreshold int `json:"ip_frequent_threshold"` + IPFrequentWindowSec int `json:"ip_frequent_window_sec"` + LoginFailureThreshold int `json:"login_failure_threshold"` + LoginFailureWindowSec int `json:"login_failure_window_sec"` + OnResourceThreshold bool `json:"on_resource_threshold"` + OnTunnelDown bool `json:"on_tunnel_down"` + CPUThreshold int `json:"cpu_threshold"` + MemoryThreshold int `json:"memory_threshold"` + DiskThreshold int `json:"disk_threshold"` + TunnelFailThreshold int `json:"tunnel_fail_threshold"` + OnFrpsDown bool `json:"on_frps_down"` + OnAgentUnreachable bool `json:"on_agent_unreachable"` + OnRouteDrift bool `json:"on_route_drift"` + FrpsFailThreshold int `json:"frps_fail_threshold"` + AgentFailThreshold int `json:"agent_fail_threshold"` + DriftCooldownHours int `json:"drift_cooldown_hours"` + OnBackupFailure bool `json:"on_backup_failure"` +} + +type RuntimeConfig struct { + Config + SMTPPassword string + WebhookSecret string + TelegramToken string +} + +type SaveInput struct { + Type NotifyType + Email EmailConfig + SMTPPassword string + Webhook WebhookConfig + WebhookSecret string + Telegram TelegramConfig + TelegramToken string + OnDDNSIPChange bool + OnDDNSFailure bool + OnCertExpiry bool + OnCertRenewSuccess bool + OnCertRenewFailure bool + OnIPFrequentAccess bool + OnLoginFailure bool + OnNginxReloadFailure bool + IPFrequentThreshold int + IPFrequentWindowSec int + LoginFailureThreshold int + LoginFailureWindowSec int + OnResourceThreshold bool + OnTunnelDown bool + CPUThreshold int + MemoryThreshold int + DiskThreshold int + TunnelFailThreshold int + OnFrpsDown bool + OnAgentUnreachable bool + OnRouteDrift bool + FrpsFailThreshold int + AgentFailThreshold int + DriftCooldownHours int + OnBackupFailure bool +} + +type TestInput struct { + Config + SMTPPassword string `json:"smtp_password"` + WebhookSecret string `json:"webhook_secret"` + TelegramToken string `json:"telegram_token"` +} + +func (c Config) Runtime(password, webhookSecret, telegramToken string) RuntimeConfig { + return RuntimeConfig{ + Config: c, + SMTPPassword: password, + WebhookSecret: webhookSecret, + TelegramToken: telegramToken, + } +} diff --git a/app-src/internal/notify/format.go b/app-src/internal/notify/format.go new file mode 100644 index 0000000..dc460a8 --- /dev/null +++ b/app-src/internal/notify/format.go @@ -0,0 +1,142 @@ +package notify + +import ( + "strings" + "time" +) + +func formatFooter(at time.Time) string { + return "Havline · " + at.Format("2006-01-02 15:04:05") +} + +type eventMeta struct { + icon string + category string +} + +type AlertContent struct { + Event string + Subject string + PushTitle string + PlainBody string + HTMLBody string + Category string + Icon string + Detail string + Timestamp time.Time +} + +func FormatAlert(event, title, message string) AlertContent { + return FormatAlertAt(event, title, message, time.Now()) +} + +func FormatAlertAt(event, title, message string, at time.Time) AlertContent { + meta := eventMetaFor(event) + title = strings.TrimSpace(title) + message = strings.TrimSpace(message) + pushTitle := strings.TrimSpace(meta.icon + " " + title) + subject := "[Havline] " + pushTitle + footer := formatFooter(at) + + detail := message + if detail == "" { + detail = "暂无更多详情" + } + + plain := strings.Join([]string{ + pushTitle, + strings.Repeat("─", 28), + "", + detail, + "", + strings.Repeat("─", 28), + footer, + }, "\n") + + html := formatTelegramHTML(pushTitle, detail, at) + + return AlertContent{ + Event: event, + Subject: subject, + PushTitle: pushTitle, + PlainBody: plain, + HTMLBody: html, + Category: meta.category, + Icon: meta.icon, + Detail: detail, + Timestamp: at, + } +} + +func eventMetaFor(event string) eventMeta { + switch event { + case EventDDNSIPChange: + return eventMeta{"🌐", "DDNS"} + case EventDDNSFailure: + return eventMeta{"⚠️", "DDNS"} + case EventCertExpiry: + return eventMeta{"📅", "证书"} + case EventCertRenewSuccess: + return eventMeta{"✅", "证书"} + case EventCertRenewFailure: + return eventMeta{"❌", "证书"} + case EventIPFrequentAccess: + return eventMeta{"🛡️", "安全"} + case EventLoginFailure: + return eventMeta{"🔐", "安全"} + case EventNginxReloadFailure: + return eventMeta{"⚙️", "系统"} + case EventResourceThreshold: + return eventMeta{"📈", "系统"} + case EventTunnelDown: + return eventMeta{"🔌", "隧道"} + case EventFrpsServerDown: + return eventMeta{"🛑", "隧道"} + case EventAgentUnreachable: + return eventMeta{"📡", "隧道"} + case EventRouteDrift: + return eventMeta{"🧭", "配置"} + case EventBackupFailure: + return eventMeta{"💾", "系统"} + case "test": + return eventMeta{"🔔", "系统"} + default: + return eventMeta{"📣", "告警"} + } +} + +func formatTelegramHTML(title, detail string, at time.Time) string { + lines := []string{ + "" + escapeHTML(title) + "", + "", + "
" + escapeHTML(detail) + "
", + "", + "" + escapeHTML(formatFooter(at)) + "", + } + return strings.Join(lines, "\n") +} + +func escapeHTML(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + ) + return replacer.Replace(s) +} + +func (c AlertContent) webhookPayload() map[string]string { + return map[string]string{ + "event": c.Event, + "category": c.Category, + "icon": c.Icon, + "title": strings.TrimPrefix(c.PushTitle, c.Icon+" "), + "message": c.Detail, + "formatted": c.PlainBody, + "time": c.Timestamp.UTC().Format(time.RFC3339), + } +} + +func (c AlertContent) barkMessage() string { + return strings.Join([]string{c.Detail, "", "────────", formatFooter(c.Timestamp)}, "\n") +} diff --git a/app-src/internal/notify/format_test.go b/app-src/internal/notify/format_test.go new file mode 100644 index 0000000..08a29e3 --- /dev/null +++ b/app-src/internal/notify/format_test.go @@ -0,0 +1,28 @@ +package notify + +import ( + "strings" + "testing" + "time" +) + +func TestFormatAlert(t *testing.T) { + at := time.Date(2026, 9, 18, 15, 49, 0, 0, time.FixedZone("CST", 8*3600)) + content := FormatAlertAt(EventDDNSIPChange, "DDNS IP 已变更", "example.com\nIPv4: 1.2.3.4 → 5.6.7.8", at) + + if content.Subject != "[Havline] 🌐 DDNS IP 已变更" { + t.Fatalf("unexpected subject: %s", content.Subject) + } + if !strings.Contains(content.PlainBody, "example.com") { + t.Fatalf("missing detail: %s", content.PlainBody) + } + if !strings.Contains(content.PlainBody, "Havline · 2026-09-18 15:49:00") { + t.Fatalf("missing footer: %s", content.PlainBody) + } + if !strings.Contains(content.HTMLBody, "") || !strings.Contains(content.HTMLBody, "
") {
+		t.Fatalf("unexpected html: %s", content.HTMLBody)
+	}
+	if content.webhookPayload()["category"] != "DDNS" {
+		t.Fatalf("unexpected webhook category: %v", content.webhookPayload())
+	}
+}
diff --git a/app-src/internal/notify/push.go b/app-src/internal/notify/push.go
new file mode 100644
index 0000000..71c3fab
--- /dev/null
+++ b/app-src/internal/notify/push.go
@@ -0,0 +1,219 @@
+package notify
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+	switch cfg.Webhook.Provider {
+	case WebhookBark:
+		return sendBark(ctx, client, cfg, content.PushTitle, content.barkMessage())
+	case WebhookNtfy:
+		return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody)
+	case WebhookGotify:
+		return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody)
+	case WebhookWecom:
+		return sendWecom(ctx, client, cfg, content.PushTitle, content.PlainBody)
+	case WebhookDingtalk:
+		return sendDingtalk(ctx, client, cfg, content.PushTitle, content.PlainBody)
+	case WebhookFeishu:
+		return sendFeishu(ctx, client, cfg, content.PushTitle, content.PlainBody)
+	case WebhookCustom:
+		return sendCustomWebhook(ctx, client, cfg, content)
+	default:
+		return fmt.Errorf("不支持的 Webhook 预设")
+	}
+}
+
+func webhookSecret(cfg RuntimeConfig) string {
+	if strings.TrimSpace(cfg.WebhookSecret) != "" {
+		return strings.TrimSpace(cfg.WebhookSecret)
+	}
+	return strings.TrimSpace(cfg.Webhook.Key)
+}
+
+func sendBark(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	key := webhookSecret(cfg)
+	if key == "" {
+		return fmt.Errorf("Bark Device Key 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		server = DefaultBarkServer
+	}
+	endpoint := fmt.Sprintf("%s/%s/%s/%s", server, url.PathEscape(key), url.PathEscape(title), url.PathEscape(message))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
+	if err != nil {
+		return err
+	}
+	return doRequest(client, req)
+}
+
+func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	topic := strings.TrimSpace(cfg.Webhook.Topic)
+	if topic == "" {
+		return fmt.Errorf("ntfy Topic 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		server = DefaultNtfyServer
+	}
+	endpoint := joinURL(server, topic)
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Title", title)
+	req.Header.Set("Content-Type", "text/plain; charset=utf-8")
+	if token := webhookSecret(cfg); token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+	return doRequest(client, req)
+}
+
+func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	token := webhookSecret(cfg)
+	if token == "" {
+		return fmt.Errorf("Gotify App Token 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		return fmt.Errorf("Gotify 服务地址未配置")
+	}
+	endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token)
+	return postJSON(ctx, client, endpoint, map[string]any{
+		"title":    title,
+		"message":  message,
+		"priority": 5,
+	})
+}
+
+// 企业微信群机器人:POST /cgi-bin/webhook/send?key=,markdown 正文
+func sendWecom(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	key := webhookSecret(cfg)
+	if key == "" {
+		return fmt.Errorf("企业微信机器人 Key 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		server = DefaultWecomServer
+	}
+	endpoint := joinURL(server, "cgi-bin", "webhook", "send") + "?key=" + url.QueryEscape(key)
+	return postJSON(ctx, client, endpoint, map[string]any{
+		"msgtype":  "markdown",
+		"markdown": map[string]string{"content": markdownBody(title, message)},
+	})
+}
+
+// 钉钉群机器人:POST /robot/send?access_token=。
+// 正文固定带 “Havline · ” 前缀,机器人用「自定义关键词 = Havline」的安全设置即可命中。
+func sendDingtalk(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	key := webhookSecret(cfg)
+	if key == "" {
+		return fmt.Errorf("钉钉机器人 Access Token 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		server = DefaultDingtalkServer
+	}
+	titled := "Havline · " + title
+	endpoint := joinURL(server, "robot", "send") + "?access_token=" + url.QueryEscape(key)
+	return postJSON(ctx, client, endpoint, map[string]any{
+		"msgtype":  "markdown",
+		"markdown": map[string]string{"title": titled, "text": markdownBody(titled, message)},
+	})
+}
+
+// 飞书自定义机器人:POST /open-apis/bot/v2/hook/,纯文本消息
+func sendFeishu(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+	key := webhookSecret(cfg)
+	if key == "" {
+		return fmt.Errorf("飞书机器人 Key 未配置")
+	}
+	server := normalizeServerURL(cfg.Webhook.Server)
+	if server == "" {
+		server = DefaultFeishuServer
+	}
+	endpoint := joinURL(server, "open-apis", "bot", "v2", "hook", key)
+	return postJSON(ctx, client, endpoint, map[string]any{
+		"msg_type": "text",
+		"content":  map[string]string{"text": title + "\n" + message},
+	})
+}
+
+// markdownBody 拼出国内 IM 机器人都能渲染的 markdown(企业微信 / 钉钉都认 ## 标题 + 正文)
+func markdownBody(title, message string) string {
+	return "## " + title + "\n" + message
+}
+
+func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+	target := strings.TrimSpace(cfg.Webhook.URL)
+	if target == "" {
+		return fmt.Errorf("Webhook URL 未配置")
+	}
+	return postJSON(ctx, client, target, content.webhookPayload())
+}
+
+// postJSON 发一个 JSON POST:各国内 IM 机器人都是这个形状,只是 URL 与 payload 不同。
+func postJSON(ctx context.Context, client *http.Client, endpoint string, payload any) error {
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	return doRequest(client, req)
+}
+
+func doRequest(client *http.Client, req *http.Request) error {
+	if client == nil {
+		client = &http.Client{Timeout: 10 * time.Second}
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		if len(body) > 0 {
+			return fmt.Errorf("请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
+		}
+		return fmt.Errorf("请求失败 (%d)", resp.StatusCode)
+	}
+	return nil
+}
+
+func normalizeServerURL(server string) string {
+	server = strings.TrimSpace(server)
+	server = strings.TrimRight(server, "/")
+	if server == "" {
+		return ""
+	}
+	if !strings.Contains(server, "://") {
+		server = "https://" + server
+	}
+	return server
+}
+
+func joinURL(base string, parts ...string) string {
+	base = strings.TrimRight(normalizeServerURL(base), "/")
+	for _, part := range parts {
+		part = strings.Trim(part, "/")
+		if part != "" {
+			base += "/" + part
+		}
+	}
+	return base
+}
diff --git a/app-src/internal/notify/push_test.go b/app-src/internal/notify/push_test.go
new file mode 100644
index 0000000..017a110
--- /dev/null
+++ b/app-src/internal/notify/push_test.go
@@ -0,0 +1,163 @@
+package notify
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestSendCustomWebhook(t *testing.T) {
+	var body string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		raw, _ := io.ReadAll(r.Body)
+		body = string(raw)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := RuntimeConfig{
+		Config: Config{
+			Type: NotifyTypeWebhook,
+			Webhook: WebhookConfig{
+				Provider: WebhookCustom,
+				URL:      server.URL,
+			},
+		},
+	}
+	content := FormatAlert("test", "title", "message")
+	if err := SendWebhook(context.Background(), server.Client(), cfg, content); err != nil {
+		t.Fatalf("send custom webhook: %v", err)
+	}
+	if !strings.Contains(body, `"title":"title"`) || !strings.Contains(body, `"message":"message"`) {
+		t.Fatalf("unexpected body: %s", body)
+	}
+	if !strings.Contains(body, `"formatted":`) {
+		t.Fatalf("missing formatted body: %s", body)
+	}
+}
+
+func TestSendNtfyUsesTopic(t *testing.T) {
+	var gotPath string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gotPath = r.URL.Path
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := RuntimeConfig{
+		Config: Config{
+			Type: NotifyTypeWebhook,
+			Webhook: WebhookConfig{
+				Provider: WebhookNtfy,
+				Server:   server.URL,
+				Topic:    "alerts",
+			},
+		},
+	}
+	if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "title", "message")); err != nil {
+		t.Fatalf("send ntfy: %v", err)
+	}
+	if gotPath != "/alerts" {
+		t.Fatalf("expected /alerts got %s", gotPath)
+	}
+}
+
+func TestJoinURL(t *testing.T) {
+	got := joinURL("https://ntfy.sh", "topic")
+	if got != "https://ntfy.sh/topic" {
+		t.Fatalf("unexpected url: %s", got)
+	}
+}
+
+// 国内 IM 机器人:固定路径 + 固定 payload 形状,这里把三家的端点与请求体锁住
+func TestSendWecomUsesKeyAndMarkdown(t *testing.T) {
+	var path, query, body string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		path = r.URL.Path
+		query = r.URL.RawQuery
+		raw, _ := io.ReadAll(r.Body)
+		body = string(raw)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := RuntimeConfig{Config: Config{
+		Type:    NotifyTypeWebhook,
+		Webhook: WebhookConfig{Provider: WebhookWecom, Server: server.URL, Key: "abc"},
+	}}
+	if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
+		t.Fatalf("send wecom: %v", err)
+	}
+	if path != "/cgi-bin/webhook/send" || query != "key=abc" {
+		t.Fatalf("unexpected endpoint: %s?%s", path, query)
+	}
+	if !strings.Contains(body, `"msgtype":"markdown"`) || !strings.Contains(body, "标题") {
+		t.Fatalf("unexpected body: %s", body)
+	}
+}
+
+func TestSendDingtalkUsesAccessTokenAndKeyword(t *testing.T) {
+	var path, query, body string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		path = r.URL.Path
+		query = r.URL.RawQuery
+		raw, _ := io.ReadAll(r.Body)
+		body = string(raw)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := RuntimeConfig{Config: Config{
+		Type:    NotifyTypeWebhook,
+		Webhook: WebhookConfig{Provider: WebhookDingtalk, Server: server.URL, Key: "tok"},
+	}}
+	if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
+		t.Fatalf("send dingtalk: %v", err)
+	}
+	if path != "/robot/send" || query != "access_token=tok" {
+		t.Fatalf("unexpected endpoint: %s?%s", path, query)
+	}
+	// 「自定义关键词」安全设置靠正文里的 Havline 命中(PushTitle 前面还有事件图标)
+	if !strings.Contains(body, "Havline · ") || !strings.Contains(body, "标题") {
+		t.Fatalf("missing keyword prefix: %s", body)
+	}
+}
+
+func TestSendFeishuUsesHookPath(t *testing.T) {
+	var path, body string
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		path = r.URL.Path
+		raw, _ := io.ReadAll(r.Body)
+		body = string(raw)
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer server.Close()
+
+	cfg := RuntimeConfig{Config: Config{
+		Type:    NotifyTypeWebhook,
+		Webhook: WebhookConfig{Provider: WebhookFeishu, Server: server.URL, Key: "hookkey"},
+	}}
+	if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
+		t.Fatalf("send feishu: %v", err)
+	}
+	if path != "/open-apis/bot/v2/hook/hookkey" {
+		t.Fatalf("unexpected path: %s", path)
+	}
+	if !strings.Contains(body, `"msg_type":"text"`) {
+		t.Fatalf("unexpected body: %s", body)
+	}
+}
+
+func TestProviderUsesKeyCoversKeyBasedPresets(t *testing.T) {
+	for _, provider := range []WebhookProvider{WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu} {
+		if !providerUsesKey(provider) {
+			t.Fatalf("%s 的凭据存在 Webhook.Key,密钥落库路径必须覆盖它", provider)
+		}
+	}
+	if providerUsesKey(WebhookCustom) {
+		t.Fatal("自定义 Webhook 的凭据来自 URL,不应走 Key 分支")
+	}
+}
diff --git a/app-src/internal/notify/service.go b/app-src/internal/notify/service.go
new file mode 100644
index 0000000..ce320dd
--- /dev/null
+++ b/app-src/internal/notify/service.go
@@ -0,0 +1,260 @@
+package notify
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/havline/havline/internal/secret"
+	"github.com/havline/havline/internal/settings"
+)
+
+type Service struct {
+	settings *settings.Store
+	store    *Store
+	logger   *slog.Logger
+	client   *http.Client
+
+	accessMu      sync.Mutex
+	accessHits    map[string][]time.Time
+	accessAlerted map[string]time.Time
+
+	loginMu      sync.Mutex
+	loginHits    map[string][]time.Time
+	loginAlerted map[string]time.Time
+}
+
+func New(settingsStore *settings.Store, secretBox *secret.Box, logger *slog.Logger) *Service {
+	if logger == nil {
+		logger = slog.Default()
+	}
+	return &Service{
+		settings:      settingsStore,
+		store:         NewStore(settingsStore, secretBox),
+		logger:        logger,
+		client:        &http.Client{Timeout: 10 * time.Second},
+		accessHits:    map[string][]time.Time{},
+		accessAlerted: map[string]time.Time{},
+		loginHits:     map[string][]time.Time{},
+		loginAlerted:  map[string]time.Time{},
+	}
+}
+
+func (s *Service) Alert(ctx context.Context, event, title, message string) {
+	if s == nil || s.store == nil {
+		return
+	}
+	cfg, err := s.store.LoadRuntime(ctx)
+	if err != nil {
+		s.logger.Warn("notify load config failed", "error", err.Error())
+		return
+	}
+	if !s.enabledFor(cfg, event) {
+		return
+	}
+	if err := s.send(ctx, cfg, event, title, message); err != nil {
+		s.logger.Warn("notify send failed", "event", event, "error", err.Error())
+	}
+}
+
+func (s *Service) RecordAccessHit(ctx context.Context, ip string) {
+	s.recordThresholdHit(ctx, ip, EventIPFrequentAccess, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
+		enabled = s.enabledFor(cfg, EventIPFrequentAccess)
+		threshold = cfg.IPFrequentThreshold
+		if threshold <= 0 {
+			threshold = DefaultIPFrequentThreshold
+		}
+		windowSec = cfg.IPFrequentWindowSec
+		if windowSec <= 0 {
+			windowSec = DefaultIPFrequentWindowSec
+		}
+		return threshold, windowSec, DefaultIPFrequentAlertCooldown, enabled
+	}, &s.accessMu, s.accessHits, s.accessAlerted, func(ip string, windowSec, count, threshold int) string {
+		return fmt.Sprintf("IP %s 在 %d 秒内访问 %d 次(阈值 %d)", ip, windowSec, count, threshold)
+	}, "IP 频繁访问")
+}
+
+func (s *Service) RecordLoginFailure(ctx context.Context, ip string) {
+	s.recordThresholdHit(ctx, ip, EventLoginFailure, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
+		enabled = s.enabledFor(cfg, EventLoginFailure)
+		threshold = cfg.LoginFailureThreshold
+		if threshold <= 0 {
+			threshold = DefaultLoginFailureThreshold
+		}
+		windowSec = cfg.LoginFailureWindowSec
+		if windowSec <= 0 {
+			windowSec = DefaultLoginFailureWindowSec
+		}
+		return threshold, windowSec, DefaultLoginFailureAlertCooldown, enabled
+	}, &s.loginMu, s.loginHits, s.loginAlerted, func(ip string, windowSec, count, threshold int) string {
+		return fmt.Sprintf("IP %s 在 %d 秒内登录失败 %d 次(阈值 %d)", ip, windowSec, count, threshold)
+	}, "登录异常")
+}
+
+func (s *Service) recordThresholdHit(
+	ctx context.Context,
+	ip string,
+	event string,
+	cfgFn func(RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool),
+	mu *sync.Mutex,
+	hits map[string][]time.Time,
+	alerted map[string]time.Time,
+	messageFn func(ip string, windowSec, count, threshold int) string,
+	title string,
+) {
+	if s == nil || s.store == nil {
+		return
+	}
+	ip = strings.TrimSpace(ip)
+	if ip == "" || ip == "-" {
+		return
+	}
+
+	cfg, err := s.store.LoadRuntime(ctx)
+	if err != nil {
+		return
+	}
+	threshold, windowSec, cooldownSec, enabled := cfgFn(cfg)
+	if !enabled {
+		return
+	}
+
+	window := time.Duration(windowSec) * time.Second
+	now := time.Now()
+	cutoff := now.Add(-window)
+
+	mu.Lock()
+	prev := hits[ip]
+	pruned := make([]time.Time, 0, len(prev)+1)
+	for _, at := range prev {
+		if !at.Before(cutoff) {
+			pruned = append(pruned, at)
+		}
+	}
+	pruned = append(pruned, now)
+	hits[ip] = pruned
+
+	count := len(pruned)
+	lastAlert := alerted[ip]
+	cooldown := time.Duration(cooldownSec) * time.Second
+	shouldAlert := count >= threshold && now.Sub(lastAlert) >= cooldown
+	if shouldAlert {
+		alerted[ip] = now
+	}
+	mu.Unlock()
+
+	if !shouldAlert {
+		return
+	}
+	s.Alert(ctx, event, title, messageFn(ip, windowSec, count, threshold))
+}
+
+func (s *Service) SendTest(ctx context.Context, in TestInput) error {
+	runtime, err := s.resolveTestRuntime(ctx, in)
+	if err != nil {
+		return err
+	}
+	if runtime.Type == "" {
+		return fmt.Errorf("请先选择通知方式")
+	}
+	if err := s.send(ctx, runtime, "test", "测试通知", "配置验证通过,当前通知通道可正常使用。"); err != nil {
+		s.logger.Warn("notify test send failed", "error", err.Error())
+		return err
+	}
+	return nil
+}
+
+func (s *Service) resolveTestRuntime(ctx context.Context, in TestInput) (RuntimeConfig, error) {
+	smtpPassword := strings.TrimSpace(in.SMTPPassword)
+	webhookSecret := strings.TrimSpace(in.WebhookSecret)
+	telegramToken := strings.TrimSpace(in.TelegramToken)
+
+	if key := strings.TrimSpace(in.Webhook.Key); key != "" && key != MaskedSecret {
+		webhookSecret = key
+	}
+
+	needStored := smtpPassword == "" || smtpPassword == MaskedSecret ||
+		webhookSecret == "" || webhookSecret == MaskedSecret ||
+		telegramToken == "" || telegramToken == MaskedSecret
+
+	if needStored && s.store != nil {
+		stored, err := s.store.LoadRuntime(ctx)
+		if err != nil {
+			return RuntimeConfig{}, err
+		}
+		if (smtpPassword == "" || smtpPassword == MaskedSecret) && in.Email.HasPassword {
+			smtpPassword = stored.SMTPPassword
+		}
+		if (webhookSecret == "" || webhookSecret == MaskedSecret) && in.Webhook.HasSecret {
+			webhookSecret = stored.WebhookSecret
+		}
+		if (telegramToken == "" || telegramToken == MaskedSecret) && in.Telegram.HasBotToken {
+			telegramToken = stored.TelegramToken
+		}
+	}
+
+	return in.Config.Runtime(smtpPassword, webhookSecret, telegramToken), nil
+}
+
+func (s *Service) Store() *Store {
+	if s == nil {
+		return nil
+	}
+	return s.store
+}
+
+func (s *Service) send(ctx context.Context, cfg RuntimeConfig, event, title, message string) error {
+	content := FormatAlert(event, title, message)
+	switch cfg.Type {
+	case NotifyTypeEmail:
+		return SendEmail(cfg, content.Subject, content.PlainBody)
+	case NotifyTypeWebhook:
+		return SendWebhook(ctx, s.client, cfg, content)
+	case NotifyTypeTelegram:
+		return SendTelegram(ctx, s.client, cfg, content)
+	default:
+		return nil
+	}
+}
+
+func (s *Service) enabledFor(cfg RuntimeConfig, event string) bool {
+	if cfg.Type == "" {
+		return false
+	}
+	switch event {
+	case EventDDNSIPChange:
+		return cfg.OnDDNSIPChange
+	case EventDDNSFailure:
+		return cfg.OnDDNSFailure
+	case EventCertExpiry:
+		return cfg.OnCertExpiry
+	case EventCertRenewSuccess:
+		return cfg.OnCertRenewSuccess
+	case EventCertRenewFailure:
+		return cfg.OnCertRenewFailure
+	case EventIPFrequentAccess:
+		return cfg.OnIPFrequentAccess
+	case EventLoginFailure:
+		return cfg.OnLoginFailure
+	case EventNginxReloadFailure:
+		return cfg.OnNginxReloadFailure
+	case EventResourceThreshold:
+		return cfg.OnResourceThreshold
+	case EventTunnelDown:
+		return cfg.OnTunnelDown
+	case EventFrpsServerDown:
+		return cfg.OnFrpsDown
+	case EventAgentUnreachable:
+		return cfg.OnAgentUnreachable
+	case EventRouteDrift:
+		return cfg.OnRouteDrift
+	case EventBackupFailure:
+		return cfg.OnBackupFailure
+	default:
+		return true
+	}
+}
diff --git a/app-src/internal/notify/service_test.go b/app-src/internal/notify/service_test.go
new file mode 100644
index 0000000..8fbc6b1
--- /dev/null
+++ b/app-src/internal/notify/service_test.go
@@ -0,0 +1,47 @@
+package notify
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+)
+
+func TestTestInputDecodesSecretFields(t *testing.T) {
+	raw := `{
+		"type": "telegram",
+		"telegram": {"chat_id": "743955235", "proxy_url": "http://127.0.0.1:7890", "has_bot_token": false},
+		"telegram_token": "123456:ABC-DEF"
+	}`
+	var in TestInput
+	if err := json.Unmarshal([]byte(raw), &in); err != nil {
+		t.Fatal(err)
+	}
+	if in.Type != NotifyTypeTelegram {
+		t.Fatalf("unexpected type: %s", in.Type)
+	}
+	if in.TelegramToken != "123456:ABC-DEF" {
+		t.Fatalf("telegram token not decoded: %q", in.TelegramToken)
+	}
+	if in.Telegram.ChatID != "743955235" {
+		t.Fatalf("chat id not decoded: %q", in.Telegram.ChatID)
+	}
+}
+
+func TestResolveTestRuntimeUsesPlainToken(t *testing.T) {
+	svc := &Service{}
+	runtime, err := svc.resolveTestRuntime(context.Background(), TestInput{
+		Config: Config{
+			Type: NotifyTypeTelegram,
+			Telegram: TelegramConfig{
+				ChatID: "1",
+			},
+		},
+		TelegramToken: "plain-token",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if runtime.TelegramToken != "plain-token" {
+		t.Fatalf("expected plain-token, got %q", runtime.TelegramToken)
+	}
+}
diff --git a/app-src/internal/notify/smtp.go b/app-src/internal/notify/smtp.go
new file mode 100644
index 0000000..7962a26
--- /dev/null
+++ b/app-src/internal/notify/smtp.go
@@ -0,0 +1,90 @@
+package notify
+
+import (
+	"crypto/tls"
+	"fmt"
+	"net/smtp"
+	"strings"
+)
+
+func SendEmail(cfg RuntimeConfig, title, message string) error {
+	if strings.TrimSpace(cfg.Email.Host) == "" {
+		return fmt.Errorf("SMTP 主机未配置")
+	}
+	if len(cfg.Email.To) == 0 {
+		return fmt.Errorf("收件人未配置")
+	}
+	if strings.TrimSpace(cfg.SMTPPassword) == "" && strings.TrimSpace(cfg.Email.Username) != "" {
+		return fmt.Errorf("SMTP 密码未配置")
+	}
+
+	from := strings.TrimSpace(cfg.Email.From)
+	if from == "" {
+		from = strings.TrimSpace(cfg.Email.Username)
+	}
+	if from == "" {
+		return fmt.Errorf("发件人未配置")
+	}
+
+	body := buildMailBody(from, cfg.Email.To, title, message)
+	addr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
+	auth := smtp.PlainAuth("", cfg.Email.Username, cfg.SMTPPassword, cfg.Email.Host)
+
+	if cfg.Email.Port == 465 {
+		return sendSMTPS(addr, cfg.Email.Host, auth, from, cfg.Email.To, body)
+	}
+	return smtp.SendMail(addr, auth, from, cfg.Email.To, body)
+}
+
+func buildMailBody(from string, to []string, title, message string) []byte {
+	headers := []string{
+		"From: " + from,
+		"To: " + strings.Join(to, ", "),
+		"Subject: " + title,
+		"MIME-Version: 1.0",
+		"Content-Type: text/plain; charset=UTF-8",
+	}
+	bodyText := strings.TrimSpace(message)
+	if bodyText == "" {
+		bodyText = title
+	}
+	return []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyText + "\r\n")
+}
+
+func sendSMTPS(addr, host string, auth smtp.Auth, from string, to []string, body []byte) error {
+	tlsCfg := &tls.Config{ServerName: host}
+	conn, err := tls.Dial("tcp", addr, tlsCfg)
+	if err != nil {
+		return err
+	}
+	client, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return err
+	}
+	defer client.Close()
+
+	if auth != nil {
+		if err := client.Auth(auth); err != nil {
+			return err
+		}
+	}
+	if err := client.Mail(from); err != nil {
+		return err
+	}
+	for _, rcpt := range to {
+		if err := client.Rcpt(strings.TrimSpace(rcpt)); err != nil {
+			return err
+		}
+	}
+	writer, err := client.Data()
+	if err != nil {
+		return err
+	}
+	if _, err := writer.Write(body); err != nil {
+		return err
+	}
+	if err := writer.Close(); err != nil {
+		return err
+	}
+	return client.Quit()
+}
diff --git a/app-src/internal/notify/smtp_test.go b/app-src/internal/notify/smtp_test.go
new file mode 100644
index 0000000..a4f0b76
--- /dev/null
+++ b/app-src/internal/notify/smtp_test.go
@@ -0,0 +1,17 @@
+package notify
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestBuildMailBody(t *testing.T) {
+	content := FormatAlert("test", "标题", "正文")
+	body := string(buildMailBody("from@example.com", []string{"to@example.com"}, content.Subject, content.PlainBody))
+	if !strings.Contains(body, "Subject: [Havline]") {
+		t.Fatalf("missing subject: %s", body)
+	}
+	if !strings.Contains(body, "正文") {
+		t.Fatalf("missing message: %s", body)
+	}
+}
diff --git a/app-src/internal/notify/store.go b/app-src/internal/notify/store.go
new file mode 100644
index 0000000..745ca5b
--- /dev/null
+++ b/app-src/internal/notify/store.go
@@ -0,0 +1,565 @@
+package notify
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/havline/havline/internal/secret"
+	"github.com/havline/havline/internal/settings"
+)
+
+type Store struct {
+	settings *settings.Store
+	secret   *secret.Box
+}
+
+func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
+	return &Store{settings: settingsStore, secret: secretBox}
+}
+
+func (s *Store) Load(ctx context.Context) (Config, error) {
+	cfg, _, err := s.load(ctx, false)
+	return cfg, err
+}
+
+func (s *Store) LoadRuntime(ctx context.Context) (RuntimeConfig, error) {
+	cfg, runtime, err := s.load(ctx, true)
+	if err != nil {
+		return RuntimeConfig{}, err
+	}
+	return cfg.Runtime(runtime.smtpPassword, runtime.webhookSecret, runtime.telegramToken), nil
+}
+
+type runtimeSecrets struct {
+	smtpPassword  string
+	webhookSecret string
+	telegramToken string
+}
+
+func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets, error) {
+	cfg := Config{
+		Email:    EmailConfig{Port: 587, TLS: true},
+		Webhook:  WebhookConfig{Provider: WebhookBark, Server: DefaultBarkServer},
+		Telegram: TelegramConfig{},
+	}
+	secrets := runtimeSecrets{}
+
+	typeRaw, _ := s.settings.Get(ctx, settings.KeyNotifyType)
+	cfg.Type = NotifyType(strings.TrimSpace(typeRaw))
+
+	emailRaw, _ := s.settings.Get(ctx, settings.KeyNotifyEmailJSON)
+	if emailRaw != "" {
+		_ = json.Unmarshal([]byte(emailRaw), &cfg.Email)
+	}
+	if cfg.Email.Port <= 0 {
+		cfg.Email.Port = 587
+	}
+
+	webhookRaw, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookJSON)
+	if webhookRaw != "" {
+		_ = json.Unmarshal([]byte(webhookRaw), &cfg.Webhook)
+	}
+	if cfg.Webhook.Server == "" && cfg.Webhook.Provider == WebhookBark {
+		cfg.Webhook.Server = DefaultBarkServer
+	}
+
+	telegramRaw, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramJSON)
+	if telegramRaw != "" {
+		_ = json.Unmarshal([]byte(telegramRaw), &cfg.Telegram)
+	}
+
+	cfg.IPFrequentThreshold = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
+	cfg.IPFrequentWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
+
+	cfg.LoginFailureThreshold = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
+	cfg.LoginFailureWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
+	cfg.CPUThreshold = intSetting(s.settings, ctx, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
+	cfg.MemoryThreshold = intSetting(s.settings, ctx, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
+	cfg.DiskThreshold = intSetting(s.settings, ctx, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
+	cfg.TunnelFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
+	cfg.FrpsFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyFrpsFailThreshold, DefaultFrpsFailThreshold)
+	cfg.AgentFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyAgentFailThreshold, DefaultAgentFailThreshold)
+	cfg.DriftCooldownHours = intSetting(s.settings, ctx, settings.KeyNotifyDriftCooldownHours, DefaultDriftCooldownHours)
+
+	if hasNotifyEventSettings(ctx, s.settings) {
+		cfg.OnDDNSIPChange = flag(s.settings, ctx, settings.KeyNotifyOnDDNSIPChange, true)
+		cfg.OnDDNSFailure = flag(s.settings, ctx, settings.KeyNotifyOnDDNSFailure, true)
+		cfg.OnCertExpiry = flag(s.settings, ctx, settings.KeyNotifyOnCertExpiry, true)
+		cfg.OnCertRenewSuccess = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewSuccess, true)
+		cfg.OnCertRenewFailure = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewFailure, true)
+		cfg.OnIPFrequentAccess = flag(s.settings, ctx, settings.KeyNotifyOnIPFrequentAccess, false)
+		cfg.OnLoginFailure = flag(s.settings, ctx, settings.KeyNotifyOnLoginFailure, false)
+		cfg.OnNginxReloadFailure = flag(s.settings, ctx, settings.KeyNotifyOnNginxReloadFailure, true)
+		// 资源与隧道告警默认关闭:升级后不改变现有用户收到的通知
+		cfg.OnResourceThreshold = flag(s.settings, ctx, settings.KeyNotifyOnResourceThreshold, false)
+		cfg.OnTunnelDown = flag(s.settings, ctx, settings.KeyNotifyOnTunnelDown, false)
+		cfg.OnFrpsDown = flag(s.settings, ctx, settings.KeyNotifyOnFrpsDown, false)
+		cfg.OnAgentUnreachable = flag(s.settings, ctx, settings.KeyNotifyOnAgentUnreachable, false)
+		cfg.OnRouteDrift = flag(s.settings, ctx, settings.KeyNotifyOnRouteDrift, false)
+		// 备份失败属于「你以为有备份其实没有」,默认开启
+		cfg.OnBackupFailure = flag(s.settings, ctx, settings.KeyNotifyOnBackupFailure, true)
+	} else {
+		legacyDDNS := flag(s.settings, ctx, settings.KeyNotifyOnDDNSError, true)
+		legacyCert := flag(s.settings, ctx, settings.KeyNotifyOnCertError, true)
+		cfg.OnDDNSFailure = legacyDDNS
+		cfg.OnCertExpiry = legacyCert
+		cfg.OnCertRenewFailure = legacyCert
+		cfg.OnDDNSIPChange = true
+		cfg.OnCertRenewSuccess = true
+		cfg.OnNginxReloadFailure = true
+		cfg.OnIPFrequentAccess = false
+		cfg.OnLoginFailure = false
+	}
+
+	smtpEnc, _ := s.settings.Get(ctx, settings.KeyNotifySMTPPassword)
+	if smtpEnc != "" {
+		cfg.Email.HasPassword = true
+		if decrypt && s.secret != nil {
+			plain, err := s.secret.Decrypt(smtpEnc)
+			if err != nil {
+				// 单个通道的密文损坏(例如更换过加密密钥)不应让整份配置加载失败:
+				// 仅标记该通道不可用并继续解析其它通道,避免 Telegram/Webhook 一起失效
+				cfg.Email.HasPassword = false
+			} else {
+				secrets.smtpPassword = plain
+			}
+		}
+	}
+
+	webhookEnc, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookSecret)
+	if webhookEnc != "" {
+		cfg.Webhook.HasSecret = true
+		if decrypt && s.secret != nil {
+			plain, err := s.secret.Decrypt(webhookEnc)
+			if err != nil {
+				// 同上:只让该通道不可用,不影响其它通道
+				cfg.Webhook.HasSecret = false
+			} else {
+				secrets.webhookSecret = plain
+			}
+		}
+	}
+
+	telegramEnc, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramToken)
+	if telegramEnc != "" {
+		cfg.Telegram.HasBotToken = true
+		if decrypt && s.secret != nil {
+			plain, err := s.secret.Decrypt(telegramEnc)
+			if err != nil {
+				// 同上:只让该通道不可用,不影响其它通道
+				cfg.Telegram.HasBotToken = false
+			} else {
+				secrets.telegramToken = plain
+			}
+		}
+	}
+
+	if cfg.Type == "" {
+		legacyURL, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookURL)
+		if strings.TrimSpace(legacyURL) != "" {
+			cfg.Type = NotifyTypeWebhook
+			cfg.Webhook.Provider = WebhookCustom
+			cfg.Webhook.URL = strings.TrimSpace(legacyURL)
+		}
+	}
+
+	return cfg, secrets, nil
+}
+
+func flag(store *settings.Store, ctx context.Context, key string, fallback bool) bool {
+	raw, err := store.Get(ctx, key)
+	if err != nil || raw == "" {
+		return fallback
+	}
+	return raw == "1" || strings.EqualFold(raw, "true")
+}
+
+func intSetting(store *settings.Store, ctx context.Context, key string, fallback int) int {
+	raw, err := store.Get(ctx, key)
+	if err != nil || strings.TrimSpace(raw) == "" {
+		return fallback
+	}
+	n, err := strconv.Atoi(strings.TrimSpace(raw))
+	if err != nil || n <= 0 {
+		return fallback
+	}
+	return n
+}
+
+func hasNotifyEventSettings(ctx context.Context, store *settings.Store) bool {
+	keys := []string{
+		settings.KeyNotifyOnDDNSIPChange,
+		settings.KeyNotifyOnDDNSFailure,
+		settings.KeyNotifyOnCertExpiry,
+		settings.KeyNotifyOnCertRenewSuccess,
+		settings.KeyNotifyOnIPFrequentAccess,
+		settings.KeyNotifyOnCertRenewFailure,
+		settings.KeyNotifyOnLoginFailure,
+		settings.KeyNotifyOnNginxReloadFailure,
+		settings.KeyNotifyOnResourceThreshold,
+		settings.KeyNotifyOnTunnelDown,
+		settings.KeyNotifyOnFrpsDown,
+		settings.KeyNotifyOnAgentUnreachable,
+		settings.KeyNotifyOnRouteDrift,
+		settings.KeyNotifyOnBackupFailure,
+	}
+	for _, key := range keys {
+		raw, err := store.Get(ctx, key)
+		if err == nil && raw != "" {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *Store) Save(ctx context.Context, in SaveInput) error {
+	if err := validateSaveInput(in); err != nil {
+		return err
+	}
+
+	if err := s.settings.Set(ctx, settings.KeyNotifyType, string(in.Type)); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSIPChange, in.OnDDNSIPChange); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSFailure, in.OnDDNSFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertExpiry, in.OnCertExpiry); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewSuccess, in.OnCertRenewSuccess); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnIPFrequentAccess, in.OnIPFrequentAccess); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewFailure, in.OnCertRenewFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnLoginFailure, in.OnLoginFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnNginxReloadFailure, in.OnNginxReloadFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnResourceThreshold, in.OnResourceThreshold); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnTunnelDown, in.OnTunnelDown); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyCPUThreshold, clampThreshold(in.CPUThreshold, DefaultCPUThreshold)); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyMemoryThreshold, clampThreshold(in.MemoryThreshold, DefaultMemoryThreshold)); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyDiskThreshold, clampThreshold(in.DiskThreshold, DefaultDiskThreshold)); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyTunnelFailThreshold, clampThreshold(in.TunnelFailThreshold, DefaultTunnelFailThreshold)); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnFrpsDown, in.OnFrpsDown); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnAgentUnreachable, in.OnAgentUnreachable); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnRouteDrift, in.OnRouteDrift); err != nil {
+		return err
+	}
+	if err := s.settings.SetBool(ctx, settings.KeyNotifyOnBackupFailure, in.OnBackupFailure); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyFrpsFailThreshold, clampThreshold(in.FrpsFailThreshold, DefaultFrpsFailThreshold)); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyAgentFailThreshold, clampThreshold(in.AgentFailThreshold, DefaultAgentFailThreshold)); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyDriftCooldownHours, clampThreshold(in.DriftCooldownHours, DefaultDriftCooldownHours)); err != nil {
+		return err
+	}
+	threshold := in.IPFrequentThreshold
+	if threshold <= 0 {
+		threshold = DefaultIPFrequentThreshold
+	}
+	windowSec := in.IPFrequentWindowSec
+	if windowSec <= 0 {
+		windowSec = DefaultIPFrequentWindowSec
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentThreshold, threshold); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentWindowSec, windowSec); err != nil {
+		return err
+	}
+	loginThreshold := in.LoginFailureThreshold
+	if loginThreshold <= 0 {
+		loginThreshold = DefaultLoginFailureThreshold
+	}
+	loginWindowSec := in.LoginFailureWindowSec
+	if loginWindowSec <= 0 {
+		loginWindowSec = DefaultLoginFailureWindowSec
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureThreshold, loginThreshold); err != nil {
+		return err
+	}
+	if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureWindowSec, loginWindowSec); err != nil {
+		return err
+	}
+
+	email := in.Email
+	email.HasPassword = email.HasPassword || strings.TrimSpace(in.SMTPPassword) != "" && in.SMTPPassword != MaskedSecret
+	emailJSON, err := json.Marshal(email)
+	if err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNotifyEmailJSON, string(emailJSON)); err != nil {
+		return err
+	}
+
+	webhook := in.Webhook
+	secretValue := strings.TrimSpace(in.WebhookSecret)
+	if secretValue != "" && secretValue != MaskedSecret {
+		webhook.HasSecret = true
+	} else if providerUsesKey(webhook.Provider) {
+		if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+			webhook.HasSecret = true
+		}
+	}
+	webhookJSON, err := json.Marshal(webhook)
+	if err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNotifyWebhookJSON, string(webhookJSON)); err != nil {
+		return err
+	}
+
+	telegram := in.Telegram
+	if strings.TrimSpace(in.TelegramToken) != "" && in.TelegramToken != MaskedSecret {
+		telegram.HasBotToken = true
+	}
+	telegramJSON, err := json.Marshal(telegram)
+	if err != nil {
+		return err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNotifyTelegramJSON, string(telegramJSON)); err != nil {
+		return err
+	}
+
+	if in.Type == NotifyTypeWebhook && webhook.Provider == WebhookCustom {
+		if err := s.settings.Set(ctx, settings.KeyNotifyWebhookURL, strings.TrimSpace(webhook.URL)); err != nil {
+			return err
+		}
+	}
+
+	if err := s.saveSecret(ctx, settings.KeyNotifySMTPPassword, in.SMTPPassword, in.Email.HasPassword); err != nil {
+		return err
+	}
+
+	webhookPlain := secretValue
+	if webhookPlain == "" || webhookPlain == MaskedSecret {
+		if providerUsesKey(webhook.Provider) {
+			if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+				webhookPlain = strings.TrimSpace(webhook.Key)
+			}
+		}
+	}
+	if err := s.saveSecret(ctx, settings.KeyNotifyWebhookSecret, webhookPlain, webhook.HasSecret); err != nil {
+		return err
+	}
+
+	if err := s.saveSecret(ctx, settings.KeyNotifyTelegramToken, in.TelegramToken, telegram.HasBotToken); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (s *Store) saveSecret(ctx context.Context, key, plain string, hasExisting bool) error {
+	plain = strings.TrimSpace(plain)
+	if plain == "" || plain == MaskedSecret {
+		if !hasExisting {
+			return nil
+		}
+		return nil
+	}
+	if s.secret == nil {
+		return fmt.Errorf("加密模块未初始化")
+	}
+	enc, err := s.secret.Encrypt(plain)
+	if err != nil {
+		return err
+	}
+	return s.settings.Set(ctx, key, enc)
+}
+
+func validateSaveInput(in SaveInput) error {
+	switch in.Type {
+	case "":
+		return nil
+	case NotifyTypeEmail:
+		if strings.TrimSpace(in.Email.Host) == "" {
+			return fmt.Errorf("请填写 SMTP 主机")
+		}
+		if len(in.Email.To) == 0 {
+			return fmt.Errorf("请填写收件人邮箱")
+		}
+		password := strings.TrimSpace(in.SMTPPassword)
+		if password == "" || password == MaskedSecret {
+			if !in.Email.HasPassword {
+				return fmt.Errorf("请填写 SMTP 密码")
+			}
+		}
+	case NotifyTypeWebhook:
+		switch in.Webhook.Provider {
+		case WebhookBark:
+			if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+				return fmt.Errorf("请填写 Bark Device Key")
+			}
+		case WebhookNtfy:
+			if strings.TrimSpace(in.Webhook.Topic) == "" {
+				return fmt.Errorf("请填写 ntfy Topic")
+			}
+		case WebhookGotify:
+			if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+				return fmt.Errorf("请填写 Gotify App Token")
+			}
+		case WebhookWecom:
+			if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+				return fmt.Errorf("请填写企业微信机器人 Key")
+			}
+		case WebhookDingtalk:
+			if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+				return fmt.Errorf("请填写钉钉机器人 Access Token")
+			}
+		case WebhookFeishu:
+			if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+				return fmt.Errorf("请填写飞书机器人 Key")
+			}
+		case WebhookCustom:
+			if strings.TrimSpace(in.Webhook.URL) == "" {
+				return fmt.Errorf("请填写 Webhook URL")
+			}
+		default:
+			return fmt.Errorf("不支持的 Webhook 预设")
+		}
+	case NotifyTypeTelegram:
+		token := strings.TrimSpace(in.TelegramToken)
+		if token == "" && !in.Telegram.HasBotToken {
+			return fmt.Errorf("请填写 Telegram Bot Token")
+		}
+		if strings.TrimSpace(in.Telegram.ChatID) == "" {
+			return fmt.Errorf("请填写 Telegram Chat ID")
+		}
+	default:
+		return fmt.Errorf("不支持的通知类型")
+	}
+	return nil
+}
+
+func SanitizeSettings(values map[string]string) {
+	delete(values, settings.KeyNotifySMTPPassword)
+	delete(values, settings.KeyNotifyWebhookSecret)
+	delete(values, settings.KeyNotifyTelegramToken)
+	// ZeroSSL API Key 属证书申请凭据:API 永不回明文,只回掩码。前端以「非空」判断是否已配置,
+	// 且只在用户重新输入时才提交该字段,因此掩码不会被当成新密钥写回。
+	if values[settings.KeyZeroSSLAPIKey] != "" {
+		values[settings.KeyZeroSSLAPIKey] = MaskedSecret
+	}
+	// LiteSSL 的 EAB HMAC 同理:属于证书申请凭据,只回掩码(EAB Kid 不是秘密,照常回显)
+	if values[settings.KeyLiteSSLEABHMAC] != "" {
+		values[settings.KeyLiteSSLEABHMAC] = MaskedSecret
+	}
+}
+
+func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
+	if values[settings.KeyNotifyType] == "" &&
+		values[settings.KeyNotifyEmailJSON] == "" &&
+		values[settings.KeyNotifyWebhookJSON] == "" &&
+		values[settings.KeyNotifyTelegramJSON] == "" &&
+		values[settings.KeyNotifySMTPPassword] == "" &&
+		values[settings.KeyNotifyWebhookSecret] == "" &&
+		values[settings.KeyNotifyTelegramToken] == "" {
+		return SaveInput{}, false
+	}
+
+	in := SaveInput{
+		Type:          NotifyType(values[settings.KeyNotifyType]),
+		SMTPPassword:  values[settings.KeyNotifySMTPPassword],
+		WebhookSecret: values[settings.KeyNotifyWebhookSecret],
+		TelegramToken: values[settings.KeyNotifyTelegramToken],
+	}
+	if raw := values[settings.KeyNotifyEmailJSON]; raw != "" {
+		_ = json.Unmarshal([]byte(raw), &in.Email)
+	}
+	if raw := values[settings.KeyNotifyWebhookJSON]; raw != "" {
+		_ = json.Unmarshal([]byte(raw), &in.Webhook)
+	}
+	if raw := values[settings.KeyNotifyTelegramJSON]; raw != "" {
+		_ = json.Unmarshal([]byte(raw), &in.Telegram)
+	}
+	in.OnDDNSIPChange = values[settings.KeyNotifyOnDDNSIPChange] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSIPChange], "true")
+	in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSFailure], "true")
+	if !in.OnDDNSFailure {
+		in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSError], "true")
+	}
+	in.OnCertExpiry = values[settings.KeyNotifyOnCertExpiry] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertExpiry], "true")
+	if !in.OnCertExpiry {
+		in.OnCertExpiry = values[settings.KeyNotifyOnCertError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertError], "true")
+	}
+	in.OnCertRenewSuccess = values[settings.KeyNotifyOnCertRenewSuccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewSuccess], "true")
+	in.OnIPFrequentAccess = values[settings.KeyNotifyOnIPFrequentAccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnIPFrequentAccess], "true")
+	in.OnCertRenewFailure = values[settings.KeyNotifyOnCertRenewFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewFailure], "true")
+	in.OnLoginFailure = values[settings.KeyNotifyOnLoginFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnLoginFailure], "true")
+	in.OnNginxReloadFailure = values[settings.KeyNotifyOnNginxReloadFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnNginxReloadFailure], "true")
+	in.IPFrequentThreshold = intSettingFromMap(values, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
+	in.IPFrequentWindowSec = intSettingFromMap(values, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
+	in.LoginFailureThreshold = intSettingFromMap(values, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
+	in.LoginFailureWindowSec = intSettingFromMap(values, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
+	in.OnResourceThreshold = values[settings.KeyNotifyOnResourceThreshold] == "1" || strings.EqualFold(values[settings.KeyNotifyOnResourceThreshold], "true")
+	in.OnTunnelDown = values[settings.KeyNotifyOnTunnelDown] == "1" || strings.EqualFold(values[settings.KeyNotifyOnTunnelDown], "true")
+	in.CPUThreshold = intSettingFromMap(values, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
+	in.MemoryThreshold = intSettingFromMap(values, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
+	in.DiskThreshold = intSettingFromMap(values, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
+	in.TunnelFailThreshold = intSettingFromMap(values, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
+	in.OnFrpsDown = values[settings.KeyNotifyOnFrpsDown] == "1" || strings.EqualFold(values[settings.KeyNotifyOnFrpsDown], "true")
+	in.OnAgentUnreachable = values[settings.KeyNotifyOnAgentUnreachable] == "1" || strings.EqualFold(values[settings.KeyNotifyOnAgentUnreachable], "true")
+	in.OnRouteDrift = values[settings.KeyNotifyOnRouteDrift] == "1" || strings.EqualFold(values[settings.KeyNotifyOnRouteDrift], "true")
+	in.OnBackupFailure = values[settings.KeyNotifyOnBackupFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnBackupFailure], "true")
+	in.FrpsFailThreshold = intSettingFromMap(values, settings.KeyNotifyFrpsFailThreshold, DefaultFrpsFailThreshold)
+	in.AgentFailThreshold = intSettingFromMap(values, settings.KeyNotifyAgentFailThreshold, DefaultAgentFailThreshold)
+	in.DriftCooldownHours = intSettingFromMap(values, settings.KeyNotifyDriftCooldownHours, DefaultDriftCooldownHours)
+	return in, true
+}
+
+// clampThreshold 把未配置或越界的阈值拉回合法范围:0/负数用默认值,超过 100 的按 100 处理
+// (百分比阈值与「连续失败次数」的上限都用这一条)。
+func clampThreshold(value, fallback int) int {
+	if value <= 0 {
+		return fallback
+	}
+	if value > 100 {
+		return 100
+	}
+	return value
+}
+
+func intSettingFromMap(values map[string]string, key string, fallback int) int {
+	raw := strings.TrimSpace(values[key])
+	if raw == "" {
+		return fallback
+	}
+	n, err := strconv.Atoi(raw)
+	if err != nil || n <= 0 {
+		return fallback
+	}
+	return n
+}
diff --git a/app-src/internal/notify/store_test.go b/app-src/internal/notify/store_test.go
new file mode 100644
index 0000000..8864cea
--- /dev/null
+++ b/app-src/internal/notify/store_test.go
@@ -0,0 +1,86 @@
+package notify
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+
+	"github.com/havline/havline/internal/db"
+	"github.com/havline/havline/internal/secret"
+	"github.com/havline/havline/internal/settings"
+)
+
+func TestStoreSaveTelegramPersistsType(t *testing.T) {
+	dir := t.TempDir()
+	conn, err := db.Open(filepath.Join(dir, "havline.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer conn.Close()
+	if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatal(err)
+	}
+
+	settingsStore := settings.NewStore(conn)
+	box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
+	if err != nil {
+		t.Fatal(err)
+	}
+	store := NewStore(settingsStore, box)
+	if err := store.Save(context.Background(), SaveInput{
+		Type: NotifyTypeTelegram,
+		Telegram: TelegramConfig{
+			ChatID:      "743955235",
+			ProxyURL:    "http://127.0.0.1:7890",
+			HasBotToken: true,
+		},
+		TelegramToken: "123456:ABC-DEF",
+	}); err != nil {
+		t.Fatal(err)
+	}
+
+	rawType, err := settingsStore.Get(context.Background(), settings.KeyNotifyType)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if rawType != "telegram" {
+		t.Fatalf("notify_type=%q, want telegram", rawType)
+	}
+	cfg, err := store.Load(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cfg.Type != NotifyTypeTelegram || cfg.Telegram.ChatID != "743955235" {
+		t.Fatalf("unexpected loaded config: %+v", cfg)
+	}
+}
+
+func TestStoreMigratesLegacyWebhookURL(t *testing.T) {
+	dir := t.TempDir()
+	conn, err := db.Open(filepath.Join(dir, "havline.db"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer conn.Close()
+	if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatal(err)
+	}
+
+	settingsStore := settings.NewStore(conn)
+	if err := settingsStore.Set(context.Background(), settings.KeyNotifyWebhookURL, "https://example.com/hook"); err != nil {
+		t.Fatal(err)
+	}
+
+	box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
+	if err != nil {
+		t.Fatal(err)
+	}
+	store := NewStore(settingsStore, box)
+	cfg, err := store.Load(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cfg.Type != NotifyTypeWebhook || cfg.Webhook.Provider != WebhookCustom || cfg.Webhook.URL != "https://example.com/hook" {
+		t.Fatalf("unexpected migrated config: %+v", cfg)
+	}
+}
diff --git a/app-src/internal/notify/telegram.go b/app-src/internal/notify/telegram.go
new file mode 100644
index 0000000..6939f89
--- /dev/null
+++ b/app-src/internal/notify/telegram.go
@@ -0,0 +1,78 @@
+package notify
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+func SendTelegram(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+	token := strings.TrimSpace(cfg.TelegramToken)
+	if token == "" {
+		return fmt.Errorf("Telegram Bot Token 未配置")
+	}
+	chatID := strings.TrimSpace(cfg.Telegram.ChatID)
+	if chatID == "" {
+		return fmt.Errorf("Telegram Chat ID 未配置")
+	}
+
+	payload := map[string]string{
+		"chat_id":    chatID,
+		"text":       content.HTMLBody,
+		"parse_mode": "HTML",
+	}
+	body, _ := json.Marshal(payload)
+	endpoint := "https://api.telegram.org/bot" + token + "/sendMessage"
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	httpClient := client
+	if httpClient == nil {
+		httpClient = &http.Client{Timeout: 15 * time.Second}
+	}
+	if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" {
+		// 常见误填:只写 host:port(无 scheme)。补全后再解析,并校验 scheme 是否受支持
+		if !strings.Contains(proxyURL, "://") {
+			proxyURL = "http://" + proxyURL
+		}
+		parsed, err := url.Parse(proxyURL)
+		if err != nil {
+			return fmt.Errorf("Telegram 代理地址无效:%s", proxyURL)
+		}
+		switch parsed.Scheme {
+		case "http", "https", "socks5", "socks5h":
+		default:
+			return fmt.Errorf("Telegram 代理仅支持 http / https / socks5,当前为 %q", parsed.Scheme)
+		}
+		httpClient = &http.Client{
+			Timeout: 15 * time.Second,
+			Transport: &http.Transport{
+				Proxy: http.ProxyURL(parsed),
+			},
+		}
+	}
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		if len(raw) > 0 {
+			return fmt.Errorf("Telegram 请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
+		}
+		return fmt.Errorf("Telegram 请求失败 (%d)", resp.StatusCode)
+	}
+	return nil
+}
diff --git a/app-src/internal/proxy/custom_endpoints.go b/app-src/internal/proxy/custom_endpoints.go
new file mode 100644
index 0000000..33b10c4
--- /dev/null
+++ b/app-src/internal/proxy/custom_endpoints.go
@@ -0,0 +1,39 @@
+package proxy
+
+import (
+	"context"
+)
+
+// ReplaceCustomEndpoints 用解析出的占用集合替换该规则在 proxy_custom_endpoints 里的记录。
+// 手动(custom)Nginx 规则保存或回滚成功后调用,使其占用对其它规则的保存校验可见。
+func (s *Store) ReplaceCustomEndpoints(ctx context.Context, ruleID int64, endpoints []Endpoint) error {
+	if err := s.ClearCustomEndpoints(ctx, ruleID); err != nil {
+		return err
+	}
+	for _, endpoint := range endpoints {
+		if _, err := s.querier().ExecContext(ctx, `
+			INSERT INTO proxy_custom_endpoints(rule_id, hostname, listen_port)
+			VALUES (?, ?, ?)
+		`, ruleID, endpoint.Hostname, endpoint.Port); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ClearCustomEndpoints 清除该规则的手动配置占用(切回自动模式或删除规则时调用)
+func (s *Store) ClearCustomEndpoints(ctx context.Context, ruleID int64) error {
+	_, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_custom_endpoints WHERE rule_id = ?`, ruleID)
+	return err
+}
+
+// EnsureEndpointsAvailable 校验一批 (域名, 端口) 是否已被其它规则占用,
+// 用于手动 Nginx 配置的保存前检查(与表单规则的校验共用同一条判定)。
+func (s *Store) EnsureEndpointsAvailable(ctx context.Context, endpoints []Endpoint, excludeRuleID int64) error {
+	for _, endpoint := range endpoints {
+		if err := s.ensureHostAvailable(ctx, endpoint.Hostname, endpoint.Port, excludeRuleID); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/app-src/internal/proxy/custom_endpoints_test.go b/app-src/internal/proxy/custom_endpoints_test.go
new file mode 100644
index 0000000..421aafd
--- /dev/null
+++ b/app-src/internal/proxy/custom_endpoints_test.go
@@ -0,0 +1,109 @@
+package proxy
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	_ "modernc.org/sqlite"
+
+	"github.com/havline/havline/internal/db"
+)
+
+func newTestStore(t *testing.T) (*Store, *sql.DB) {
+	t.Helper()
+	ctx := context.Background()
+	name := strings.ReplaceAll(t.Name(), "/", "_")
+	conn, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=memory&cache=shared&_pragma=foreign_keys(1)", name))
+	if err != nil {
+		t.Fatalf("open test db: %v", err)
+	}
+	conn.SetMaxOpenConns(1)
+	if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil {
+		t.Fatalf("migrate test db: %v", err)
+	}
+	t.Cleanup(func() { _ = conn.Close() })
+	return NewStore(conn), conn
+}
+
+func createTestRule(t *testing.T, store *Store, name, host string, port int) Rule {
+	t.Helper()
+	rule, err := store.Create(context.Background(), CreateInput{
+		Upstream:   "http://192.168.1.10:8080",
+		ListenPort: port,
+		ListenIPv4: true,
+		Hosts:      []string{host},
+		Enabled:    true,
+		Name:       name,
+	})
+	if err != nil {
+		t.Fatalf("create rule %s: %v", name, err)
+	}
+	return rule
+}
+
+func TestEnsureEndpointsAvailableNamesOwningRule(t *testing.T) {
+	store, _ := newTestStore(t)
+	ctx := context.Background()
+	owner := createTestRule(t, store, "官网", "a.com", 8011)
+
+	err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, 0)
+	if err == nil {
+		t.Fatal("expected conflict error")
+	}
+	if !strings.Contains(err.Error(), "规则「官网」") {
+		t.Fatalf("expected owning rule name in error, got %q", err.Error())
+	}
+
+	// 编辑自身不应被判为冲突
+	if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, owner.ID); err != nil {
+		t.Fatalf("exclude self should pass, got %v", err)
+	}
+	// 同域名不同端口是合法虚拟主机
+	if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8012}}, 0); err != nil {
+		t.Fatalf("different port should pass, got %v", err)
+	}
+}
+
+func TestEnsureEndpointsAvailableSeesCustomEndpoints(t *testing.T) {
+	store, conn := newTestStore(t)
+	ctx := context.Background()
+	custom := createTestRule(t, store, "手动规则", "b.com", 8011)
+	if err := store.ReplaceCustomEndpoints(ctx, custom.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
+		t.Fatalf("replace custom endpoints: %v", err)
+	}
+
+	err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0)
+	if err == nil {
+		t.Fatal("expected conflict with custom endpoints")
+	}
+	if !strings.Contains(err.Error(), "手动 Nginx 配置") {
+		t.Fatalf("expected custom-rule hint in error, got %q", err.Error())
+	}
+
+	// 删除规则后占用记录随外键级联清除
+	if _, err := conn.ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, custom.ID); err != nil {
+		t.Fatalf("delete rule: %v", err)
+	}
+	if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
+		t.Fatalf("cascade should free the binding, got %v", err)
+	}
+}
+
+func TestClearCustomEndpoints(t *testing.T) {
+	store, _ := newTestStore(t)
+	ctx := context.Background()
+	rule := createTestRule(t, store, "手动规则", "b.com", 8011)
+	if err := store.ReplaceCustomEndpoints(ctx, rule.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
+		t.Fatalf("replace custom endpoints: %v", err)
+	}
+	if err := store.ClearCustomEndpoints(ctx, rule.ID); err != nil {
+		t.Fatalf("clear custom endpoints: %v", err)
+	}
+	if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
+		t.Fatalf("cleared binding should be free, got %v", err)
+	}
+}
diff --git a/app-src/internal/proxy/exits_store_test.go b/app-src/internal/proxy/exits_store_test.go
new file mode 100644
index 0000000..9cc1a38
--- /dev/null
+++ b/app-src/internal/proxy/exits_store_test.go
@@ -0,0 +1,55 @@
+package proxy
+
+import (
+	"context"
+	"testing"
+)
+
+func TestStorePersistsExitsAndFiltersLocal(t *testing.T) {
+	store, _ := newTestStore(t)
+	ctx := context.Background()
+	rule, err := store.Create(ctx, CreateInput{
+		Upstream:   "http://192.168.1.10:8080",
+		ListenPort: 8443,
+		ListenIPv4: true,
+		Hosts:      []string{"cf.example.com"},
+		Enabled:    true,
+		Exits:      []string{ExitCloudflare},
+		CFTunnelID: 7,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if rule.CFTunnelID != 7 || len(rule.Exits) != 1 || rule.Exits[0] != ExitCloudflare {
+		t.Fatalf("Cloudflare 出口未持久化:%#v", rule)
+	}
+	localRules, err := store.ListEnabledLocal(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(localRules) != 0 {
+		t.Fatalf("仅 Cloudflare 规则不应生成本机 Nginx:%#v", localRules)
+	}
+
+	enabled := true
+	updated, err := store.Update(ctx, rule.ID, UpdateInput{
+		Exits:      &[]string{ExitLocal, ExitCloudflare},
+		Enabled:    &enabled,
+		CFTunnelID: int64Ptr(7),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(updated.Exits) != 2 {
+		t.Fatalf("本机 + Cloudflare 出口未保存:%#v", updated.Exits)
+	}
+	localRules, err = store.ListEnabledLocal(ctx)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(localRules) != 1 || localRules[0].ID != rule.ID {
+		t.Fatalf("启用本机出口后应进入 Nginx 规则:%#v", localRules)
+	}
+}
+
+func int64Ptr(v int64) *int64 { return &v }
diff --git a/app-src/internal/proxy/exits_test.go b/app-src/internal/proxy/exits_test.go
new file mode 100644
index 0000000..e726678
--- /dev/null
+++ b/app-src/internal/proxy/exits_test.go
@@ -0,0 +1,39 @@
+package proxy
+
+import "testing"
+
+func TestNormalizeExitsDefaultsToLocal(t *testing.T) {
+	exits, tunnelID, err := normalizeExits(nil, 0)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if tunnelID != 0 || len(exits) != 1 || exits[0] != ExitLocal {
+		t.Fatalf("默认出口应为 local:%#v tunnel=%d", exits, tunnelID)
+	}
+}
+
+func TestNormalizeExitsRequiresTunnelForCloudflare(t *testing.T) {
+	if _, _, err := normalizeExits([]string{ExitCloudflare}, 0); err == nil {
+		t.Fatal("Cloudflare 出口缺少隧道时应报错")
+	}
+}
+
+func TestNormalizeExitsDeduplicatesAndSorts(t *testing.T) {
+	exits, tunnelID, err := normalizeExits([]string{ExitCloudflare, ExitLocal, ExitCloudflare}, 12)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if tunnelID != 12 || len(exits) != 2 || exits[0] != ExitCloudflare || exits[1] != ExitLocal {
+		t.Fatalf("出口去重排序不符:%#v tunnel=%d", exits, tunnelID)
+	}
+}
+
+func TestRuleUsesExitTreatsEmptyAsLocal(t *testing.T) {
+	rule := Rule{}
+	if !rule.UsesExit(ExitLocal) {
+		t.Fatal("空出口应兼容为本机出口")
+	}
+	if rule.UsesExit(ExitCloudflare) {
+		t.Fatal("空出口不应被视为 Cloudflare")
+	}
+}
diff --git a/app-src/internal/proxy/proxy.go b/app-src/internal/proxy/proxy.go
new file mode 100644
index 0000000..c2ef1bb
--- /dev/null
+++ b/app-src/internal/proxy/proxy.go
@@ -0,0 +1,919 @@
+package proxy
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/havline/havline/internal/validate"
+)
+
+const (
+	ExitLocal      = "local"
+	ExitCloudflare = "cloudflare"
+)
+
+type Host struct {
+	ID         int64  `json:"id"`
+	Hostname   string `json:"hostname"`
+	ListenPort *int   `json:"listen_port,omitempty"`
+}
+
+type Rule struct {
+	ID           int64          `json:"id"`
+	Domain       string         `json:"domain"`
+	Upstream     string         `json:"upstream"`
+	ListenPort   int            `json:"listen_port"`
+	ListenIPv4   bool           `json:"listen_ipv4"`
+	ListenIPv6   bool           `json:"listen_ipv6"`
+	Hosts        []Host         `json:"hosts"`
+	HTTPSEnabled bool           `json:"https_enabled"`
+	HTTPRedirect bool           `json:"http_redirect"`
+	Enabled      bool           `json:"enabled"`
+	NginxMode    string         `json:"nginx_mode"`
+	Name         string         `json:"name"`
+	SortOrder    int            `json:"sort_order"`
+	Security     SecurityConfig `json:"security"`
+	Exits        []string       `json:"exits"`
+	CFTunnelID   int64          `json:"cf_tunnel_id"`
+	SyncWarning  string         `json:"sync_warning,omitempty"`
+	CreatedAt    time.Time      `json:"created_at"`
+	UpdatedAt    time.Time      `json:"updated_at"`
+}
+
+type PortGroup struct {
+	Port      int
+	Hostnames []string
+}
+
+func (r Rule) PrimaryHost() string {
+	if len(r.Hosts) > 0 {
+		return r.Hosts[0].Hostname
+	}
+	return ""
+}
+
+type Endpoint struct {
+	Hostname string
+	Port     int
+}
+
+func (r Rule) Endpoints() []Endpoint {
+	seen := make(map[string]bool)
+	var out []Endpoint
+	for _, host := range r.Hosts {
+		name := strings.ToLower(strings.TrimSpace(host.Hostname))
+		if name == "" {
+			continue
+		}
+		port := hostEffectivePort(host, r.ListenPort)
+		key := fmt.Sprintf("%s:%d", name, port)
+		if seen[key] {
+			continue
+		}
+		seen[key] = true
+		out = append(out, Endpoint{Hostname: name, Port: port})
+	}
+	if len(out) == 0 {
+		domain := strings.ToLower(strings.TrimSpace(r.Domain))
+		if domain != "" {
+			out = append(out, Endpoint{Hostname: domain, Port: r.ListenPort})
+		}
+	}
+	return out
+}
+
+func (r Rule) Hostnames() []string {
+	seen := make(map[string]bool)
+	var names []string
+	for _, host := range r.Hosts {
+		name := strings.ToLower(strings.TrimSpace(host.Hostname))
+		if name == "" || seen[name] {
+			continue
+		}
+		seen[name] = true
+		names = append(names, name)
+	}
+	if len(names) == 0 {
+		domain := strings.ToLower(strings.TrimSpace(r.Domain))
+		if domain != "" {
+			names = append(names, domain)
+		}
+	}
+	return names
+}
+
+// UsesExit 判断规则是否启用了指定出口;空值按旧数据兼容为本机出口。
+func (r Rule) UsesExit(exit string) bool {
+	if len(r.Exits) == 0 {
+		return exit == ExitLocal
+	}
+	for _, item := range r.Exits {
+		if item == exit {
+			return true
+		}
+	}
+	return false
+}
+
+// HasExit 判断出口列表是否包含指定出口;空值按旧数据兼容为本机出口。
+func HasExit(exits []string, exit string) bool {
+	return Rule{Exits: exits}.UsesExit(exit)
+}
+
+// LocalEnabled 判断规则当前是否需要生成本机 Nginx 配置。
+func (r Rule) LocalEnabled() bool {
+	return r.Enabled && r.UsesExit(ExitLocal)
+}
+
+// CloudflareEnabled 判断规则当前是否需要同步到 Cloudflare 隧道。
+func (r Rule) CloudflareEnabled() bool {
+	return r.Enabled && r.UsesExit(ExitCloudflare) && r.CFTunnelID > 0
+}
+
+func (r Rule) PortGroups() []PortGroup {
+	groups := map[int][]string{}
+	ports := make([]int, 0)
+	for _, host := range r.Hosts {
+		port := r.ListenPort
+		if host.ListenPort != nil && *host.ListenPort > 0 {
+			port = *host.ListenPort
+		}
+		if _, ok := groups[port]; !ok {
+			ports = append(ports, port)
+		}
+		groups[port] = append(groups[port], host.Hostname)
+	}
+
+	out := make([]PortGroup, 0, len(ports))
+	for _, port := range ports {
+		out = append(out, PortGroup{Port: port, Hostnames: groups[port]})
+	}
+	return out
+}
+
+type CreateInput struct {
+	Upstream     string
+	ListenPort   int
+	ListenIPv4   bool
+	ListenIPv6   bool
+	Hosts        []string
+	HTTPSEnabled bool
+	HTTPRedirect bool
+	Enabled      bool
+	Name         string
+	Security     SecurityInput
+	Exits        []string
+	CFTunnelID   int64
+}
+
+type UpdateInput struct {
+	Upstream     *string
+	ListenPort   *int
+	ListenIPv4   *bool
+	ListenIPv6   *bool
+	Hosts        *[]string
+	HTTPSEnabled *bool
+	HTTPRedirect *bool
+	Enabled      *bool
+	Name         *string
+	Security     *SecurityInput
+	Exits        *[]string
+	CFTunnelID   *int64
+}
+
+type Store struct {
+	db *sql.DB
+	tx *sql.Tx
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func NewStoreWithTx(tx *sql.Tx) *Store {
+	return &Store{tx: tx}
+}
+
+func (s *Store) querier() interface {
+	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
+	QueryRowContext(context.Context, string, ...any) *sql.Row
+	ExecContext(context.Context, string, ...any) (sql.Result, error)
+} {
+	if s.tx != nil {
+		return s.tx
+	}
+	return s.db
+}
+
+func (s *Store) List(ctx context.Context) ([]Rule, error) {
+	if err := s.cleanupOrphanRules(ctx); err != nil {
+		return nil, err
+	}
+	q := s.querier()
+	rows, err := q.QueryContext(ctx, `
+		SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
+		FROM proxy_rules
+		ORDER BY sort_order ASC, id ASC
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var rules []Rule
+	for rows.Next() {
+		rule, err := scanRule(rows)
+		if err != nil {
+			return nil, err
+		}
+		rules = append(rules, rule)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return s.attachHosts(ctx, rules)
+}
+
+func (s *Store) Get(ctx context.Context, id int64) (Rule, error) {
+	row := s.querier().QueryRowContext(ctx, `
+		SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
+		FROM proxy_rules WHERE id = ?
+	`, id)
+	rule, err := scanRule(row)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Rule{}, fmt.Errorf("规则不存在")
+	}
+	if err != nil {
+		return Rule{}, err
+	}
+	rules, err := s.attachHosts(ctx, []Rule{rule})
+	if err != nil {
+		return Rule{}, err
+	}
+	return rules[0], nil
+}
+
+func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
+	upstream, hosts, err := validateCreateInput(in)
+	if err != nil {
+		return Rule{}, err
+	}
+	if err := s.ensureHostsAvailable(ctx, hosts, in.ListenPort, 0); err != nil {
+		return Rule{}, err
+	}
+
+	name, err := normalizeName(in.Name)
+	if err != nil {
+		return Rule{}, err
+	}
+	sortOrder, err := s.nextSortOrder(ctx)
+	if err != nil {
+		return Rule{}, err
+	}
+
+	security, err := MergeSecurity(DefaultSecurityConfig(), in.Security)
+	if err != nil {
+		return Rule{}, err
+	}
+	securityJSON, err := security.ToJSON()
+	if err != nil {
+		return Rule{}, err
+	}
+	exits, cfTunnelID, err := normalizeExits(in.Exits, in.CFTunnelID)
+	if err != nil {
+		return Rule{}, err
+	}
+	exitsJSON, err := marshalExits(exits)
+	if err != nil {
+		return Rule{}, err
+	}
+
+	res, err := s.querier().ExecContext(ctx, `
+		INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, exits_json, cf_tunnel_id, updated_at)
+		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+	`, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON, exitsJSON, cfTunnelID)
+	if err != nil {
+		return Rule{}, err
+	}
+	id, err := res.LastInsertId()
+	if err != nil {
+		return Rule{}, err
+	}
+	if err := s.replaceHosts(ctx, id, in.ListenPort, hosts); err != nil {
+		_, _ = s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
+		return Rule{}, err
+	}
+	return s.Get(ctx, id)
+}
+
+func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, error) {
+	current, err := s.Get(ctx, id)
+	if err != nil {
+		return Rule{}, err
+	}
+
+	oldListenPort := current.ListenPort
+	upstream := current.Upstream
+	listenPort := current.ListenPort
+	listenIPv4 := current.ListenIPv4
+	listenIPv6 := current.ListenIPv6
+	httpsEnabled := current.HTTPSEnabled
+	httpRedirect := current.HTTPRedirect
+	enabled := current.Enabled
+	name := current.Name
+	hosts := current.Hosts
+	exits := current.Exits
+	cfTunnelID := current.CFTunnelID
+
+	if in.Upstream != nil {
+		upstream, err = validate.Upstream(*in.Upstream)
+		if err != nil {
+			return Rule{}, err
+		}
+	}
+	if in.ListenPort != nil {
+		if err := validate.ListenPort(*in.ListenPort); err != nil {
+			return Rule{}, err
+		}
+		listenPort = *in.ListenPort
+	}
+	if in.ListenIPv4 != nil {
+		listenIPv4 = *in.ListenIPv4
+	}
+	if in.ListenIPv6 != nil {
+		listenIPv6 = *in.ListenIPv6
+	}
+	if !listenIPv4 && !listenIPv6 {
+		return Rule{}, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
+	}
+	if in.HTTPSEnabled != nil {
+		httpsEnabled = *in.HTTPSEnabled
+	}
+	if in.HTTPRedirect != nil {
+		httpRedirect = *in.HTTPRedirect
+	}
+	if in.Enabled != nil {
+		enabled = *in.Enabled
+	}
+	if in.Name != nil {
+		name, err = normalizeName(*in.Name)
+		if err != nil {
+			return Rule{}, err
+		}
+	}
+	if in.Exits != nil || in.CFTunnelID != nil {
+		rawExits := exits
+		if in.Exits != nil {
+			rawExits = *in.Exits
+		}
+		rawTunnelID := cfTunnelID
+		if in.CFTunnelID != nil {
+			rawTunnelID = *in.CFTunnelID
+		}
+		exits, cfTunnelID, err = normalizeExits(rawExits, rawTunnelID)
+		if err != nil {
+			return Rule{}, err
+		}
+	}
+	if in.Hosts != nil {
+		hosts, err = parseHosts(*in.Hosts, listenPort)
+		if err != nil {
+			return Rule{}, err
+		}
+		if len(hosts) == 0 {
+			return Rule{}, fmt.Errorf("至少需要一个前端域名")
+		}
+	}
+
+	security := current.Security
+	if in.Security != nil {
+		security, err = MergeSecurity(current.Security, *in.Security)
+		if err != nil {
+			return Rule{}, err
+		}
+	}
+	securityJSON, err := security.ToJSON()
+	if err != nil {
+		return Rule{}, err
+	}
+	exitsJSON, err := marshalExits(exits)
+	if err != nil {
+		return Rule{}, err
+	}
+
+	if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
+		for _, host := range current.Hosts {
+			if hostEffectivePort(host, oldListenPort) != oldListenPort {
+				continue
+			}
+			if err := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); err != nil {
+				return Rule{}, err
+			}
+		}
+	}
+
+	_, err = s.querier().ExecContext(ctx, `
+		UPDATE proxy_rules
+		SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, exits_json = ?, cf_tunnel_id = ?, updated_at = datetime('now')
+		WHERE id = ?
+	`, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, exitsJSON, cfTunnelID, id)
+	if err != nil {
+		return Rule{}, err
+	}
+	if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
+		if _, err := s.querier().ExecContext(ctx, `
+			UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ?
+		`, listenPort, id, oldListenPort); err != nil {
+			if isUniqueViolation(err) {
+				// 保存前校验与写入之间存在窗口,被并发规则抢占时按域名复检并给出带规则名的原因
+				for _, host := range current.Hosts {
+					if hostEffectivePort(host, oldListenPort) != oldListenPort {
+						continue
+					}
+					if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); ownerErr != nil {
+						return Rule{}, ownerErr
+					}
+				}
+			}
+			return Rule{}, err
+		}
+	}
+	if in.Hosts != nil {
+		if err := s.ensureHostsAvailable(ctx, hosts, listenPort, id); err != nil {
+			return Rule{}, err
+		}
+		if err := s.replaceHosts(ctx, id, listenPort, hosts); err != nil {
+			return Rule{}, err
+		}
+	}
+	return s.Get(ctx, id)
+}
+
+func (s *Store) SetNginxMode(ctx context.Context, id int64, mode string) error {
+	mode = strings.TrimSpace(mode)
+	if mode != "auto" && mode != "custom" {
+		return fmt.Errorf("无效的 nginx 模式")
+	}
+	res, err := s.querier().ExecContext(ctx, `
+		UPDATE proxy_rules SET nginx_mode = ?, updated_at = datetime('now') WHERE id = ?
+	`, mode, id)
+	if err != nil {
+		return err
+	}
+	n, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if n == 0 {
+		return fmt.Errorf("规则不存在")
+	}
+	return nil
+}
+
+func (s *Store) Delete(ctx context.Context, id int64) error {
+	res, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
+	if err != nil {
+		return err
+	}
+	n, err := res.RowsAffected()
+	if err != nil {
+		return err
+	}
+	if n == 0 {
+		return fmt.Errorf("规则不存在")
+	}
+	return nil
+}
+
+func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
+	rows, err := s.querier().QueryContext(ctx, `
+		SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, COALESCE(exits_json, '["local"]'), COALESCE(cf_tunnel_id, 0), created_at, updated_at
+		FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var rules []Rule
+	for rows.Next() {
+		rule, err := scanRule(rows)
+		if err != nil {
+			return nil, err
+		}
+		rules = append(rules, rule)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return s.attachHosts(ctx, rules)
+}
+
+// ListEnabledLocal 只返回需要生成本机 Nginx 配置的启用规则。
+func (s *Store) ListEnabledLocal(ctx context.Context) ([]Rule, error) {
+	rules, err := s.ListEnabled(ctx)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]Rule, 0, len(rules))
+	for _, rule := range rules {
+		if rule.UsesExit(ExitLocal) {
+			out = append(out, rule)
+		}
+	}
+	return out, nil
+}
+
+func validateCreateInput(in CreateInput) (string, []Host, error) {
+	upstream, err := validate.Upstream(in.Upstream)
+	if err != nil {
+		return "", nil, err
+	}
+	if err := validate.ListenPort(in.ListenPort); err != nil {
+		return "", nil, err
+	}
+	if !in.ListenIPv4 && !in.ListenIPv6 {
+		return "", nil, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
+	}
+	hosts, err := parseHosts(in.Hosts, in.ListenPort)
+	if err != nil {
+		return "", nil, err
+	}
+	if len(hosts) == 0 {
+		return "", nil, fmt.Errorf("至少需要一个前端域名")
+	}
+	return upstream, hosts, nil
+}
+
+func (s *Store) cleanupOrphanRules(ctx context.Context) error {
+	_, err := s.querier().ExecContext(ctx, `
+		DELETE FROM proxy_rules
+		WHERE id NOT IN (SELECT rule_id FROM proxy_hosts)
+	`)
+	return err
+}
+
+// isUniqueViolation 判断是否为 SQLite 唯一约束冲突(hostname + listen_port 撞车)
+func isUniqueViolation(err error) bool {
+	return err != nil && strings.Contains(strings.ToUpper(err.Error()), "UNIQUE")
+}
+
+func hostEffectivePort(host Host, ruleListenPort int) int {
+	if host.ListenPort != nil && *host.ListenPort > 0 {
+		return *host.ListenPort
+	}
+	return ruleListenPort
+}
+
+func parseHosts(raw []string, ruleListenPort int) ([]Host, error) {
+	seen := make(map[string]struct{})
+	hosts := make([]Host, 0, len(raw))
+	for _, item := range raw {
+		hostname, port, err := validate.FrontendAddress(item)
+		if err != nil {
+			return nil, err
+		}
+		effectivePort := ruleListenPort
+		if port > 0 {
+			effectivePort = port
+		}
+		key := fmt.Sprintf("%s:%d", strings.ToLower(hostname), effectivePort)
+		if _, ok := seen[key]; ok {
+			return nil, fmt.Errorf("前端域名 %s:%d 重复", hostname, effectivePort)
+		}
+		seen[key] = struct{}{}
+		host := Host{Hostname: hostname}
+		if port > 0 {
+			host.ListenPort = &port
+		}
+		hosts = append(hosts, host)
+	}
+	return hosts, nil
+}
+
+func (s *Store) ensureHostAvailable(ctx context.Context, hostname string, port int, excludeRuleID int64) error {
+	owner, err := s.endpointOwner(ctx, "proxy_hosts", hostname, port, excludeRuleID)
+	if err != nil {
+		return err
+	}
+	if owner != "" {
+		return fmt.Errorf("域名 %s:%d 已被%s使用,请编辑现有规则或更换域名/端口", hostname, port, owner)
+	}
+	// 手动(custom)Nginx 规则的占用单独存表,其域名/端口不在 proxy_hosts 里,需一并检查
+	owner, err = s.endpointOwner(ctx, "proxy_custom_endpoints", hostname, port, excludeRuleID)
+	if err != nil {
+		return err
+	}
+	if owner != "" {
+		return fmt.Errorf("域名 %s:%d 已被%s使用(该规则使用手动 Nginx 配置),请编辑现有规则或更换域名/端口", hostname, port, owner)
+	}
+	return nil
+}
+
+// endpointOwner 返回占用该 (域名, 端口) 的规则描述(如 规则「名称」);空闲时返回空串。
+// table 只传入本包内两张固定表名(proxy_hosts / proxy_custom_endpoints),不接受外部输入。
+// 规则描述优先用备注名,其次用该规则的首个域名,最后回落为规则 ID。
+func (s *Store) endpointOwner(ctx context.Context, table, hostname string, port int, excludeRuleID int64) (string, error) {
+	var (
+		ownerID   int64
+		ownerDesc string
+	)
+	query := `SELECT h.rule_id,
+			COALESCE(NULLIF(TRIM(r.name), ''),
+				(SELECT hostname FROM proxy_hosts WHERE rule_id = h.rule_id ORDER BY id LIMIT 1), '')
+		FROM ` + table + ` h
+		LEFT JOIN proxy_rules r ON r.id = h.rule_id
+		WHERE h.hostname = ? COLLATE NOCASE AND h.listen_port = ? AND h.rule_id != ?
+		LIMIT 1`
+	err := s.querier().QueryRowContext(ctx, query, hostname, port, excludeRuleID).Scan(&ownerID, &ownerDesc)
+	if errors.Is(err, sql.ErrNoRows) {
+		return "", nil
+	}
+	if err != nil {
+		return "", err
+	}
+	if desc := strings.TrimSpace(ownerDesc); desc != "" {
+		return fmt.Sprintf("规则「%s」", desc), nil
+	}
+	return fmt.Sprintf("规则 #%d", ownerID), nil
+}
+
+func (s *Store) ensureHostsAvailable(ctx context.Context, hosts []Host, ruleListenPort int, excludeRuleID int64) error {
+	for _, host := range hosts {
+		port := hostEffectivePort(host, ruleListenPort)
+		if err := s.ensureHostAvailable(ctx, host.Hostname, port, excludeRuleID); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Store) replaceHosts(ctx context.Context, ruleID int64, ruleListenPort int, hosts []Host) error {
+	if _, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_hosts WHERE rule_id = ?`, ruleID); err != nil {
+		return err
+	}
+	for _, host := range hosts {
+		port := hostEffectivePort(host, ruleListenPort)
+		_, err := s.querier().ExecContext(ctx, `
+			INSERT INTO proxy_hosts(rule_id, hostname, listen_port)
+			VALUES (?, ?, ?)
+		`, ruleID, host.Hostname, port)
+		if err != nil {
+			if isUniqueViolation(err) {
+				// 并发下可能绕过保存前校验:复检一次以给出带规则名的原因
+				if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, port, ruleID); ownerErr != nil {
+					return ownerErr
+				}
+				return fmt.Errorf("域名 %s:%d 已被其它规则占用", host.Hostname, port)
+			}
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *Store) attachHosts(ctx context.Context, rules []Rule) ([]Rule, error) {
+	if len(rules) == 0 {
+		return rules, nil
+	}
+	ids := make([]string, len(rules))
+	args := make([]any, len(rules))
+	for i, rule := range rules {
+		ids[i] = "?"
+		args[i] = rule.ID
+	}
+
+	query := `
+		SELECT id, rule_id, hostname, listen_port
+		FROM proxy_hosts
+		WHERE rule_id IN (` + strings.Join(ids, ",") + `)
+		ORDER BY id ASC
+	`
+	rows, err := s.querier().QueryContext(ctx, query, args...)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	byRule := make(map[int64][]Host)
+	for rows.Next() {
+		var host Host
+		var ruleID int64
+		var listenPort sql.NullInt64
+		if err := rows.Scan(&host.ID, &ruleID, &host.Hostname, &listenPort); err != nil {
+			return nil, err
+		}
+		if listenPort.Valid {
+			port := int(listenPort.Int64)
+			host.ListenPort = &port
+		}
+		byRule[ruleID] = append(byRule[ruleID], host)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+
+	for i := range rules {
+		rules[i].Hosts = byRule[rules[i].ID]
+		rules[i].Domain = rules[i].PrimaryHost()
+	}
+	return rules, nil
+}
+
+type rowScanner interface {
+	Scan(dest ...any) error
+}
+
+func scanRule(row rowScanner) (Rule, error) {
+	var rule Rule
+	var listenIPv4 int
+	var listenIPv6 int
+	var httpsEnabled int
+	var httpRedirect int
+	var enabled int
+	var nginxMode string
+	var securityJSON string
+	var exitsJSON string
+	var cfTunnelID int64
+	var createdAt string
+	var updatedAt string
+	if err := row.Scan(
+		&rule.ID,
+		&rule.Upstream,
+		&rule.ListenPort,
+		&listenIPv4,
+		&listenIPv6,
+		&httpsEnabled,
+		&httpRedirect,
+		&enabled,
+		&nginxMode,
+		&rule.Name,
+		&rule.SortOrder,
+		&securityJSON,
+		&exitsJSON,
+		&cfTunnelID,
+		&createdAt,
+		&updatedAt,
+	); err != nil {
+		return Rule{}, err
+	}
+	rule.ListenIPv4 = listenIPv4 == 1
+	rule.ListenIPv6 = listenIPv6 == 1
+	rule.HTTPSEnabled = httpsEnabled == 1
+	rule.HTTPRedirect = httpRedirect == 1
+	rule.Enabled = enabled == 1
+	if nginxMode == "" {
+		nginxMode = "auto"
+	}
+	rule.NginxMode = nginxMode
+	security, err := ParseSecurityJSON(securityJSON)
+	if err != nil {
+		return Rule{}, err
+	}
+	rule.Security = security
+	rule.Exits = parseExits(exitsJSON)
+	rule.CFTunnelID = cfTunnelID
+	rule.CreatedAt = parseTime(createdAt)
+	rule.UpdatedAt = parseTime(updatedAt)
+	return rule, nil
+}
+
+func normalizeExits(raw []string, cfTunnelID int64) ([]string, int64, error) {
+	if len(raw) == 0 {
+		raw = []string{ExitLocal}
+	}
+	seen := make(map[string]struct{}, len(raw))
+	out := make([]string, 0, len(raw))
+	for _, item := range raw {
+		exit := strings.ToLower(strings.TrimSpace(item))
+		switch exit {
+		case ExitLocal, ExitCloudflare:
+		default:
+			return nil, 0, fmt.Errorf("不支持的出口: %s", item)
+		}
+		if _, ok := seen[exit]; ok {
+			continue
+		}
+		seen[exit] = struct{}{}
+		out = append(out, exit)
+	}
+	sort.Strings(out)
+	_, usesCloudflare := seen[ExitCloudflare]
+	if usesCloudflare && cfTunnelID <= 0 {
+		return nil, 0, fmt.Errorf("选择 Cloudflare 出口时需要指定隧道")
+	}
+	if !usesCloudflare {
+		cfTunnelID = 0
+	}
+	return out, cfTunnelID, nil
+}
+
+func marshalExits(exits []string) (string, error) {
+	normalized, _, err := normalizeExits(exits, 1)
+	if err != nil {
+		return "", err
+	}
+	data, err := json.Marshal(normalized)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func parseExits(raw string) []string {
+	var exits []string
+	if err := json.Unmarshal([]byte(raw), &exits); err != nil {
+		return []string{ExitLocal}
+	}
+	normalized, _, err := normalizeExits(exits, 1)
+	if err != nil {
+		return []string{ExitLocal}
+	}
+	return normalized
+}
+
+func parseTime(v string) time.Time {
+	t, err := time.Parse("2006-01-02 15:04:05", v)
+	if err != nil {
+		t, _ = time.Parse(time.RFC3339, v)
+	}
+	return t.UTC()
+}
+
+func boolInt(v bool) int {
+	if v {
+		return 1
+	}
+	return 0
+}
+
+func normalizeName(raw string) (string, error) {
+	name := strings.TrimSpace(raw)
+	if len(name) > 100 {
+		return "", fmt.Errorf("名称不能超过 100 个字符")
+	}
+	return name, nil
+}
+
+func (s *Store) nextSortOrder(ctx context.Context) (int, error) {
+	var maxOrder sql.NullInt64
+	err := s.querier().QueryRowContext(ctx, `SELECT MAX(sort_order) FROM proxy_rules`).Scan(&maxOrder)
+	if err != nil {
+		return 0, err
+	}
+	if !maxOrder.Valid {
+		return 0, nil
+	}
+	return int(maxOrder.Int64) + 1, nil
+}
+
+func (s *Store) Reorder(ctx context.Context, ids []int64) error {
+	if len(ids) == 0 {
+		return nil
+	}
+	seen := make(map[int64]struct{}, len(ids))
+	for _, id := range ids {
+		if id <= 0 {
+			return fmt.Errorf("无效的规则 ID")
+		}
+		if _, ok := seen[id]; ok {
+			return fmt.Errorf("排序列表包含重复的规则 ID")
+		}
+		seen[id] = struct{}{}
+	}
+
+	var total int
+	if err := s.querier().QueryRowContext(ctx, `SELECT COUNT(*) FROM proxy_rules`).Scan(&total); err != nil {
+		return err
+	}
+	if len(ids) != total {
+		return fmt.Errorf("排序列表必须包含全部规则")
+	}
+
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+
+	for index, id := range ids {
+		res, err := tx.ExecContext(ctx, `UPDATE proxy_rules SET sort_order = ?, updated_at = datetime('now') WHERE id = ?`, index, id)
+		if err != nil {
+			return err
+		}
+		n, err := res.RowsAffected()
+		if err != nil {
+			return err
+		}
+		if n == 0 {
+			return fmt.Errorf("规则不存在")
+		}
+	}
+	return tx.Commit()
+}
diff --git a/app-src/internal/proxy/proxy_endpoints_test.go b/app-src/internal/proxy/proxy_endpoints_test.go
new file mode 100644
index 0000000..c14ec3f
--- /dev/null
+++ b/app-src/internal/proxy/proxy_endpoints_test.go
@@ -0,0 +1,25 @@
+package proxy
+
+import "testing"
+
+func TestRuleEndpointsUsesListenPort(t *testing.T) {
+	rule := Rule{
+		ListenPort: 6893,
+		Hosts: []Host{
+			{Hostname: "xxx.com"},
+			{Hostname: "xxx.com", ListenPort: intPtr(8081)},
+		},
+	}
+	endpoints := rule.Endpoints()
+	if len(endpoints) != 2 {
+		t.Fatalf("expected 2 endpoints, got %d", len(endpoints))
+	}
+	if endpoints[0].Hostname != "xxx.com" || endpoints[0].Port != 6893 {
+		t.Fatalf("unexpected first endpoint: %+v", endpoints[0])
+	}
+	if endpoints[1].Port != 8081 {
+		t.Fatalf("expected custom port 8081, got %d", endpoints[1].Port)
+	}
+}
+
+func intPtr(v int) *int { return &v }
diff --git a/app-src/internal/proxy/proxy_hosts_test.go b/app-src/internal/proxy/proxy_hosts_test.go
new file mode 100644
index 0000000..2f17b33
--- /dev/null
+++ b/app-src/internal/proxy/proxy_hosts_test.go
@@ -0,0 +1,28 @@
+package proxy
+
+import (
+	"testing"
+)
+
+func TestParseHostsAllowsSameHostnameDifferentPorts(t *testing.T) {
+	hosts, err := parseHosts([]string{"example.com", "example.com:6893"}, 8011)
+	if err != nil {
+		t.Fatalf("parseHosts failed: %v", err)
+	}
+	if len(hosts) != 2 {
+		t.Fatalf("expected 2 hosts, got %d", len(hosts))
+	}
+	if hostEffectivePort(hosts[0], 8011) != 8011 {
+		t.Fatalf("expected default port 8011, got %d", hostEffectivePort(hosts[0], 8011))
+	}
+	if hostEffectivePort(hosts[1], 8011) != 6893 {
+		t.Fatalf("expected custom port 6893, got %d", hostEffectivePort(hosts[1], 8011))
+	}
+}
+
+func TestParseHostsRejectsDuplicateBinding(t *testing.T) {
+	_, err := parseHosts([]string{"example.com", "example.com"}, 8011)
+	if err == nil {
+		t.Fatal("expected duplicate binding error")
+	}
+}
diff --git a/app-src/internal/proxy/security.go b/app-src/internal/proxy/security.go
new file mode 100644
index 0000000..d8e3aaf
--- /dev/null
+++ b/app-src/internal/proxy/security.go
@@ -0,0 +1,292 @@
+package proxy
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/havline/havline/internal/validate"
+)
+
+type BasicAuthConfig struct {
+	Enabled      bool   `json:"enabled,omitempty"`
+	Username     string `json:"username,omitempty"`
+	PasswordHash string `json:"password_hash,omitempty"` // bcrypt htpasswd line body
+	HasPassword  bool   `json:"has_password,omitempty"`  // API response only
+}
+
+type RateLimitConfig struct {
+	Enabled bool `json:"enabled,omitempty"`
+	Rate    int  `json:"rate,omitempty"`  // requests per second
+	Burst   int  `json:"burst,omitempty"` // burst count
+}
+
+type ConnLimitConfig struct {
+	Enabled bool `json:"enabled,omitempty"`
+	Max     int  `json:"max,omitempty"` // max concurrent connections per IP
+}
+
+type SecurityConfig struct {
+	IPBlacklist       []string         `json:"ip_blacklist,omitempty"`
+	IPWhitelist       []string         `json:"ip_whitelist,omitempty"`
+	IPWhitelistMode   bool             `json:"ip_whitelist_mode,omitempty"`
+	ChinaOnly         bool             `json:"china_only,omitempty"`
+	BasicAuth         *BasicAuthConfig `json:"basic_auth,omitempty"`
+	RateLimit         *RateLimitConfig `json:"rate_limit,omitempty"`
+	ConnLimit         *ConnLimitConfig `json:"conn_limit,omitempty"`
+	ProxySSLVerifyOff bool             `json:"proxy_ssl_verify_off,omitempty"`
+	ProxyHostUpstream bool             `json:"proxy_host_upstream,omitempty"` // use $proxy_host instead of $host
+	TLSMin13Only      bool             `json:"tls_min_13_only,omitempty"`
+	SecurityHeaders   bool             `json:"security_headers,omitempty"`
+}
+
+type SecurityInput struct {
+	IPBlacklist       []string
+	IPWhitelist       []string
+	IPWhitelistMode   *bool
+	ChinaOnly         *bool
+	BasicAuth         *BasicAuthInput
+	RateLimit         *RateLimitConfig
+	ConnLimit         *ConnLimitConfig
+	ProxySSLVerifyOff *bool
+	ProxyHostUpstream *bool
+	TLSMin13Only      *bool
+	SecurityHeaders   *bool
+}
+
+type BasicAuthInput struct {
+	Enabled  *bool
+	Username *string
+	Password *string // plaintext on write only
+}
+
+// DefaultPrivateCIDRs are always exempt from china_only restrictions.
+var DefaultPrivateCIDRs = []string{
+	"10.0.0.0/8",
+	"127.0.0.0/8",
+	"172.16.0.0/12",
+	"192.168.0.0/16",
+	"::1/128",
+	"fc00::/7",
+}
+
+func ChinaBypassCIDRs(extra []string) []string {
+	seen := make(map[string]bool, len(DefaultPrivateCIDRs)+len(extra))
+	out := make([]string, 0, len(DefaultPrivateCIDRs)+len(extra))
+	for _, cidr := range DefaultPrivateCIDRs {
+		if !seen[cidr] {
+			seen[cidr] = true
+			out = append(out, cidr)
+		}
+	}
+	for _, cidr := range extra {
+		cidr = strings.TrimSpace(cidr)
+		if cidr != "" && !seen[cidr] {
+			seen[cidr] = true
+			out = append(out, cidr)
+		}
+	}
+	return out
+}
+
+func DefaultSecurityConfig() SecurityConfig {
+	return SecurityConfig{}
+}
+
+func ParseSecurityJSON(raw string) (SecurityConfig, error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" || raw == "{}" {
+		return DefaultSecurityConfig(), nil
+	}
+	var cfg SecurityConfig
+	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+		return SecurityConfig{}, fmt.Errorf("安全配置格式无效")
+	}
+	return cfg.Normalize(), nil
+}
+
+func (c SecurityConfig) Normalize() SecurityConfig {
+	if c.RateLimit != nil && c.RateLimit.Enabled {
+		if c.RateLimit.Rate <= 0 {
+			c.RateLimit.Rate = 10
+		}
+		if c.RateLimit.Burst <= 0 {
+			c.RateLimit.Burst = 20
+		}
+	}
+	if c.ConnLimit != nil && c.ConnLimit.Enabled {
+		if c.ConnLimit.Max <= 0 {
+			c.ConnLimit.Max = 20
+		}
+	}
+	return c
+}
+
+func RuleForAPI(rule Rule) Rule {
+	rule.Security = rule.Security.ForAPI()
+	return rule
+}
+
+func RulesForAPI(rules []Rule) []Rule {
+	out := make([]Rule, len(rules))
+	for i, rule := range rules {
+		out[i] = RuleForAPI(rule)
+	}
+	return out
+}
+
+func (c SecurityConfig) ForAPI() SecurityConfig {
+	out := c.Normalize()
+	if out.BasicAuth != nil {
+		hasPassword := out.BasicAuth.PasswordHash != ""
+		out.BasicAuth = &BasicAuthConfig{
+			Enabled:     out.BasicAuth.Enabled,
+			Username:    out.BasicAuth.Username,
+			HasPassword: hasPassword,
+		}
+	}
+	return out
+}
+
+func (c SecurityConfig) MarshalJSON() ([]byte, error) {
+	type alias SecurityConfig
+	return json.Marshal(alias(c.Normalize()))
+}
+
+func (c SecurityConfig) ToJSON() (string, error) {
+	b, err := c.MarshalJSON()
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}
+
+func (c SecurityConfig) BasicAuthEnabled() bool {
+	return c.BasicAuth != nil && c.BasicAuth.Enabled && c.BasicAuth.Username != "" && c.BasicAuth.PasswordHash != ""
+}
+
+func (c SecurityConfig) RateLimitEnabled() bool {
+	return c.RateLimit != nil && c.RateLimit.Enabled
+}
+
+func (c SecurityConfig) ConnLimitEnabled() bool {
+	return c.ConnLimit != nil && c.ConnLimit.Enabled
+}
+
+func (c SecurityConfig) WhitelistEnabled() bool {
+	return c.IPWhitelistMode && len(c.IPWhitelist) > 0
+}
+
+func MergeSecurity(current SecurityConfig, in SecurityInput) (SecurityConfig, error) {
+	out := current.Normalize()
+
+	if in.IPBlacklist != nil {
+		out.IPBlacklist = in.IPBlacklist
+	}
+	if in.IPWhitelist != nil {
+		out.IPWhitelist = in.IPWhitelist
+	}
+	if in.IPWhitelistMode != nil {
+		out.IPWhitelistMode = *in.IPWhitelistMode
+	}
+	if in.ChinaOnly != nil {
+		out.ChinaOnly = *in.ChinaOnly
+	}
+	if in.RateLimit != nil {
+		out.RateLimit = in.RateLimit
+	}
+	if in.ConnLimit != nil {
+		out.ConnLimit = in.ConnLimit
+	}
+	if in.ProxySSLVerifyOff != nil {
+		out.ProxySSLVerifyOff = *in.ProxySSLVerifyOff
+	}
+	if in.ProxyHostUpstream != nil {
+		out.ProxyHostUpstream = *in.ProxyHostUpstream
+	}
+	if in.TLSMin13Only != nil {
+		out.TLSMin13Only = *in.TLSMin13Only
+	}
+	if in.SecurityHeaders != nil {
+		out.SecurityHeaders = *in.SecurityHeaders
+	}
+
+	if in.BasicAuth != nil {
+		if in.BasicAuth.Enabled != nil {
+			if out.BasicAuth == nil {
+				out.BasicAuth = &BasicAuthConfig{}
+			}
+			out.BasicAuth.Enabled = *in.BasicAuth.Enabled
+		}
+		if in.BasicAuth.Username != nil {
+			if out.BasicAuth == nil {
+				out.BasicAuth = &BasicAuthConfig{}
+			}
+			out.BasicAuth.Username = strings.TrimSpace(*in.BasicAuth.Username)
+		}
+		if in.BasicAuth.Password != nil && strings.TrimSpace(*in.BasicAuth.Password) != "" {
+			if out.BasicAuth == nil {
+				out.BasicAuth = &BasicAuthConfig{}
+			}
+			hash, err := validate.BasicAuthPasswordHash(out.BasicAuth.Username, *in.BasicAuth.Password)
+			if err != nil {
+				return SecurityConfig{}, err
+			}
+			out.BasicAuth.PasswordHash = hash
+		}
+	}
+
+	return out, ValidateSecurity(out)
+}
+
+func ValidateSecurity(cfg SecurityConfig) error {
+	cfg = cfg.Normalize()
+	if err := validate.IPOrCIDRList(cfg.IPBlacklist); err != nil {
+		return fmt.Errorf("IP 黑名单:%w", err)
+	}
+	if err := validate.IPOrCIDRList(cfg.IPWhitelist); err != nil {
+		return fmt.Errorf("IP 白名单:%w", err)
+	}
+	if cfg.IPWhitelistMode && len(cfg.IPWhitelist) == 0 {
+		return fmt.Errorf("启用白名单模式时至少添加一条 IP 或 CIDR")
+	}
+	if cfg.BasicAuth != nil && cfg.BasicAuth.Enabled {
+		if err := validate.Username(cfg.BasicAuth.Username); err != nil {
+			return fmt.Errorf("Basic Auth:%w", err)
+		}
+		if cfg.BasicAuth.PasswordHash == "" {
+			return fmt.Errorf("Basic Auth 需要设置密码")
+		}
+	}
+	if cfg.RateLimit != nil && cfg.RateLimit.Enabled {
+		if cfg.RateLimit.Rate < 1 || cfg.RateLimit.Rate > 10000 {
+			return fmt.Errorf("限流速率无效(1-10000 次/秒)")
+		}
+		if cfg.RateLimit.Burst < 1 || cfg.RateLimit.Burst > 100000 {
+			return fmt.Errorf("限流突发值无效(1-100000)")
+		}
+	}
+	if cfg.ConnLimit != nil && cfg.ConnLimit.Enabled {
+		if cfg.ConnLimit.Max < 1 || cfg.ConnLimit.Max > 10000 {
+			return fmt.Errorf("连接数限制无效(1-10000)")
+		}
+	}
+	return nil
+}
+
+func ParseIPListText(text string) []string {
+	lines := strings.Split(text, "\n")
+	out := make([]string, 0, len(lines))
+	for _, line := range lines {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+		out = append(out, line)
+	}
+	return out
+}
+
+func FormatIPListText(list []string) string {
+	return strings.Join(list, "\n")
+}
diff --git a/app-src/internal/proxy/security_test.go b/app-src/internal/proxy/security_test.go
new file mode 100644
index 0000000..fa88495
--- /dev/null
+++ b/app-src/internal/proxy/security_test.go
@@ -0,0 +1,102 @@
+package proxy
+
+import (
+	"testing"
+
+	"github.com/havline/havline/internal/validate"
+)
+
+func TestForAPIStripsPasswordHashButRuntimeKeepsAuth(t *testing.T) {
+	hash := "$2a$10$abcdefghijklmnopqrstuv"
+	sec := SecurityConfig{
+		BasicAuth: &BasicAuthConfig{
+			Enabled:      true,
+			Username:     "admin",
+			PasswordHash: hash,
+		},
+	}
+	if !sec.BasicAuthEnabled() {
+		t.Fatal("expected runtime basic auth enabled")
+	}
+	api := sec.ForAPI()
+	if api.BasicAuth.PasswordHash != "" {
+		t.Fatal("expected API view to strip password hash")
+	}
+	if !api.BasicAuth.HasPassword {
+		t.Fatal("expected has_password flag in API view")
+	}
+	if api.BasicAuthEnabled() {
+		t.Fatal("API view must not be treated as nginx-ready basic auth config")
+	}
+}
+
+func TestMergeSecurityBasicAuthKeepsHash(t *testing.T) {
+	hash, err := validate.BasicAuthPasswordHash("admin", "password123")
+	if err != nil {
+		t.Fatal(err)
+	}
+	current := SecurityConfig{
+		BasicAuth: &BasicAuthConfig{
+			Enabled:      true,
+			Username:     "admin",
+			PasswordHash: hash,
+		},
+	}
+	enabled := true
+	out, err := MergeSecurity(current, SecurityInput{
+		BasicAuth: &BasicAuthInput{Enabled: &enabled, Username: strPtr("admin")},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if out.BasicAuth.PasswordHash != hash {
+		t.Fatalf("expected hash preserved")
+	}
+}
+
+func TestValidateSecurityWhitelistRequiresEntries(t *testing.T) {
+	err := ValidateSecurity(SecurityConfig{IPWhitelistMode: true})
+	if err == nil {
+		t.Fatal("expected error")
+	}
+}
+
+func TestChinaBypassCIDRsIncludesDefaults(t *testing.T) {
+	got := ChinaBypassCIDRs(nil)
+	for _, want := range DefaultPrivateCIDRs {
+		if !contains(got, want) {
+			t.Fatalf("missing default %s in %v", want, got)
+		}
+	}
+}
+
+func TestChinaBypassCIDRsMergesExtra(t *testing.T) {
+	got := ChinaBypassCIDRs([]string{"203.0.113.0/24", "192.168.0.0/16"})
+	if !contains(got, "203.0.113.0/24") {
+		t.Fatalf("missing extra CIDR: %v", got)
+	}
+	if count(got, "192.168.0.0/16") != 1 {
+		t.Fatalf("expected single 192.168.0.0/16 entry: %v", got)
+	}
+}
+
+func contains(ss []string, s string) bool {
+	for _, v := range ss {
+		if v == s {
+			return true
+		}
+	}
+	return false
+}
+
+func count(ss []string, s string) int {
+	n := 0
+	for _, v := range ss {
+		if v == s {
+			n++
+		}
+	}
+	return n
+}
+
+func strPtr(s string) *string { return &s }
diff --git a/app-src/internal/publicip/publicip.go b/app-src/internal/publicip/publicip.go
new file mode 100644
index 0000000..716a53a
--- /dev/null
+++ b/app-src/internal/publicip/publicip.go
@@ -0,0 +1,122 @@
+package publicip
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"strings"
+	"time"
+)
+
+var ipv4Providers = []string{
+	"https://api.ipify.org",
+	"https://ipv4.icanhazip.com",
+	"https://ifconfig.me/ip",
+	"http://ipv4.icanhazip.com",
+}
+
+var ipv6Providers = []string{
+	"https://api64.ipify.org",
+	"https://ipv6.icanhazip.com",
+}
+
+func Detect(ctx context.Context) (ipv4, ipv6 string, err error) {
+	ipv4, v4err := detectIPv4(ctx)
+	ipv6, _ = detectIPv6(ctx)
+	if v4err != nil {
+		return "", ipv6, v4err
+	}
+	return ipv4, ipv6, nil
+}
+
+func detectClient() *http.Client {
+	return &http.Client{
+		Timeout: 8 * time.Second,
+		Transport: &http.Transport{
+			Proxy: nil,
+			DialContext: (&net.Dialer{
+				Timeout:   8 * time.Second,
+				KeepAlive: 30 * time.Second,
+			}).DialContext,
+		},
+	}
+}
+
+func detectIPv4(ctx context.Context) (string, error) {
+	client := detectClient()
+	var lastErr error
+	for _, url := range ipv4Providers {
+		ip, err := fetchIP(ctx, client, url, false)
+		if err == nil {
+			return ip, nil
+		}
+		lastErr = err
+	}
+	return "", fmt.Errorf("获取公网 IPv4 失败:%w", lastErr)
+}
+
+func detectIPv6(ctx context.Context) (string, error) {
+	client := detectClient()
+	for _, url := range ipv6Providers {
+		ip, err := fetchIP(ctx, client, url, true)
+		if err == nil {
+			return ip, nil
+		}
+	}
+	return "", fmt.Errorf("no ipv6")
+}
+
+func fetchIP(ctx context.Context, client *http.Client, url string, wantV6 bool) (string, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return "", fmt.Errorf("http %d", resp.StatusCode)
+	}
+	body, err := io.ReadAll(io.LimitReader(resp.Body, 128))
+	if err != nil {
+		return "", err
+	}
+	ip := strings.TrimSpace(string(body))
+	parsed := net.ParseIP(ip)
+	if parsed == nil {
+		return "", fmt.Errorf("invalid ip response")
+	}
+	if wantV6 && parsed.To4() != nil {
+		return "", fmt.Errorf("no ipv6")
+	}
+	if !wantV6 && parsed.To4() == nil {
+		return "", fmt.Errorf("no ipv4")
+	}
+	if !wantV6 && !IsPublicIPv4(ip) {
+		return "", fmt.Errorf("private ipv4")
+	}
+	if wantV6 && !IsPublicIPv6(ip) {
+		return "", fmt.Errorf("private ipv6")
+	}
+	return ip, nil
+}
+
+func IsPublicIPv4(ip string) bool {
+	parsed := net.ParseIP(strings.TrimSpace(ip))
+	if parsed == nil || parsed.To4() == nil {
+		return false
+	}
+	return !parsed.IsPrivate() && !parsed.IsLoopback() && !parsed.IsLinkLocalUnicast() && !parsed.IsUnspecified()
+}
+
+func IsPublicIPv6(ip string) bool {
+	parsed := net.ParseIP(strings.TrimSpace(ip))
+	if parsed == nil || parsed.To4() != nil {
+		return false
+	}
+	return !parsed.IsPrivate() && !parsed.IsLoopback() && !parsed.IsLinkLocalUnicast() && !parsed.IsUnspecified()
+}
diff --git a/app-src/internal/publicip/publicip_test.go b/app-src/internal/publicip/publicip_test.go
new file mode 100644
index 0000000..ed457cb
--- /dev/null
+++ b/app-src/internal/publicip/publicip_test.go
@@ -0,0 +1,12 @@
+package publicip
+
+import "testing"
+
+func TestIsPublicIPv4(t *testing.T) {
+	if IsPublicIPv4("192.168.1.1") {
+		t.Fatal("private ip should be rejected")
+	}
+	if !IsPublicIPv4("8.8.8.8") {
+		t.Fatal("public ip should be accepted")
+	}
+}
diff --git a/app-src/internal/scheduler/scheduler.go b/app-src/internal/scheduler/scheduler.go
new file mode 100644
index 0000000..718f45a
--- /dev/null
+++ b/app-src/internal/scheduler/scheduler.go
@@ -0,0 +1,59 @@
+package scheduler
+
+import (
+	"context"
+	"log"
+	"time"
+)
+
+type Job struct {
+	Name     string
+	Interval time.Duration
+	Run      func(ctx context.Context)
+}
+
+type Scheduler struct {
+	jobs []Job
+}
+
+// New 会丢掉间隔非正的任务:time.NewTicker 对非正间隔直接 panic,这类任务应由调用方单独启动。
+func New(jobs ...Job) *Scheduler {
+	valid := make([]Job, 0, len(jobs))
+	for _, job := range jobs {
+		if job.Interval <= 0 {
+			log.Printf("scheduler: 任务 %s 间隔为 %v,已跳过", job.Name, job.Interval)
+			continue
+		}
+		valid = append(valid, job)
+	}
+	return &Scheduler{jobs: valid}
+}
+
+func (s *Scheduler) Start(ctx context.Context) {
+	for _, job := range s.jobs {
+		go s.runJob(ctx, job)
+	}
+}
+
+func (s *Scheduler) runJob(ctx context.Context, job Job) {
+	ticker := time.NewTicker(job.Interval)
+	defer ticker.Stop()
+	// 单个任务 panic 不能带走整个进程:包一层 recover,记录后继续下一轮
+	run := func() {
+		defer func() {
+			if r := recover(); r != nil {
+				log.Printf("scheduler: 任务 %s panic: %v", job.Name, r)
+			}
+		}()
+		job.Run(ctx)
+	}
+	run()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			run()
+		}
+	}
+}
diff --git a/app-src/internal/scheduler/scheduler_test.go b/app-src/internal/scheduler/scheduler_test.go
new file mode 100644
index 0000000..6d79c10
--- /dev/null
+++ b/app-src/internal/scheduler/scheduler_test.go
@@ -0,0 +1,40 @@
+package scheduler
+
+import (
+	"context"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+// TestNewSkipsNonPositiveIntervals:time.NewTicker 对非正间隔会 panic,这类任务必须在建表时丢掉。
+func TestNewSkipsNonPositiveIntervals(t *testing.T) {
+	s := New(
+		Job{Name: "zero", Interval: 0, Run: func(context.Context) {}},
+		Job{Name: "negative", Interval: -time.Second, Run: func(context.Context) {}},
+		Job{Name: "ok", Interval: time.Hour, Run: func(context.Context) {}},
+	)
+	if len(s.jobs) != 1 || s.jobs[0].Name != "ok" {
+		t.Fatalf("非正间隔的任务应被跳过,实际保留 %d 个", len(s.jobs))
+	}
+}
+
+// TestRunJobRecoversFromPanic:单个任务 panic 不能带走整个进程,且要能继续下一轮。
+func TestRunJobRecoversFromPanic(t *testing.T) {
+	var calls int32
+	s := New(Job{Name: "panic", Interval: 5 * time.Millisecond, Run: func(context.Context) {
+		atomic.AddInt32(&calls, 1)
+		panic("boom")
+	}})
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	s.Start(ctx)
+
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) && atomic.LoadInt32(&calls) < 3 {
+		time.Sleep(5 * time.Millisecond)
+	}
+	if got := atomic.LoadInt32(&calls); got < 3 {
+		t.Fatalf("panic 后应继续后续轮次,实际只执行了 %d 次", got)
+	}
+}
diff --git a/app-src/internal/secret/secret.go b/app-src/internal/secret/secret.go
new file mode 100644
index 0000000..511420d
--- /dev/null
+++ b/app-src/internal/secret/secret.go
@@ -0,0 +1,84 @@
+package secret
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"strings"
+)
+
+var ErrEmpty = errors.New("secret is empty")
+
+type Box struct {
+	gcm cipher.AEAD
+}
+
+func NewBox(keyMaterial string) (*Box, error) {
+	if keyMaterial == "" {
+		return nil, fmt.Errorf("encryption key material is required")
+	}
+	sum := sha256.Sum256([]byte(keyMaterial))
+	block, err := aes.NewCipher(sum[:])
+	if err != nil {
+		return nil, err
+	}
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, err
+	}
+	return &Box{gcm: gcm}, nil
+}
+
+func (b *Box) Encrypt(plain string) (string, error) {
+	if plain == "" {
+		return "", ErrEmpty
+	}
+	nonce := make([]byte, b.gcm.NonceSize())
+	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+		return "", err
+	}
+	ciphertext := b.gcm.Seal(nonce, nonce, []byte(plain), nil)
+	return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+func (b *Box) Decrypt(encoded string) (string, error) {
+	if encoded == "" {
+		return "", ErrEmpty
+	}
+	raw, err := base64.StdEncoding.DecodeString(encoded)
+	if err != nil {
+		return "", err
+	}
+	nonceSize := b.gcm.NonceSize()
+	if len(raw) < nonceSize {
+		return "", fmt.Errorf("invalid ciphertext")
+	}
+	nonce, ciphertext := raw[:nonceSize], raw[nonceSize:]
+	plain, err := b.gcm.Open(nil, nonce, ciphertext, nil)
+	if err != nil {
+		return "", err
+	}
+	return string(plain), nil
+}
+
+func IsDecryptFailure(err error) bool {
+	if err == nil {
+		return false
+	}
+	return strings.Contains(err.Error(), "message authentication failed")
+}
+
+func DecryptHint(err error) error {
+	if err == nil {
+		return nil
+	}
+	if IsDecryptFailure(err) {
+		return fmt.Errorf("DNS API 凭据无法解密,请在 DDNS 页面重新保存 API Token(服务加密密钥可能已变更)")
+	}
+	return err
+}
diff --git a/app-src/internal/service/nginx_config.go b/app-src/internal/service/nginx_config.go
new file mode 100644
index 0000000..1d4225e
--- /dev/null
+++ b/app-src/internal/service/nginx_config.go
@@ -0,0 +1,418 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/havline/havline/internal/nginx"
+	"github.com/havline/havline/internal/proxy"
+	"github.com/havline/havline/internal/settings"
+)
+
+type NginxBackupInfo struct {
+	Name      string `json:"name"`
+	CreatedAt string `json:"created_at"`
+}
+
+type RuleNginxView struct {
+	Mode      string            `json:"mode"`
+	Enabled   bool              `json:"enabled"`
+	Active    bool              `json:"active"`
+	Generated string            `json:"generated"`
+	Content   string            `json:"content"`
+	Backups   []NginxBackupInfo `json:"backups"`
+}
+
+type GlobalNginxView struct {
+	Mode               string            `json:"mode"`
+	GeneratedFramework string            `json:"generated_framework"`
+	GeneratedSnippet   string            `json:"generated_snippet"`
+	Content            string            `json:"content"`
+	Backups            []NginxBackupInfo `json:"backups"`
+}
+
+type SaveRuleNginxInput struct {
+	Mode    string
+	Content *string
+}
+
+type SaveGlobalNginxInput struct {
+	Mode    string
+	Content *string
+}
+
+func (s *ProxyService) GetRuleNginx(ctx context.Context, id int64) (RuleNginxView, error) {
+	rule, err := s.Get(ctx, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	opts := s.loadGenerateOptions(ctx)
+	generated, err := nginx.GenerateRuleBlocks(s.cfg, rule, certs, opts)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	mode := rule.NginxMode
+	if mode == "" {
+		mode = "auto"
+	}
+	content := generated
+	if mode == "custom" {
+		custom, err := nginx.ReadRuleCustom(s.cfg, id)
+		if err != nil {
+			return RuleNginxView{}, err
+		}
+		if strings.TrimSpace(custom) != "" {
+			content = custom
+		}
+	}
+	backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	return RuleNginxView{
+		Mode:      mode,
+		Enabled:   rule.Enabled,
+		Active:    rule.Enabled,
+		Generated: generated,
+		Content:   content,
+		Backups:   mapBackups(backups),
+	}, nil
+}
+
+func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleNginxInput) (RuleNginxView, error) {
+	rule, err := s.Get(ctx, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	mode := strings.TrimSpace(in.Mode)
+	if mode == "" {
+		mode = rule.NginxMode
+	}
+	if mode != "auto" && mode != "custom" {
+		return RuleNginxView{}, fmt.Errorf("无效的 nginx 模式")
+	}
+
+	if mode == "auto" {
+		if err := s.store.SetNginxMode(ctx, id, "auto"); err != nil {
+			return RuleNginxView{}, err
+		}
+		// 切回自动模式后,手动配置的占用记录不再适用
+		if err := s.store.ClearCustomEndpoints(ctx, id); err != nil {
+			return RuleNginxView{}, err
+		}
+		if err := s.applyNginx(ctx); err != nil {
+			return RuleNginxView{}, err
+		}
+		return s.GetRuleNginx(ctx, id)
+	}
+
+	if in.Content == nil {
+		return RuleNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
+	}
+	content := *in.Content
+	if strings.TrimSpace(content) == "" {
+		return RuleNginxView{}, fmt.Errorf("Nginx 配置不能为空")
+	}
+
+	if err := s.validateRuleNginxPending(ctx, rule, content); err != nil {
+		return RuleNginxView{}, err
+	}
+	// 手动配置里的 listen/server_name 不进 proxy_hosts,需单独解析记录并校验占用,
+	// 否则它与其它规则的同域名同端口冲突只会在 Nginx reload 时静默失效
+	endpoints := nginx.ParseServerEndpoints(content)
+	if err := s.store.EnsureEndpointsAvailable(ctx, endpoints, id); err != nil {
+		return RuleNginxView{}, err
+	}
+	if _, err := nginx.SaveRuleCustomWithBackup(s.cfg, id, content); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.store.ReplaceCustomEndpoints(ctx, id, endpoints); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return RuleNginxView{}, err
+	}
+	return s.GetRuleNginx(ctx, id)
+}
+
+func (s *ProxyService) RollbackRuleNginx(ctx context.Context, id int64, backupName string) (RuleNginxView, error) {
+	if _, err := s.Get(ctx, id); err != nil {
+		return RuleNginxView{}, err
+	}
+	backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	if len(backups) == 0 {
+		return RuleNginxView{}, fmt.Errorf("没有可回滚的备份")
+	}
+	if backupName == "" {
+		backupName = backups[0].Name
+	}
+	if err := nginx.RestoreBackup(s.cfg.NginxRuleBackupsDir(id), backupName, s.cfg.NginxRuleCustomPath(id)); err != nil {
+		return RuleNginxView{}, err
+	}
+	custom, err := nginx.ReadRuleCustom(s.cfg, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	rule, err := s.Get(ctx, id)
+	if err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.validateRuleNginxPending(ctx, rule, custom); err != nil {
+		return RuleNginxView{}, err
+	}
+	endpoints := nginx.ParseServerEndpoints(custom)
+	if err := s.store.EnsureEndpointsAvailable(ctx, endpoints, id); err != nil {
+		// 备份文件已写回(与原有行为一致),占用记录需跟磁盘内容保持一致,避免留下过期占用
+		_ = s.store.ReplaceCustomEndpoints(ctx, id, endpoints)
+		return RuleNginxView{}, err
+	}
+	if err := s.store.ReplaceCustomEndpoints(ctx, id, endpoints); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
+		return RuleNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return RuleNginxView{}, err
+	}
+	return s.GetRuleNginx(ctx, id)
+}
+
+func (s *ProxyService) GetGlobalNginx(ctx context.Context) (GlobalNginxView, error) {
+	mode, err := s.settings.Get(ctx, settings.KeyNginxGlobalMode)
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	if mode == "" {
+		mode = "auto"
+	}
+	content, err := nginx.ReadGlobalCustom(s.cfg)
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	return GlobalNginxView{
+		Mode:               mode,
+		GeneratedFramework: nginx.GenerateGlobalFramework(s.cfg),
+		GeneratedSnippet:   nginx.DefaultGlobalHTTPSnippet(),
+		Content:            content,
+		Backups:            mapBackups(backups),
+	}, nil
+}
+
+func (s *ProxyService) SaveGlobalNginx(ctx context.Context, in SaveGlobalNginxInput) (GlobalNginxView, error) {
+	mode := strings.TrimSpace(in.Mode)
+	if mode == "" {
+		var err error
+		mode, err = s.settings.Get(ctx, settings.KeyNginxGlobalMode)
+		if err != nil {
+			return GlobalNginxView{}, err
+		}
+	}
+	if mode != "auto" && mode != "custom" {
+		return GlobalNginxView{}, fmt.Errorf("无效的 nginx 模式")
+	}
+
+	if mode == "auto" {
+		if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "auto"); err != nil {
+			return GlobalNginxView{}, err
+		}
+		if err := s.applyNginx(ctx); err != nil {
+			return GlobalNginxView{}, err
+		}
+		return s.GetGlobalNginx(ctx)
+	}
+
+	if in.Content == nil {
+		return GlobalNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
+	}
+	content := *in.Content
+	if err := s.validateGlobalNginxPending(ctx, content); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if _, err := nginx.SaveGlobalCustomWithBackup(s.cfg, content); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return GlobalNginxView{}, err
+	}
+	return s.GetGlobalNginx(ctx)
+}
+
+func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName string) (GlobalNginxView, error) {
+	backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	if len(backups) == 0 {
+		return GlobalNginxView{}, fmt.Errorf("没有可回滚的备份")
+	}
+	if backupName == "" {
+		backupName = backups[0].Name
+	}
+	if err := nginx.RestoreBackup(s.cfg.NginxGlobalBackupsDir(), backupName, s.cfg.NginxGlobalCustomPath()); err != nil {
+		return GlobalNginxView{}, err
+	}
+	content, err := nginx.ReadGlobalCustom(s.cfg)
+	if err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.validateGlobalNginxPending(ctx, content); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
+		return GlobalNginxView{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return GlobalNginxView{}, err
+	}
+	return s.GetGlobalNginx(ctx)
+}
+
+func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
+	rules, err := s.store.ListEnabledLocal(ctx)
+	if err != nil {
+		return err
+	}
+	merged := make([]proxy.Rule, 0, len(rules))
+	found := false
+	for _, r := range rules {
+		if r.ID == rule.ID {
+			ruleCopy := rule
+			ruleCopy.NginxMode = "custom"
+			ruleCopy.Enabled = true
+			merged = append(merged, ruleCopy)
+			found = true
+			continue
+		}
+		merged = append(merged, r)
+	}
+	if !found && rule.Enabled {
+		ruleCopy := rule
+		ruleCopy.NginxMode = "custom"
+		merged = append(merged, ruleCopy)
+	}
+	return s.validateWithOverrides(ctx, merged, nginx.GenerateOptions{
+		RuleCustomOverrides: map[int64]string{rule.ID: content},
+	})
+}
+
+func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error {
+	rules, err := s.store.ListEnabledLocal(ctx)
+	if err != nil {
+		return err
+	}
+	return s.validateWithOverrides(ctx, rules, nginx.GenerateOptions{
+		GlobalCustomOverride: content,
+	})
+}
+
+func (s *ProxyService) validateWithOverrides(ctx context.Context, rules []proxy.Rule, extra nginx.GenerateOptions) error {
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	opts := s.loadGenerateOptions(ctx)
+	opts.GlobalCustomOverride = extra.GlobalCustomOverride
+	opts.GlobalCustomPathOverride = extra.GlobalCustomPathOverride
+	opts.RuleCustomOverrides = extra.RuleCustomOverrides
+	opts.ChinaCIDRAvailable = nginx.ChinaCIDRExists(s.cfg)
+	content, err := nginx.Generate(s.cfg, rules, certs, opts)
+	if err != nil {
+		return err
+	}
+	return s.nginx.ValidateContent(ctx, content)
+}
+
+func mapBackups(entries []nginx.BackupEntry) []NginxBackupInfo {
+	out := make([]NginxBackupInfo, 0, len(entries))
+	for _, entry := range entries {
+		out = append(out, NginxBackupInfo{
+			Name:      entry.Name,
+			CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
+		})
+	}
+	return out
+}
+
+// NginxConfigVersionInfo 描述主配置(nginx.conf)的一个历史版本。
+type NginxConfigVersionInfo struct {
+	Name      string `json:"name"`
+	Size      int64  `json:"size"`
+	CreatedAt string `json:"created_at"`
+}
+
+type NginxConfigVersionsView struct {
+	Message  string                   `json:"message,omitempty"`
+	Content  string                   `json:"content"`
+	Versions []NginxConfigVersionInfo `json:"versions"`
+}
+
+type NginxConfigVersionView struct {
+	Name    string `json:"name"`
+	Content string `json:"content"`
+}
+
+// GetNginxConfigVersions 返回当前主配置与其历史版本,前端拿两者做差异对比。
+func (s *ProxyService) GetNginxConfigVersions() (NginxConfigVersionsView, error) {
+	content, err := s.nginx.ReadConfig()
+	if err != nil {
+		return NginxConfigVersionsView{}, err
+	}
+	versions, err := s.nginx.ListConfigVersions()
+	if err != nil {
+		return NginxConfigVersionsView{}, err
+	}
+	return NginxConfigVersionsView{Content: content, Versions: mapConfigVersions(versions)}, nil
+}
+
+func (s *ProxyService) ReadNginxConfigVersion(name string) (NginxConfigVersionView, error) {
+	content, err := s.nginx.ReadConfigVersion(name)
+	if err != nil {
+		return NginxConfigVersionView{}, err
+	}
+	return NginxConfigVersionView{Name: name, Content: content}, nil
+}
+
+// RollbackNginxConfigVersion 回滚主配置;校验不过时 nginx 层不会动磁盘上的当前配置。
+func (s *ProxyService) RollbackNginxConfigVersion(ctx context.Context, name string) (NginxConfigVersionsView, error) {
+	result, err := s.nginx.RollbackConfigVersion(ctx, name)
+	if err != nil {
+		return NginxConfigVersionsView{}, err
+	}
+	view, err := s.GetNginxConfigVersions()
+	if err != nil {
+		return NginxConfigVersionsView{}, err
+	}
+	view.Message = result.Message
+	return view, nil
+}
+
+func mapConfigVersions(entries []nginx.ConfigVersion) []NginxConfigVersionInfo {
+	out := make([]NginxConfigVersionInfo, 0, len(entries))
+	for _, entry := range entries {
+		out = append(out, NginxConfigVersionInfo{
+			Name:      entry.Name,
+			Size:      entry.Size,
+			CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
+		})
+	}
+	return out
+}
diff --git a/app-src/internal/service/proxy.go b/app-src/internal/service/proxy.go
new file mode 100644
index 0000000..74cf328
--- /dev/null
+++ b/app-src/internal/service/proxy.go
@@ -0,0 +1,233 @@
+package service
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"strconv"
+
+	"github.com/havline/havline/internal/certificate"
+	"github.com/havline/havline/internal/config"
+	"github.com/havline/havline/internal/nginx"
+	"github.com/havline/havline/internal/notify"
+	"github.com/havline/havline/internal/proxy"
+	"github.com/havline/havline/internal/settings"
+	"github.com/havline/havline/internal/validate"
+)
+
+type ProxyService struct {
+	db        *sql.DB
+	cfg       config.Config
+	store     *proxy.Store
+	certStore *certificate.Store
+	settings  *settings.Store
+	nginx     *nginx.Manager
+	notify    *notify.Service
+	exitSync  ExitSync
+}
+
+// ExitSync 处理服务发布规则变化后的外部出口同步。
+type ExitSync interface {
+	SyncRules(ctx context.Context) error
+}
+
+func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
+	return &ProxyService{db: db, cfg: cfg, store: store, certStore: certStore, settings: settingsStore, nginx: nginxMgr}
+}
+
+func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
+	s.notify = notifySvc
+}
+
+func (s *ProxyService) SetExitSync(sync ExitSync) {
+	s.exitSync = sync
+}
+
+func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
+	return s.store.List(ctx)
+}
+
+func (s *ProxyService) Get(ctx context.Context, id int64) (proxy.Rule, error) {
+	return s.store.Get(ctx, id)
+}
+
+func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
+	if proxy.HasExit(in.Exits, proxy.ExitLocal) {
+		if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
+			return proxy.Rule{}, err
+		}
+	}
+
+	rule, err := s.store.Create(ctx, in)
+	if err != nil {
+		return proxy.Rule{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
+	}
+	if err := s.syncExits(ctx); err != nil {
+		rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
+	}
+	return rule, nil
+}
+
+func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInput) (proxy.Rule, error) {
+	current, err := s.Get(ctx, id)
+	if err != nil {
+		return proxy.Rule{}, err
+	}
+	httpsEnabled := current.HTTPSEnabled
+	if in.HTTPSEnabled != nil {
+		httpsEnabled = *in.HTTPSEnabled
+	}
+	hosts := hostsFromRule(current)
+	if in.Hosts != nil {
+		hosts = *in.Hosts
+	}
+	exits := current.Exits
+	if in.Exits != nil {
+		exits = *in.Exits
+	}
+	if proxy.HasExit(exits, proxy.ExitLocal) {
+		if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
+			return proxy.Rule{}, err
+		}
+	}
+
+	rule, err := s.store.Update(ctx, id, in)
+	if err != nil {
+		return proxy.Rule{}, err
+	}
+	if err := s.applyNginx(ctx); err != nil {
+		return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
+	}
+	if err := s.syncExits(ctx); err != nil {
+		rule.SyncWarning = "规则已保存,但 Cloudflare 同步失败:" + err.Error()
+	}
+	return rule, nil
+}
+
+func (s *ProxyService) syncExits(ctx context.Context) error {
+	if s.exitSync == nil {
+		return nil
+	}
+	return s.exitSync.SyncRules(ctx)
+}
+
+func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
+	return s.store.Reorder(ctx, ids)
+}
+
+func (s *ProxyService) Delete(ctx context.Context, id int64) error {
+	if err := s.store.Delete(ctx, id); err != nil {
+		return err
+	}
+	_ = nginx.RemoveRuleCustom(s.cfg, id)
+	if err := s.applyNginx(ctx); err != nil {
+		return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
+	}
+	if err := s.syncExits(ctx); err != nil {
+		return fmt.Errorf("规则已删除,但 Cloudflare 同步失败:%w", err)
+	}
+	return nil
+}
+
+func (s *ProxyService) ReloadAll(ctx context.Context) error {
+	return s.applyNginx(ctx)
+}
+
+func (s *ProxyService) applyNginx(ctx context.Context) error {
+	rules, err := s.store.ListEnabledLocal(ctx)
+	if err != nil {
+		return err
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	s.nginx.SetGenerateOptions(s.loadGenerateOptions(ctx))
+	_, err = s.nginx.Apply(ctx, rules, certs)
+	if err != nil && s.notify != nil {
+		s.notify.Alert(ctx, notify.EventNginxReloadFailure, "Nginx 重载失败", err.Error())
+	}
+	return err
+}
+
+func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, existing []proxy.Rule) error {
+	rules := make([]proxy.Rule, 0, len(existing)+1)
+	replaced := false
+	for _, r := range existing {
+		if r.ID == candidate.ID {
+			if candidate.LocalEnabled() {
+				rules = append(rules, candidate)
+			}
+			replaced = true
+			continue
+		}
+		if r.LocalEnabled() {
+			rules = append(rules, r)
+		}
+	}
+	if !replaced && candidate.LocalEnabled() {
+		rules = append(rules, candidate)
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	s.nginx.SetGenerateOptions(s.loadGenerateOptions(ctx))
+	if err := s.nginx.ValidateOnly(ctx, rules, certs); err != nil {
+		return err
+	}
+	return nil
+}
+
+func hostsFromRule(rule proxy.Rule) []string {
+	hosts := make([]string, 0, len(rule.Hosts))
+	for _, host := range rule.Hosts {
+		if host.ListenPort != nil && *host.ListenPort > 0 && *host.ListenPort != rule.ListenPort {
+			hosts = append(hosts, host.Hostname+":"+strconv.Itoa(*host.ListenPort))
+			continue
+		}
+		hosts = append(hosts, host.Hostname)
+	}
+	return hosts
+}
+
+func (s *ProxyService) validateHTTPSInput(ctx context.Context, hosts []string, httpsEnabled bool) error {
+	if !httpsEnabled || len(hosts) == 0 {
+		return nil
+	}
+	certs, err := s.loadCertSources(ctx)
+	if err != nil {
+		return err
+	}
+	parsedHosts := make([]string, 0, len(hosts))
+	for _, host := range hosts {
+		hostname, _, err := validate.FrontendAddress(host)
+		if err != nil {
+			return err
+		}
+		parsedHosts = append(parsedHosts, hostname)
+	}
+	if nginx.HasCertificateForHosts(s.cfg.CertsDir(), parsedHosts, certs) {
+		return nil
+	}
+	return nginx.HTTPSCoverageError(parsedHosts[0], certs)
+}
+
+func (s *ProxyService) loadCertSources(ctx context.Context) ([]nginx.CertSource, error) {
+	records, err := s.certStore.List(ctx)
+	if err != nil {
+		return nil, err
+	}
+	sources := make([]nginx.CertSource, 0, len(records))
+	for _, record := range records {
+		sources = append(sources, nginx.CertSource{
+			Domains:  record.Domains,
+			CertPath: record.CertPath,
+			KeyPath:  record.KeyPath,
+		})
+	}
+	return sources, nil
+}
diff --git a/app-src/internal/service/proxy_opts.go b/app-src/internal/service/proxy_opts.go
new file mode 100644
index 0000000..2eab91f
--- /dev/null
+++ b/app-src/internal/service/proxy_opts.go
@@ -0,0 +1,29 @@
+package service
+
+import (
+	"context"
+
+	"github.com/havline/havline/internal/chinacidr"
+	"github.com/havline/havline/internal/nginx"
+	"github.com/havline/havline/internal/settings"
+)
+
+func (s *ProxyService) loadGenerateOptions(ctx context.Context) nginx.GenerateOptions {
+	opts := nginx.GenerateOptions{}
+	if s.settings == nil {
+		return opts
+	}
+	raw, err := s.settings.Get(ctx, settings.KeyTrustedProxy)
+	if err == nil {
+		opts.TrustedProxy = nginx.ParseTrustedProxyJSON(raw)
+	}
+	blRaw, err := s.settings.Get(ctx, settings.KeyGlobalIPBlacklist)
+	if err == nil {
+		opts.GlobalIPBlacklist = chinacidr.ParseGlobalIPList(blRaw)
+	}
+	wlRaw, err := s.settings.Get(ctx, settings.KeyGlobalIPWhitelist)
+	if err == nil {
+		opts.GlobalIPWhitelist = chinacidr.ParseGlobalIPList(wlRaw)
+	}
+	return opts
+}
diff --git a/app-src/internal/settings/settings.go b/app-src/internal/settings/settings.go
new file mode 100644
index 0000000..e2e2588
--- /dev/null
+++ b/app-src/internal/settings/settings.go
@@ -0,0 +1,208 @@
+package settings
+
+import (
+	"context"
+	"database/sql"
+	"strconv"
+	"strings"
+)
+
+const (
+	KeyTheme              = "theme"
+	KeyTimezone           = "timezone"
+	KeyLogRetentionDays   = "log_retention_days"
+	KeyDDNSCheckInterval  = "ddns_check_interval_minutes"
+	KeyCertRenewThreshold = "cert_renew_threshold_days"
+	KeyRootDomain         = "root_domain"
+	KeyACMEEmail          = "acme_email"
+	KeyACMECA             = "acme_ca"
+	KeyZeroSSLAPIKey      = "zerossl_api_key"
+	// KeyACMEDNSResolvers DNS-01 传播检查用的递归解析器(逗号分隔;system = 用系统解析器)
+	KeyACMEDNSResolvers = "acme_dns_resolvers"
+	// LiteSSL 强制 EAB:kid 与 hmac 在 FreeSSL 的「证书自动化 → EAB 管理」创建
+	KeyLiteSSLEABKid               = "litessl_eab_kid"
+	KeyLiteSSLEABHMAC              = "litessl_eab_hmac"
+	KeyNotifyWebhookURL            = "notify_webhook_url"
+	KeyNotifyType                  = "notify_type"
+	KeyNotifyEmailJSON             = "notify_email_json"
+	KeyNotifySMTPPassword          = "notify_smtp_password"
+	KeyNotifyWebhookJSON           = "notify_webhook_json"
+	KeyNotifyWebhookSecret         = "notify_webhook_secret"
+	KeyNotifyTelegramJSON          = "notify_telegram_json"
+	KeyNotifyTelegramToken         = "notify_telegram_token"
+	KeyNotifyOnDDNSError           = "notify_on_ddns_error"  // legacy, migrated to KeyNotifyOnDDNSFailure
+	KeyNotifyOnCertError           = "notify_on_cert_error"  // legacy, migrated to KeyNotifyOnCertExpiry
+	KeyNotifyOnNginxError          = "notify_on_nginx_error" // legacy, ignored
+	KeyNotifyOnDDNSIPChange        = "notify_on_ddns_ip_change"
+	KeyNotifyOnDDNSFailure         = "notify_on_ddns_failure"
+	KeyNotifyOnCertExpiry          = "notify_on_cert_expiry"
+	KeyNotifyOnCertRenewSuccess    = "notify_on_cert_renew_success"
+	KeyNotifyOnIPFrequentAccess    = "notify_on_ip_frequent_access"
+	KeyNotifyOnCertRenewFailure    = "notify_on_cert_renew_failure"
+	KeyNotifyOnLoginFailure        = "notify_on_login_failure"
+	KeyNotifyOnNginxReloadFailure  = "notify_on_nginx_reload_failure"
+	KeyNotifyIPFrequentThreshold   = "notify_ip_frequent_threshold"
+	KeyNotifyIPFrequentWindowSec   = "notify_ip_frequent_window_sec"
+	KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold"
+	KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec"
+	KeyNotifyOnResourceThreshold   = "notify_on_resource_threshold"
+	KeyNotifyOnTunnelDown          = "notify_on_tunnel_down"
+	KeyNotifyCPUThreshold          = "notify_cpu_threshold"
+	KeyNotifyMemoryThreshold       = "notify_memory_threshold"
+	KeyNotifyDiskThreshold         = "notify_disk_threshold"
+	KeyNotifyTunnelFailThreshold   = "notify_tunnel_fail_threshold"
+	KeyNotifyOnFrpsDown            = "notify_on_frps_down"
+	KeyNotifyOnAgentUnreachable    = "notify_on_agent_unreachable"
+	KeyNotifyOnRouteDrift          = "notify_on_route_drift"
+	KeyNotifyFrpsFailThreshold     = "notify_frps_fail_threshold"
+	KeyNotifyAgentFailThreshold    = "notify_agent_fail_threshold"
+	KeyNotifyDriftCooldownHours    = "notify_drift_cooldown_hours"
+	KeyBackupAutoEnabled           = "backup_auto_enabled"
+	KeyBackupKeepCount             = "backup_keep_count"
+	KeyRouteHealthEnabled          = "route_health_enabled"
+	KeyStatusPageEnabled           = "status_page_enabled"
+	KeyMetricsHistoryEnabled       = "metrics_history_enabled"
+	// KeyStatusPageCodeHash 存访问码的 SHA-256;明文只在保存请求里出现一次
+	KeyStatusPageCodeHash = "status_page_code_hash"
+	// KeyStatusPageCode 只是「保存请求」里用的字段名,不会被直接写库
+	KeyStatusPageCode        = "status_page_code"
+	KeyNotifyOnBackupFailure = "notify_on_backup_failure"
+	KeyTrustedProxy          = "trusted_proxy_json"
+	KeyGlobalIPBlacklist     = "global_ip_blacklist"
+	KeyGlobalIPWhitelist     = "global_ip_whitelist"
+	KeyChinaCIDRUpdateHours  = "china_cidr_update_interval_hours"
+	KeyChinaCIDRSourceV4     = "china_cidr_source_url_v4"
+	KeyChinaCIDRSourceV6     = "china_cidr_source_url_v6"
+	KeyChinaCIDRUpdatedAt    = "china_cidr_updated_at"
+	KeyChinaCIDRCountV4      = "china_cidr_count_v4"
+	KeyChinaCIDRCountV6      = "china_cidr_count_v6"
+	KeyChinaCIDRLastError    = "china_cidr_last_error"
+	KeyFRPEnabled            = "frp_enabled"
+	KeyFRPServerAddr         = "frp_server_addr"
+	KeyFRPServerPort         = "frp_server_port"
+	KeyFRPAuthToken          = "frp_auth_token"
+	KeyFRPTLSEnabled         = "frp_tls_enabled"
+	KeyFRPCustomDomains      = "frp_custom_domains"
+	KeyFRPLastError          = "frp_last_error"
+	KeyFRPStartedAt          = "frp_started_at"
+	KeyNginxGlobalMode       = "nginx_global_mode"
+)
+
+var Defaults = map[string]string{
+	KeyTheme:                "system",
+	KeyTimezone:             "Asia/Shanghai",
+	KeyLogRetentionDays:     "30",
+	KeyDDNSCheckInterval:    "5",
+	KeyCertRenewThreshold:   "30",
+	KeyACMECA:               "letsencrypt",
+	KeyChinaCIDRUpdateHours: "24",
+	KeyNginxGlobalMode:      "auto",
+}
+
+type Store struct {
+	db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func (s *Store) Get(ctx context.Context, key string) (string, error) {
+	var value string
+	err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
+	if err == sql.ErrNoRows {
+		if def, ok := Defaults[key]; ok {
+			return def, nil
+		}
+		return "", nil
+	}
+	return value, err
+}
+
+func (s *Store) Set(ctx context.Context, key, value string) error {
+	_, err := s.db.ExecContext(ctx, `
+		INSERT INTO settings(key, value) VALUES (?, ?)
+		ON CONFLICT(key) DO UPDATE SET value = excluded.value
+	`, key, value)
+	return err
+}
+
+func (s *Store) GetBool(ctx context.Context, key string) (bool, error) {
+	raw, err := s.Get(ctx, key)
+	if err != nil {
+		return false, err
+	}
+	switch strings.ToLower(strings.TrimSpace(raw)) {
+	case "1", "true", "yes", "on":
+		return true, nil
+	default:
+		return false, nil
+	}
+}
+
+func (s *Store) SetBool(ctx context.Context, key string, value bool) error {
+	if value {
+		return s.Set(ctx, key, "true")
+	}
+	return s.Set(ctx, key, "false")
+}
+
+func (s *Store) SetInt(ctx context.Context, key string, value int) error {
+	return s.Set(ctx, key, strconv.Itoa(value))
+}
+
+func (s *Store) GetInt(ctx context.Context, key string) (int, error) {
+	raw, err := s.Get(ctx, key)
+	if err != nil {
+		return 0, err
+	}
+	if raw == "" {
+		if def, ok := Defaults[key]; ok {
+			raw = def
+		}
+	}
+	n, err := strconv.Atoi(raw)
+	if err != nil {
+		return 0, err
+	}
+	return n, nil
+}
+
+func (s *Store) List(ctx context.Context) (map[string]string, error) {
+	rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	out := map[string]string{}
+	for k, v := range Defaults {
+		out[k] = v
+	}
+	for rows.Next() {
+		var key, value string
+		if err := rows.Scan(&key, &value); err != nil {
+			return nil, err
+		}
+		out[key] = value
+	}
+	return out, rows.Err()
+}
+
+func (s *Store) SetMany(ctx context.Context, values map[string]string) error {
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+	defer func() { _ = tx.Rollback() }()
+
+	for key, value := range values {
+		if _, err := tx.ExecContext(ctx, `
+			INSERT INTO settings(key, value) VALUES (?, ?)
+			ON CONFLICT(key) DO UPDATE SET value = excluded.value
+		`, key, value); err != nil {
+			return err
+		}
+	}
+	return tx.Commit()
+}
diff --git a/app-src/internal/sysinfo/sysinfo.go b/app-src/internal/sysinfo/sysinfo.go
new file mode 100644
index 0000000..b142ff7
--- /dev/null
+++ b/app-src/internal/sysinfo/sysinfo.go
@@ -0,0 +1,105 @@
+// Package sysinfo 采集主机资源占用(CPU / 内存 / 磁盘),供仪表盘展示。
+//
+// 注意:在容器(Docker)里运行时,/proc 与根文件系统通常反映的是宿主机的总量与占用
+// (除非挂载了 lxcfs 之类的视图隔离),因此这里的数值是「宿主机视角」,不是容器配额。
+package sysinfo
+
+import (
+	"strconv"
+	"strings"
+)
+
+// CPUSample 是 CPU 累计计数器的一次快照;占用率需要两次采样求差值
+type CPUSample struct {
+	Total uint64
+	Idle  uint64
+}
+
+// Stats 是主机资源快照;当前平台采集不到的字段保持 0
+type Stats struct {
+	CPUPercent     float64
+	MemUsedBytes   uint64
+	MemTotalBytes  uint64
+	DiskUsedBytes  uint64
+	DiskTotalBytes uint64
+}
+
+// Read 采集一次主机资源。cpuPrev 为上次采样的 CPU 计数器(零值表示首次采集,
+// 此时 CPUPercent 为 0),返回的 sample 供下次调用传入。
+func Read(cpuPrev CPUSample) (Stats, CPUSample) {
+	stats, sample := readPlatform()
+	stats.CPUPercent = CPUPercent(cpuPrev, sample)
+	return stats, sample
+}
+
+// CPUPercent 由两次采样的差值计算占用率;首次采样或计数器未前进时返回 0
+func CPUPercent(prev, cur CPUSample) float64 {
+	if prev.Total == 0 || cur.Total <= prev.Total {
+		return 0
+	}
+	totalDelta := cur.Total - prev.Total
+	var idleDelta uint64
+	if cur.Idle > prev.Idle {
+		idleDelta = cur.Idle - prev.Idle
+	}
+	if idleDelta >= totalDelta {
+		return 0
+	}
+	return float64(totalDelta-idleDelta) / float64(totalDelta) * 100
+}
+
+// parseProcStat 解析 /proc/stat 的 cpu 汇总行;第 4、5 列(idle、iowait)都计入空闲
+func parseProcStat(raw string) CPUSample {
+	for _, line := range strings.Split(raw, "\n") {
+		fields := strings.Fields(line)
+		if len(fields) < 5 || fields[0] != "cpu" {
+			continue
+		}
+		var total, idle uint64
+		for i, field := range fields[1:] {
+			value, err := strconv.ParseUint(field, 10, 64)
+			if err != nil {
+				return CPUSample{}
+			}
+			total += value
+			if i == 3 || i == 4 {
+				idle += value
+			}
+		}
+		return CPUSample{Total: total, Idle: idle}
+	}
+	return CPUSample{}
+}
+
+// parseMemInfo 解析 /proc/meminfo(值以 kB 为单位),返回总内存与已用内存字节数。
+// 已用内存优先按 MemAvailable 计算,缺失时回落到 MemFree + Buffers + Cached。
+func parseMemInfo(raw string) (total, used uint64) {
+	values := make(map[string]uint64, 16)
+	for _, line := range strings.Split(raw, "\n") {
+		key, value, ok := strings.Cut(line, ":")
+		if !ok {
+			continue
+		}
+		fields := strings.Fields(value)
+		if len(fields) == 0 {
+			continue
+		}
+		amount, err := strconv.ParseUint(fields[0], 10, 64)
+		if err != nil {
+			continue
+		}
+		values[strings.TrimSpace(key)] = amount * 1024
+	}
+	total = values["MemTotal"]
+	if total == 0 {
+		return 0, 0
+	}
+	available := values["MemAvailable"]
+	if available == 0 {
+		available = values["MemFree"] + values["Buffers"] + values["Cached"]
+	}
+	if available > total {
+		return total, 0
+	}
+	return total, total - available
+}
diff --git a/app-src/internal/sysinfo/sysinfo_linux.go b/app-src/internal/sysinfo/sysinfo_linux.go
new file mode 100644
index 0000000..0e2f1b5
--- /dev/null
+++ b/app-src/internal/sysinfo/sysinfo_linux.go
@@ -0,0 +1,76 @@
+//go:build linux
+
+package sysinfo
+
+import (
+	"os"
+	"strconv"
+	"strings"
+	"syscall"
+)
+
+func readPlatform() (Stats, CPUSample) {
+	var (
+		stats  Stats
+		sample CPUSample
+	)
+	if raw, err := os.ReadFile("/proc/stat"); err == nil {
+		sample = parseProcStat(string(raw))
+	}
+	if raw, err := os.ReadFile("/proc/meminfo"); err == nil {
+		stats.MemTotalBytes, stats.MemUsedBytes = parseMemInfo(string(raw))
+	}
+	stats.DiskTotalBytes, stats.DiskUsedBytes = diskUsage("/")
+	return stats, sample
+}
+
+// LoadAvg 返回 1 / 5 / 15 分钟平均负载;读取失败返回 nil
+func LoadAvg() []float64 {
+	raw, err := os.ReadFile("/proc/loadavg")
+	if err != nil {
+		return nil
+	}
+	fields := strings.Fields(string(raw))
+	if len(fields) < 3 {
+		return nil
+	}
+	values := make([]float64, 0, 3)
+	for _, field := range fields[:3] {
+		value, err := strconv.ParseFloat(field, 64)
+		if err != nil {
+			return nil
+		}
+		values = append(values, value)
+	}
+	return values
+}
+
+// UptimeSeconds 返回系统运行秒数(/proc/uptime)
+func UptimeSeconds() uint64 {
+	raw, err := os.ReadFile("/proc/uptime")
+	if err != nil {
+		return 0
+	}
+	fields := strings.Fields(string(raw))
+	if len(fields) == 0 {
+		return 0
+	}
+	seconds, err := strconv.ParseFloat(fields[0], 64)
+	if err != nil || seconds < 0 {
+		return 0
+	}
+	return uint64(seconds)
+}
+
+func diskUsage(path string) (total, used uint64) {
+	var stat syscall.Statfs_t
+	if err := syscall.Statfs(path, &stat); err != nil {
+		return 0, 0
+	}
+	total = stat.Blocks * uint64(stat.Bsize)
+	free := stat.Bavail * uint64(stat.Bsize)
+	if free > total {
+		return total, 0
+	}
+	return total, total - free
+}
diff --git a/app-src/internal/sysinfo/sysinfo_other.go b/app-src/internal/sysinfo/sysinfo_other.go
new file mode 100644
index 0000000..c0652b6
--- /dev/null
+++ b/app-src/internal/sysinfo/sysinfo_other.go
@@ -0,0 +1,18 @@
+//go:build !linux && !windows
+
+package sysinfo
+
+// readPlatform 在未适配的平台(macOS / BSD 等)上返回空快照,前端按「不可用」处理
+func readPlatform() (Stats, CPUSample) {
+	return Stats{}, CPUSample{}
+}
+
+// LoadAvg 未适配的平台没有平均负载概念
+func LoadAvg() []float64 {
+	return nil
+}
+
+// UptimeSeconds 未适配的平台不采集运行时长
+func UptimeSeconds() uint64 {
+	return 0
+}
diff --git a/app-src/internal/sysinfo/sysinfo_test.go b/app-src/internal/sysinfo/sysinfo_test.go
new file mode 100644
index 0000000..4312099
--- /dev/null
+++ b/app-src/internal/sysinfo/sysinfo_test.go
@@ -0,0 +1,61 @@
+package sysinfo
+
+import "testing"
+
+func TestCPUPercent(t *testing.T) {
+	prev := CPUSample{Total: 1000, Idle: 400}
+	if got := CPUPercent(prev, CPUSample{Total: 1200, Idle: 500}); got != 50 {
+		t.Fatalf("expected 50%%, got %v", got)
+	}
+	// 首次采样(prev 为零值)不给出占用率
+	if got := CPUPercent(CPUSample{}, CPUSample{Total: 1200, Idle: 500}); got != 0 {
+		t.Fatalf("expected 0 on first sample, got %v", got)
+	}
+	// 计数器未前进
+	if got := CPUPercent(prev, prev); got != 0 {
+		t.Fatalf("expected 0 when counters stalled, got %v", got)
+	}
+}
+
+func TestParseProcStat(t *testing.T) {
+	raw := "cpu  100 20 80 400 10 0 5 0 0 0\ncpu0 1 2 3 4 5 6 7 8 9 10\n"
+	sample := parseProcStat(raw)
+	// 总时间 100+20+80+400+10+0+5 = 615,空闲 400(idle)+10(iowait) = 410
+	if sample.Total != 615 || sample.Idle != 410 {
+		t.Fatalf("unexpected sample: %+v", sample)
+	}
+}
+
+func TestParseMemInfo(t *testing.T) {
+	raw := "MemTotal:       16384000 kB\nMemFree:         1000000 kB\nMemAvailable:    8192000 kB\n"
+	total, used := parseMemInfo(raw)
+	if total != 16384000*1024 {
+		t.Fatalf("unexpected total: %d", total)
+	}
+	if used != (16384000-8192000)*1024 {
+		t.Fatalf("unexpected used: %d", used)
+	}
+
+	// 缺少 MemAvailable 时回落到 MemFree + Buffers + Cached
+	fallback := "MemTotal:       1000 kB\nMemFree:         200 kB\nBuffers:         100 kB\nCached:          300 kB\n"
+	total, used = parseMemInfo(fallback)
+	if total != 1000*1024 || used != 400*1024 {
+		t.Fatalf("unexpected fallback: total=%d used=%d", total, used)
+	}
+}
+
+func TestReadReportsHostResources(t *testing.T) {
+	stats, sample := Read(CPUSample{})
+	if stats.MemTotalBytes == 0 && stats.DiskTotalBytes == 0 {
+		t.Skip("当前平台未实现资源采集")
+	}
+	if stats.MemTotalBytes == 0 || stats.MemUsedBytes > stats.MemTotalBytes {
+		t.Fatalf("unexpected memory: used=%d total=%d", stats.MemUsedBytes, stats.MemTotalBytes)
+	}
+	if stats.DiskTotalBytes == 0 || stats.DiskUsedBytes > stats.DiskTotalBytes {
+		t.Fatalf("unexpected disk: used=%d total=%d", stats.DiskUsedBytes, stats.DiskTotalBytes)
+	}
+	if sample.Total == 0 {
+		t.Fatalf("expected non-zero cpu counters")
+	}
+}
diff --git a/app-src/internal/sysinfo/sysinfo_windows.go b/app-src/internal/sysinfo/sysinfo_windows.go
new file mode 100644
index 0000000..e987c24
--- /dev/null
+++ b/app-src/internal/sysinfo/sysinfo_windows.go
@@ -0,0 +1,105 @@
+//go:build windows
+
+package sysinfo
+
+import (
+	"os"
+	"syscall"
+	"unsafe"
+)
+
+var (
+	kernel32                 = syscall.NewLazyDLL("kernel32.dll")
+	procGetSystemTimes       = kernel32.NewProc("GetSystemTimes")
+	procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
+	procGetDiskFreeSpaceExW  = kernel32.NewProc("GetDiskFreeSpaceExW")
+	procGetTickCount64       = kernel32.NewProc("GetTickCount64")
+)
+
+type memoryStatusEx struct {
+	Length               uint32
+	MemoryLoad           uint32
+	TotalPhys            uint64
+	AvailPhys            uint64
+	TotalPageFile        uint64
+	AvailPageFile        uint64
+	TotalVirtual         uint64
+	AvailVirtual         uint64
+	AvailExtendedVirtual uint64
+}
+
+func readPlatform() (Stats, CPUSample) {
+	var (
+		stats  Stats
+		sample CPUSample
+	)
+
+	var idle, kernel, user syscall.Filetime
+	if ret, _, _ := procGetSystemTimes.Call(
+		uintptr(unsafe.Pointer(&idle)),
+		uintptr(unsafe.Pointer(&kernel)),
+		uintptr(unsafe.Pointer(&user)),
+	); ret != 0 {
+		// kernel 时间包含 idle,因此总时间 = kernel + user
+		sample = CPUSample{
+			Total: filetimeTicks(kernel) + filetimeTicks(user),
+			Idle:  filetimeTicks(idle),
+		}
+	}
+
+	var mem memoryStatusEx
+	mem.Length = uint32(unsafe.Sizeof(mem))
+	if ret, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&mem))); ret != 0 {
+		stats.MemTotalBytes = mem.TotalPhys
+		stats.MemUsedBytes = mem.TotalPhys - mem.AvailPhys
+	}
+
+	stats.DiskTotalBytes, stats.DiskUsedBytes = diskUsage(volumeRoot())
+	return stats, sample
+}
+
+// volumeRoot 取当前工作目录所在盘符(Havline 的数据目录与工作目录同盘),失败时回落到 C:\
+func volumeRoot() string {
+	wd, err := os.Getwd()
+	if err != nil || len(wd) < 2 || wd[1] != ':' {
+		return `C:\`
+	}
+	return wd[:3]
+}
+
+func diskUsage(root string) (total, used uint64) {
+	rootPtr, err := syscall.UTF16PtrFromString(root)
+	if err != nil {
+		return 0, 0
+	}
+	var freeToCaller, totalBytes, totalFree uint64
+	ret, _, _ := procGetDiskFreeSpaceExW.Call(
+		uintptr(unsafe.Pointer(rootPtr)),
+		uintptr(unsafe.Pointer(&freeToCaller)),
+		uintptr(unsafe.Pointer(&totalBytes)),
+		uintptr(unsafe.Pointer(&totalFree)),
+	)
+	if ret == 0 || totalBytes == 0 {
+		return 0, 0
+	}
+	if totalFree > totalBytes {
+		return totalBytes, 0
+	}
+	return totalBytes, totalBytes - totalFree
+}
+
+// LoadAvg Windows 没有平均负载概念,固定返回 nil
+func LoadAvg() []float64 {
+	return nil
+}
+
+// UptimeSeconds 用 GetTickCount64 取系统运行秒数
+func UptimeSeconds() uint64 {
+	ret, _, _ := procGetTickCount64.Call()
+	return uint64(ret) / 1000
+}
+
+// filetimeTicks 把 FILETIME(100ns 单位)折算为 10ms 刻度,避免累加时溢出
+func filetimeTicks(ft syscall.Filetime) uint64 {
+	return (uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)) / 10000
+}
diff --git a/app-src/internal/traffic/collector.go b/app-src/internal/traffic/collector.go
new file mode 100644
index 0000000..1b2cf35
--- /dev/null
+++ b/app-src/internal/traffic/collector.go
@@ -0,0 +1,384 @@
+package traffic
+
+import (
+	"bufio"
+	"context"
+	"database/sql"
+	"io"
+	"os"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/havline/havline/internal/logstore"
+	"github.com/havline/havline/internal/notify"
+	"github.com/havline/havline/internal/proxy"
+)
+
+const (
+	rateWindow        = 5 * time.Second
+	activeWindow      = 65 * time.Second
+	persistInterval   = 30 * time.Second
+	maxSamplesPerHost = 256
+)
+
+type RuleTraffic struct {
+	RuleID        int64   `json:"rule_id"`
+	UploadTotal   int64   `json:"upload_total"`
+	DownloadTotal int64   `json:"download_total"`
+	UploadRate    float64 `json:"upload_rate"`
+	DownloadRate  float64 `json:"download_rate"`
+	Connections   int     `json:"connections"`
+}
+
+type ClientConn struct {
+	IP       string `json:"ip"`
+	LastSeen string `json:"last_seen"`
+}
+
+type Collector struct {
+	path   string
+	store  *Store
+	notify *notify.Service
+
+	mu    sync.RWMutex
+	hosts map[string]*hostState
+}
+
+type hostState struct {
+	uploadTotal   int64
+	downloadTotal int64
+	clients       map[string]time.Time
+	samples       []sample
+}
+
+type sample struct {
+	at   time.Time
+	up   int64
+	down int64
+}
+
+func NewCollector(db *sql.DB, accessLogPath string, notifySvc *notify.Service) *Collector {
+	return &Collector{
+		path:   accessLogPath,
+		store:  NewStore(db),
+		notify: notifySvc,
+		hosts:  map[string]*hostState{},
+	}
+}
+
+func (c *Collector) Start(ctx context.Context) {
+	if totals, err := c.store.LoadTotals(ctx); err == nil {
+		c.mu.Lock()
+		for host, t := range totals {
+			st := c.ensureHost(host)
+			st.uploadTotal = t.Upload
+			st.downloadTotal = t.Download
+		}
+		c.mu.Unlock()
+	}
+
+	go c.tailLoop(ctx)
+	go c.persistLoop(ctx)
+}
+
+func (c *Collector) tailLoop(ctx context.Context) {
+	offset := c.logOffset()
+	for {
+		if ctx.Err() != nil {
+			return
+		}
+		offset = c.readFrom(offset)
+		time.Sleep(500 * time.Millisecond)
+	}
+}
+
+func (c *Collector) logOffset() int64 {
+	info, err := os.Stat(c.path)
+	if err != nil {
+		return 0
+	}
+	return info.Size()
+}
+
+func (c *Collector) readFrom(offset int64) int64 {
+	file, err := os.Open(c.path)
+	if err != nil {
+		return offset
+	}
+	defer file.Close()
+
+	info, err := file.Stat()
+	if err != nil {
+		return offset
+	}
+	if info.Size() < offset {
+		offset = 0
+	}
+	if _, err := file.Seek(offset, 0); err != nil {
+		return offset
+	}
+
+	reader := bufio.NewReader(file)
+	for {
+		line, err := reader.ReadString('\n')
+		if len(line) > 0 {
+			c.ingestLine(strings.TrimRight(line, "\r\n"), true)
+		}
+		if err != nil {
+			if err == io.EOF {
+				pos, seekErr := file.Seek(0, io.SeekCurrent)
+				if seekErr == nil {
+					return pos
+				}
+				return info.Size()
+			}
+			return offset
+		}
+	}
+}
+
+func (c *Collector) persistLoop(ctx context.Context) {
+	ticker := time.NewTicker(persistInterval)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			_ = c.persist(context.Background())
+			return
+		case <-ticker.C:
+			_ = c.persist(ctx)
+		}
+	}
+}
+
+func (c *Collector) persist(ctx context.Context) error {
+	c.mu.RLock()
+	snapshot := make(map[string]hostTotals, len(c.hosts))
+	for host, st := range c.hosts {
+		snapshot[host] = hostTotals{Upload: st.uploadTotal, Download: st.downloadTotal}
+	}
+	c.mu.RUnlock()
+	return c.store.SaveTotals(ctx, snapshot)
+}
+
+func (c *Collector) ingestLine(line string, trackLive bool) {
+	entry, ok := logstore.ParseAccess(line)
+	if !ok || entry.Domain == "" {
+		return
+	}
+	host := EndpointKey(entry.Domain, entry.ServerPort)
+	up := entry.RequestLength
+	down := entry.BytesSent
+	if up == 0 && down == 0 && !trackLive {
+		return
+	}
+
+	now := time.Now()
+	hitIP := ""
+	c.mu.Lock()
+	st := c.ensureHost(host)
+	st.uploadTotal += up
+	st.downloadTotal += down
+	if trackLive {
+		if up > 0 || down > 0 {
+			st.samples = append(st.samples, sample{at: now, up: up, down: down})
+			if len(st.samples) > maxSamplesPerHost {
+				st.samples = st.samples[len(st.samples)-maxSamplesPerHost:]
+			}
+		}
+		if entry.ClientIP != "" && entry.ClientIP != "-" {
+			st.clients[entry.ClientIP] = now
+			// 锁内只记 IP:RecordAccessHit 会写库并判断通知,放在锁外,避免阻塞读侧
+			hitIP = entry.ClientIP
+		}
+		c.pruneHost(st, now)
+	}
+	c.mu.Unlock()
+	if hitIP != "" && c.notify != nil {
+		c.notify.RecordAccessHit(context.Background(), hitIP)
+	}
+}
+
+func (c *Collector) ensureHost(host string) *hostState {
+	st, ok := c.hosts[host]
+	if !ok {
+		st = &hostState{clients: map[string]time.Time{}}
+		c.hosts[host] = st
+	}
+	return st
+}
+
+func (c *Collector) pruneHost(st *hostState, now time.Time) {
+	cutoff := now.Add(-rateWindow)
+	for i := 0; i < len(st.samples); {
+		if st.samples[i].at.Before(cutoff) {
+			st.samples = append(st.samples[:i], st.samples[i+1:]...)
+			continue
+		}
+		i++
+	}
+	activeCutoff := now.Add(-activeWindow)
+	for ip, seen := range st.clients {
+		if seen.Before(activeCutoff) {
+			delete(st.clients, ip)
+		}
+	}
+}
+
+func (c *Collector) aggregateEndpoints(endpoints []proxy.Endpoint, now time.Time) (uploadTotal, downloadTotal int64, uploadRate, downloadRate float64, connections int) {
+	keys := make([]string, 0, len(endpoints))
+	for _, ep := range endpoints {
+		keys = append(keys, EndpointKey(ep.Hostname, ep.Port))
+	}
+	return c.aggregateHostKeys(keys, now)
+}
+
+// aggregateHostKeys 汇总若干 host:port 条目的总量、速率与活跃连接数
+func (c *Collector) aggregateHostKeys(keys []string, now time.Time) (uploadTotal, downloadTotal int64, uploadRate, downloadRate float64, connections int) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+
+	var upSamples, downSamples int64
+	var oldest time.Time
+	clientSet := map[string]time.Time{}
+
+	for _, key := range keys {
+		st, ok := c.hosts[key]
+		if !ok {
+			continue
+		}
+		uploadTotal += st.uploadTotal
+		downloadTotal += st.downloadTotal
+		cutoff := now.Add(-rateWindow)
+		for _, s := range st.samples {
+			if s.at.Before(cutoff) {
+				continue
+			}
+			upSamples += s.up
+			downSamples += s.down
+			if oldest.IsZero() || s.at.Before(oldest) {
+				oldest = s.at
+			}
+		}
+		activeCutoff := now.Add(-activeWindow)
+		for ip, seen := range st.clients {
+			if seen.Before(activeCutoff) {
+				continue
+			}
+			if prev, ok := clientSet[ip]; !ok || seen.After(prev) {
+				clientSet[ip] = seen
+			}
+		}
+	}
+
+	span := now.Sub(oldest).Seconds()
+	if span < 0.5 {
+		span = 0.5
+	}
+	if upSamples > 0 {
+		uploadRate = float64(upSamples) / span
+	}
+	if downSamples > 0 {
+		downloadRate = float64(downSamples) / span
+	}
+	connections = len(clientSet)
+	return uploadTotal, downloadTotal, uploadRate, downloadRate, connections
+}
+
+func (c *Collector) SnapshotForRules(rules []proxy.Rule) []RuleTraffic {
+	now := time.Now()
+	out := make([]RuleTraffic, 0, len(rules))
+	for _, rule := range rules {
+		endpoints := rule.Endpoints()
+		up, down, upRate, downRate, conns := c.aggregateEndpoints(endpoints, now)
+		out = append(out, RuleTraffic{
+			RuleID:        rule.ID,
+			UploadTotal:   up,
+			DownloadTotal: down,
+			UploadRate:    upRate,
+			DownloadRate:  downRate,
+			Connections:   conns,
+		})
+	}
+	return out
+}
+
+// HostTraffic 单个域名的当前速率与总量(指标历史按域名落点)
+type HostTraffic struct {
+	Hostname      string  `json:"hostname"`
+	UploadTotal   int64   `json:"upload_total"`
+	DownloadTotal int64   `json:"download_total"`
+	UploadRate    float64 `json:"upload_rate"`
+	DownloadRate  float64 `json:"download_rate"`
+	Connections   int     `json:"connections"`
+}
+
+// SnapshotByHost 按域名汇总当前速率:内部键是 host:port,
+// 同一域名的多个端口会合并,与 aggregateEndpoints 的算法保持一致。
+func (c *Collector) SnapshotByHost(hostnames []string) map[string]HostTraffic {
+	now := time.Now()
+	out := map[string]HostTraffic{}
+	for _, host := range hostnames {
+		host = strings.ToLower(strings.TrimSpace(host))
+		if host == "" {
+			continue
+		}
+		prefix := host + ":"
+		c.mu.RLock()
+		keys := make([]string, 0, 2)
+		for key := range c.hosts {
+			if strings.HasPrefix(key, prefix) {
+				keys = append(keys, key)
+			}
+		}
+		c.mu.RUnlock()
+		if len(keys) == 0 {
+			continue
+		}
+		up, down, upRate, downRate, conns := c.aggregateHostKeys(keys, now)
+		out[host] = HostTraffic{
+			Hostname:      host,
+			UploadTotal:   up,
+			DownloadTotal: down,
+			UploadRate:    upRate,
+			DownloadRate:  downRate,
+			Connections:   conns,
+		}
+	}
+	return out
+}
+
+func (c *Collector) ClientsForRule(rule proxy.Rule) []ClientConn {
+	now := time.Now()
+	activeCutoff := now.Add(-activeWindow)
+	clientSet := map[string]time.Time{}
+
+	c.mu.RLock()
+	for _, ep := range rule.Endpoints() {
+		key := EndpointKey(ep.Hostname, ep.Port)
+		st, ok := c.hosts[key]
+		if !ok {
+			continue
+		}
+		for ip, seen := range st.clients {
+			if seen.Before(activeCutoff) {
+				continue
+			}
+			if prev, ok := clientSet[ip]; !ok || seen.After(prev) {
+				clientSet[ip] = seen
+			}
+		}
+	}
+	c.mu.RUnlock()
+
+	out := make([]ClientConn, 0, len(clientSet))
+	for ip, seen := range clientSet {
+		out = append(out, ClientConn{IP: ip, LastSeen: seen.UTC().Format(time.RFC3339)})
+	}
+	sort.Slice(out, func(i, j int) bool {
+		return out[i].LastSeen > out[j].LastSeen
+	})
+	return out
+}
diff --git a/app-src/internal/traffic/collector_test.go b/app-src/internal/traffic/collector_test.go
new file mode 100644
index 0000000..76ebdf1
--- /dev/null
+++ b/app-src/internal/traffic/collector_test.go
@@ -0,0 +1,53 @@
+package traffic
+
+import (
+	"testing"
+	"time"
+
+	"github.com/havline/havline/internal/proxy"
+)
+
+func TestCollectorAttributesConnectionsByHostAndPort(t *testing.T) {
+	c := NewCollector(nil, "", nil)
+	now := time.Now()
+	line6893 := "2026-09-17T08:30:00+08:00 xxx.com 6893 GET /login 200 0.010 218.88.23.20 192.168.8.3:6893 100 200"
+	line8443 := "2026-09-17T08:30:01+08:00 xxx.com 8443 GET / 200 0.010 218.88.23.20 192.168.8.3:8443 100 200"
+
+	c.ingestLine(line6893, true)
+	c.ingestLine(line8443, true)
+
+	rules := []proxy.Rule{
+		{
+			ID:         1,
+			ListenPort: 6893,
+			Hosts:      []proxy.Host{{Hostname: "xxx.com"}},
+		},
+		{
+			ID:         2,
+			ListenPort: 8443,
+			Hosts:      []proxy.Host{{Hostname: "xxx.com"}},
+		},
+	}
+
+	stats := c.SnapshotForRules(rules)
+	if len(stats) != 2 {
+		t.Fatalf("expected 2 stats, got %d", len(stats))
+	}
+	byID := map[int64]RuleTraffic{}
+	for _, s := range stats {
+		byID[s.RuleID] = s
+	}
+	if byID[1].Connections != 1 {
+		t.Fatalf("rule 6893 connections=%d want 1", byID[1].Connections)
+	}
+	if byID[2].Connections != 1 {
+		t.Fatalf("rule 8443 connections=%d want 1", byID[2].Connections)
+	}
+	_ = now
+}
+
+func TestEndpointKeyIncludesPort(t *testing.T) {
+	if got := EndpointKey("XXX.com", 6893); got != "xxx.com:6893" {
+		t.Fatalf("got %q", got)
+	}
+}
diff --git a/app-src/internal/traffic/endpoint.go b/app-src/internal/traffic/endpoint.go
new file mode 100644
index 0000000..7d2cc57
--- /dev/null
+++ b/app-src/internal/traffic/endpoint.go
@@ -0,0 +1,17 @@
+package traffic
+
+import (
+	"fmt"
+	"strings"
+)
+
+func EndpointKey(host string, port int) string {
+	host = strings.ToLower(strings.TrimSpace(host))
+	if i := strings.Index(host, ":"); i > 0 {
+		host = host[:i]
+	}
+	if port <= 0 {
+		return host
+	}
+	return fmt.Sprintf("%s:%d", host, port)
+}
diff --git a/app-src/internal/traffic/store.go b/app-src/internal/traffic/store.go
new file mode 100644
index 0000000..00a54ad
--- /dev/null
+++ b/app-src/internal/traffic/store.go
@@ -0,0 +1,63 @@
+package traffic
+
+import (
+	"context"
+	"database/sql"
+	"strings"
+	"time"
+)
+
+type Store struct {
+	db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+	return &Store{db: db}
+}
+
+func (s *Store) LoadTotals(ctx context.Context) (map[string]hostTotals, error) {
+	rows, err := s.db.QueryContext(ctx, `SELECT host, upload_bytes, download_bytes FROM proxy_traffic`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	out := map[string]hostTotals{}
+	for rows.Next() {
+		var host string
+		var up, down int64
+		if err := rows.Scan(&host, &up, &down); err != nil {
+			return nil, err
+		}
+		key := strings.ToLower(strings.TrimSpace(host))
+		out[key] = hostTotals{Upload: up, Download: down}
+	}
+	return out, rows.Err()
+}
+
+func (s *Store) SaveTotals(ctx context.Context, hosts map[string]hostTotals) error {
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+	for host, totals := range hosts {
+		_, err := tx.ExecContext(ctx, `
+			INSERT INTO proxy_traffic(host, upload_bytes, download_bytes, updated_at)
+			VALUES (?, ?, ?, ?)
+			ON CONFLICT(host) DO UPDATE SET
+				upload_bytes = excluded.upload_bytes,
+				download_bytes = excluded.download_bytes,
+				updated_at = excluded.updated_at
+		`, host, totals.Upload, totals.Download, time.Now().UTC().Format(time.RFC3339))
+		if err != nil {
+			_ = tx.Rollback()
+			return err
+		}
+	}
+	return tx.Commit()
+}
+
+type hostTotals struct {
+	Upload   int64
+	Download int64
+}
diff --git a/app-src/internal/updater/client.go b/app-src/internal/updater/client.go
new file mode 100644
index 0000000..98984ef
--- /dev/null
+++ b/app-src/internal/updater/client.go
@@ -0,0 +1,98 @@
+package updater
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+)
+
+// Client 通过共享 Unix Socket 调用 havline-updater sidecar。
+type Client struct {
+	socketPath string
+	http       *http.Client
+}
+
+func NewClient(socketPath string) *Client {
+	socketPath = strings.TrimSpace(socketPath)
+	transport := &http.Transport{
+		DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+			var dialer net.Dialer
+			return dialer.DialContext(ctx, "unix", socketPath)
+		},
+	}
+	return &Client{
+		socketPath: socketPath,
+		http:       &http.Client{Transport: transport, Timeout: 15 * time.Second},
+	}
+}
+
+func (c *Client) Enabled() bool {
+	if c == nil || c.socketPath == "" {
+		return false
+	}
+	info, err := os.Stat(c.socketPath)
+	return err == nil && !info.IsDir()
+}
+
+func (c *Client) Status(ctx context.Context) (Status, error) {
+	var status Status
+	if err := c.request(ctx, http.MethodGet, "/status", nil, &status); err != nil {
+		return Status{}, err
+	}
+	return status, nil
+}
+
+func (c *Client) Apply(ctx context.Context) (Status, error) {
+	var status Status
+	if err := c.request(ctx, http.MethodPost, "/apply", map[string]string{}, &status); err != nil {
+		return Status{}, err
+	}
+	return status, nil
+}
+
+func (c *Client) request(ctx context.Context, method, path string, payload any, result any) error {
+	if !c.Enabled() {
+		return fmt.Errorf("一键升级服务未启用")
+	}
+	var body io.Reader
+	if payload != nil {
+		data, err := json.Marshal(payload)
+		if err != nil {
+			return err
+		}
+		body = bytes.NewReader(data)
+	}
+	req, err := http.NewRequestWithContext(ctx, method, "http://unix"+path, body)
+	if err != nil {
+		return err
+	}
+	if payload != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return fmt.Errorf("updater 返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
+	}
+	if result == nil {
+		return nil
+	}
+	if err := json.Unmarshal(data, result); err != nil {
+		return fmt.Errorf("updater 响应格式无效: %w", err)
+	}
+	return nil
+}
diff --git a/app-src/internal/updater/model.go b/app-src/internal/updater/model.go
new file mode 100644
index 0000000..c85ec2c
--- /dev/null
+++ b/app-src/internal/updater/model.go
@@ -0,0 +1,95 @@
+package updater
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+type Phase string
+
+const (
+	PhaseIdle      Phase = "idle"
+	PhaseScheduled Phase = "scheduled"
+	PhaseRunning   Phase = "running"
+	PhaseSuccess   Phase = "success"
+	PhaseFailed    Phase = "failed"
+)
+
+// Status 是主程序与 havline-updater sidecar 共享的更新状态。
+type Status struct {
+	Enabled         bool     `json:"enabled"`
+	Mode            string   `json:"mode"`
+	CurrentVersion  string   `json:"current_version,omitempty"`
+	LatestVersion   string   `json:"latest_version,omitempty"`
+	UpdateAvailable bool     `json:"update_available"`
+	Busy            bool     `json:"busy"`
+	Phase           Phase    `json:"phase"`
+	Message         string   `json:"message"`
+	Log             []string `json:"log,omitempty"`
+	CheckedAt       string   `json:"checked_at,omitempty"`
+}
+
+func busyPhase(phase Phase) bool {
+	return phase == PhaseScheduled || phase == PhaseRunning
+}
+
+// CompareVersions 比较形如 1.2.3、v1.2.3 的版本号;返回值大于 0 表示 a 更新。
+func CompareVersions(a, b string) int {
+	left := parseVersion(a)
+	right := parseVersion(b)
+	length := len(left)
+	if len(right) > length {
+		length = len(right)
+	}
+	for index := 0; index < length; index++ {
+		var lv, rv int
+		if index < len(left) {
+			lv = left[index]
+		}
+		if index < len(right) {
+			rv = right[index]
+		}
+		if lv != rv {
+			if lv > rv {
+				return 1
+			}
+			return -1
+		}
+	}
+	return 0
+}
+
+func parseVersion(value string) []int {
+	clean := strings.TrimSpace(strings.TrimPrefix(value, "v"))
+	if index := strings.IndexAny(clean, "-+"); index >= 0 {
+		clean = clean[:index]
+	}
+	parts := strings.Split(clean, ".")
+	out := make([]int, 0, len(parts))
+	for _, part := range parts {
+		number, err := strconv.Atoi(part)
+		if err != nil {
+			out = append(out, 0)
+			continue
+		}
+		out = append(out, number)
+	}
+	return out
+}
+
+func validateSemver(value string) error {
+	if value == "" {
+		return fmt.Errorf("版本号为空")
+	}
+	parts := strings.Split(strings.TrimPrefix(value, "v"), ".")
+	if len(parts) != 3 {
+		return fmt.Errorf("版本号格式无效: %s", value)
+	}
+	for _, part := range parts {
+		if _, err := strconv.Atoi(part); err != nil {
+			return fmt.Errorf("版本号格式无效: %s", value)
+		}
+	}
+	return nil
+}
diff --git a/app-src/internal/updater/model_test.go b/app-src/internal/updater/model_test.go
new file mode 100644
index 0000000..5b5588b
--- /dev/null
+++ b/app-src/internal/updater/model_test.go
@@ -0,0 +1,33 @@
+package updater
+
+import "testing"
+
+func TestCompareVersions(t *testing.T) {
+	cases := []struct {
+		left  string
+		right string
+		want  int
+	}{
+		{"1.2.20", "1.2.19", 1},
+		{"v1.2.19", "1.2.20", -1},
+		{"1.3.0", "1.3.0", 0},
+		{"1.2.21-beta.1", "1.2.21", 0},
+	}
+	for _, tc := range cases {
+		got := CompareVersions(tc.left, tc.right)
+		if got != tc.want {
+			t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tc.left, tc.right, got, tc.want)
+		}
+	}
+}
+
+func TestValidateSemver(t *testing.T) {
+	if err := validateSemver("1.2.21"); err != nil {
+		t.Fatalf("合法版本被拒绝: %v", err)
+	}
+	for _, value := range []string{"", "1.2", "main", "1.2.x"} {
+		if err := validateSemver(value); err == nil {
+			t.Fatalf("非法版本未被拒绝: %q", value)
+		}
+	}
+}
diff --git a/app-src/internal/updater/remote_version_test.go b/app-src/internal/updater/remote_version_test.go
new file mode 100644
index 0000000..69ba872
--- /dev/null
+++ b/app-src/internal/updater/remote_version_test.go
@@ -0,0 +1,59 @@
+package updater
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func TestRemoteVersionURLs(t *testing.T) {
+	urls, err := remoteVersionURLs("http://example.com/mrlee/Havline.git", "main")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(urls) == 0 || !strings.Contains(urls[0], "/api/v1/repos/mrlee/Havline/contents/VERSION") {
+		t.Fatalf("缺少 Gitea API 候选地址: %#v", urls)
+	}
+	if !strings.Contains(urls[1], "/mrlee/Havline/raw/branch/main/VERSION") {
+		t.Fatalf("缺少 Gitea raw 候选地址: %#v", urls)
+	}
+}
+
+func TestParseRemoteVersion(t *testing.T) {
+	if got, err := parseRemoteVersion([]byte("1.3.2\n")); err != nil || got != "1.3.2" {
+		t.Fatalf("文本版本解析失败: got=%q err=%v", got, err)
+	}
+	payload, _ := json.Marshal(map[string]string{
+		"encoding": "base64",
+		"content":  base64.StdEncoding.EncodeToString([]byte("1.3.2\n")),
+	})
+	if got, err := parseRemoteVersion(payload); err != nil || got != "1.3.2" {
+		t.Fatalf("JSON 版本解析失败: got=%q err=%v", got, err)
+	}
+}
+
+func TestFetchRemoteVersionFromGiteaAPI(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/api/v1/repos/mrlee/Havline/contents/VERSION" {
+			http.NotFound(w, r)
+			return
+		}
+		_ = json.NewEncoder(w).Encode(map[string]string{
+			"encoding": "base64",
+			"content":  base64.StdEncoding.EncodeToString([]byte("1.3.2\n")),
+		})
+	}))
+	defer server.Close()
+
+	got, err := fetchRemoteVersion(context.Background(), server.URL+"/mrlee/Havline.git", "main")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got != "1.3.2" {
+		t.Fatalf("got %q, want 1.3.2", got)
+	}
+}
diff --git a/app-src/internal/updater/runner.go b/app-src/internal/updater/runner.go
new file mode 100644
index 0000000..edc0397
--- /dev/null
+++ b/app-src/internal/updater/runner.go
@@ -0,0 +1,383 @@
+package updater
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+const maxCommandOutput = 64 << 10
+
+func (s *Server) runUpdate(ctx context.Context) error {
+	s.mu.Lock()
+	s.state.Phase = PhaseRunning
+	s.state.Message = "正在准备升级"
+	s.mu.Unlock()
+	if err := s.ensureComposeFile(ctx); err != nil {
+		return err
+	}
+
+	latest, err := s.latestVersion(ctx)
+	if err != nil {
+		return err
+	}
+	if err := validateSemver(latest); err != nil {
+		return err
+	}
+	current, _ := readCurrentVersion(ctx, s.cfg.AppURL)
+	if current != "" && CompareVersions(latest, current) <= 0 {
+		return fmt.Errorf("当前已是最新版本 %s", current)
+	}
+	previousImage, _ := s.currentImage()
+	s.logf("当前容器镜像:%s", previousImage)
+	s.logf("目标版本:%s", latest)
+
+	if s.cfg.Mode == "local" {
+		if err := s.prepareLocalSource(ctx); err != nil {
+			return err
+		}
+	}
+	if err := s.build(ctx, latest); err != nil {
+		return err
+	}
+	if err := s.recreate(ctx, latest); err != nil {
+		return err
+	}
+	if err := s.waitHealthy(ctx, latest); err != nil {
+		s.rollback(previousImage)
+		return fmt.Errorf("升级后健康检查失败:%w", err)
+	}
+	return nil
+}
+
+func (s *Server) build(ctx context.Context, version string) error {
+	s.logf("开始构建 havline:%s,这一步可能持续几分钟", version)
+	buildCtx, cancel := context.WithTimeout(ctx, s.cfg.BuildTimeout)
+	defer cancel()
+	if _, err := s.compose(buildCtx, version, "build", s.cfg.Service); err != nil {
+		return fmt.Errorf("docker compose build 失败:%w", err)
+	}
+	return nil
+}
+
+func (s *Server) recreate(ctx context.Context, version string) error {
+	s.logf("重建容器 %s", s.cfg.Service)
+	upCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+	defer cancel()
+	if _, err := s.compose(upCtx, version, "up", "-d", "--no-deps", "--force-recreate", s.cfg.Service); err != nil {
+		return fmt.Errorf("docker compose up 失败:%w", err)
+	}
+	return nil
+}
+
+func (s *Server) waitHealthy(ctx context.Context, expected string) error {
+	s.logf("等待服务恢复并返回版本 %s", expected)
+	deadline := time.Now().Add(s.cfg.HealthTimeout)
+	for time.Now().Before(deadline) {
+		checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+		version, err := readCurrentVersion(checkCtx, s.cfg.AppURL)
+		cancel()
+		if err == nil && version == expected {
+			s.logf("健康检查通过:%s", version)
+			return nil
+		}
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(3 * time.Second):
+		}
+	}
+	return fmt.Errorf("等待 %s 超时", expected)
+}
+
+func (s *Server) rollback(previousImage string) {
+	version := imageTag(previousImage)
+	if version == "" {
+		s.logf("无法确定上一个镜像 tag,跳过自动回滚")
+		return
+	}
+	s.logf("发现升级失败,尝试回滚到 havline:%s", version)
+	rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+	defer cancel()
+	if _, err := s.compose(rollbackCtx, version, "up", "-d", "--no-deps", "--force-recreate", s.cfg.Service); err != nil {
+		s.logf("自动回滚失败:%v", err)
+		return
+	}
+	s.logf("已切回 havline:%s", version)
+}
+
+func (s *Server) prepareLocalSource(ctx context.Context) error {
+	if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil {
+		s.logf("项目目录不是 Git 仓库,跳过源码拉取")
+		return nil
+	}
+	status, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "status", "--porcelain")
+	if err != nil {
+		return fmt.Errorf("检查 Git 工作区失败:%w", err)
+	}
+	if strings.TrimSpace(status) != "" {
+		return fmt.Errorf("本地源码存在未提交改动,已拒绝自动拉取")
+	}
+	s.logf("拉取远端源码")
+	if _, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "pull", "--ff-only", "origin", s.cfg.Branch); err != nil {
+		return fmt.Errorf("git pull 失败:%w", err)
+	}
+	return nil
+}
+
+func (s *Server) latestVersion(ctx context.Context) (string, error) {
+	repoURL := strings.TrimSpace(s.cfg.RepoURL)
+	if s.cfg.Mode == "local" {
+		if remote, err := s.localRemoteURL(ctx); err == nil && strings.TrimSpace(remote) != "" {
+			repoURL = remote
+		}
+	}
+	localPath := filepath.Join(s.cfg.ProjectDir, "VERSION")
+	if repoURL != "" {
+		version, err := fetchRemoteVersion(ctx, repoURL, s.cfg.Branch)
+		if err == nil {
+			return version, nil
+		}
+		if s.cfg.Mode == "git" {
+			return "", err
+		}
+		s.logf("读取远端版本失败,改用本地 VERSION:%v", err)
+	}
+	return readVersionFile(localPath)
+}
+
+func (s *Server) localRemoteURL(ctx context.Context) (string, error) {
+	if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil {
+		return "", err
+	}
+	checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+	defer cancel()
+	output, err := runCommand(checkCtx, s.cfg.ProjectDir, nil, "git", "config", "--get", "remote.origin.url")
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(output), nil
+}
+
+// fetchRemoteVersion 通过仓库 raw / API 地址读取 VERSION,避免为了检查版本克隆整个仓库。
+func fetchRemoteVersion(ctx context.Context, repoURL, branch string) (string, error) {
+	urls, err := remoteVersionURLs(repoURL, branch)
+	if err != nil {
+		return "", err
+	}
+	var lastErr error
+	for _, candidate := range urls {
+		version, err := fetchVersionURL(ctx, candidate)
+		if err == nil {
+			return version, nil
+		}
+		lastErr = err
+	}
+	return "", fmt.Errorf("读取远端版本失败:%w", lastErr)
+}
+
+func remoteVersionURLs(repoURL, branch string) ([]string, error) {
+	parsed, err := url.Parse(strings.TrimSpace(repoURL))
+	if err != nil || parsed.Host == "" {
+		return nil, fmt.Errorf("仓库地址无效:%s", repoURL)
+	}
+	branch = strings.TrimSpace(branch)
+	if branch == "" {
+		branch = "main"
+	}
+	repoPath := strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/")
+	if repoPath == "" {
+		return nil, fmt.Errorf("仓库地址缺少路径:%s", repoURL)
+	}
+	base := parsed.Scheme + "://" + parsed.Host
+	urls := []string{
+		fmt.Sprintf("%s/api/v1/repos/%s/contents/VERSION?ref=%s", base, repoPath, url.QueryEscape(branch)),
+		fmt.Sprintf("%s/%s/raw/branch/%s/VERSION", base, repoPath, url.PathEscape(branch)),
+		fmt.Sprintf("%s/%s/raw/%s/VERSION", base, repoPath, url.PathEscape(branch)),
+	}
+	if strings.EqualFold(parsed.Hostname(), "github.com") {
+		urls = append([]string{fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/VERSION", repoPath, url.PathEscape(branch))}, urls...)
+	}
+	return urls, nil
+}
+
+func fetchVersionURL(ctx context.Context, rawURL string) (string, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+	if err != nil {
+		return "", err
+	}
+	client := &http.Client{Timeout: 10 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return "", fmt.Errorf("%s: HTTP %d", rawURL, resp.StatusCode)
+	}
+	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+	if err != nil {
+		return "", err
+	}
+	version, err := parseRemoteVersion(data)
+	if err != nil {
+		return "", fmt.Errorf("%s: %w", rawURL, err)
+	}
+	return version, nil
+}
+
+func parseRemoteVersion(data []byte) (string, error) {
+	text := strings.TrimSpace(string(data))
+	if strings.HasPrefix(text, "{") {
+		var payload struct {
+			Content     string `json:"content"`
+			Encoding    string `json:"encoding"`
+			DownloadURL string `json:"download_url"`
+		}
+		if err := json.Unmarshal(data, &payload); err != nil {
+			return "", err
+		}
+		if payload.Encoding == "base64" && payload.Content != "" {
+			decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(payload.Content))
+			if err != nil {
+				return "", err
+			}
+			text = strings.TrimSpace(string(decoded))
+		}
+	}
+	if err := validateSemver(text); err != nil {
+		return "", err
+	}
+	return text, nil
+}
+
+func readVersionFile(path string) (string, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return "", err
+	}
+	version := strings.TrimSpace(string(data))
+	if err := validateSemver(version); err != nil {
+		return "", err
+	}
+	return version, nil
+}
+
+func readCurrentVersion(ctx context.Context, appURL string) (string, error) {
+	url := strings.TrimRight(appURL, "/") + "/api/version"
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return "", err
+	}
+	resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return "", fmt.Errorf("HTTP %d", resp.StatusCode)
+	}
+	var payload struct {
+		Version string `json:"version"`
+	}
+	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(payload.Version), nil
+}
+
+func (s *Server) currentImage() (string, error) {
+	output, err := runCommand(context.Background(), "", nil, "docker", "inspect", "--format", "{{.Config.Image}}", s.cfg.Service)
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(output), nil
+}
+
+func imageTag(image string) string {
+	image = strings.TrimSpace(image)
+	if image == "" {
+		return ""
+	}
+	parts := strings.Split(image, ":")
+	if len(parts) < 2 {
+		return ""
+	}
+	return parts[len(parts)-1]
+}
+
+func (s *Server) compose(ctx context.Context, version string, args ...string) (string, error) {
+	plan, err := s.resolveComposePlan(ctx)
+	if err != nil {
+		return "", err
+	}
+	if plan.helper {
+		return s.composeViaHelper(ctx, plan, version, args...)
+	}
+	fullArgs := append([]string{"compose", "--project-name", plan.projectName, "-f", plan.containerCompose}, args...)
+	env := []string{"HAVLINE_VERSION=" + version}
+	return runCommand(ctx, s.cfg.ProjectDir, env, "docker", fullArgs...)
+}
+
+func (s *Server) composeViaHelper(ctx context.Context, plan composePlan, version string, args ...string) (string, error) {
+	image, err := s.selfImage(ctx)
+	if err != nil {
+		return "", err
+	}
+	helperArgs := []string{
+		"run", "--rm", "--entrypoint", "docker",
+		"-v", s.cfg.DockerSocket + ":/var/run/docker.sock",
+		"-v", plan.hostProjectDir + ":/workspace",
+		"-w", "/workspace",
+		"-e", "HAVLINE_VERSION=" + version,
+		image,
+		"compose", "--project-name", plan.projectName, "-f", plan.helperComposePath,
+	}
+	helperArgs = append(helperArgs, args...)
+	return runCommand(ctx, "", nil, "docker", helperArgs...)
+}
+
+func (s *Server) selfImage(ctx context.Context) (string, error) {
+	if s.cfg.SelfImage != "" {
+		return s.cfg.SelfImage, nil
+	}
+	hostname, err := os.Hostname()
+	if err != nil {
+		return "", err
+	}
+	output, err := runCommand(ctx, "", nil, "docker", "inspect", "--format", "{{.Config.Image}}", hostname)
+	if err != nil {
+		return "", fmt.Errorf("无法识别 updater 镜像:%w", err)
+	}
+	image := strings.TrimSpace(output)
+	if image == "" {
+		return "", fmt.Errorf("无法识别 updater 镜像")
+	}
+	return image, nil
+}
+
+func runCommand(ctx context.Context, dir string, extraEnv []string, name string, args ...string) (string, error) {
+	command := exec.CommandContext(ctx, name, args...)
+	command.Dir = dir
+	if len(extraEnv) > 0 {
+		command.Env = append(os.Environ(), extraEnv...)
+	}
+	output, err := command.CombinedOutput()
+	text := string(output)
+	if len(text) > maxCommandOutput {
+		text = text[len(text)-maxCommandOutput:]
+	}
+	if err != nil {
+		return text, fmt.Errorf("%s: %w: %s", strings.Join(append([]string{name}, args...), " "), err, strings.TrimSpace(text))
+	}
+	return text, nil
+}
diff --git a/app-src/internal/updater/server.go b/app-src/internal/updater/server.go
new file mode 100644
index 0000000..2814475
--- /dev/null
+++ b/app-src/internal/updater/server.go
@@ -0,0 +1,439 @@
+package updater
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+)
+
+var ErrBusy = errors.New("升级任务正在进行中")
+
+var serviceNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
+
+// Config 是 havline-updater sidecar 的运行配置。
+type Config struct {
+	SocketPath     string
+	ProjectDir     string
+	ComposeFile    string
+	Service        string
+	Mode           string
+	RepoURL        string
+	Branch         string
+	AppURL         string
+	HostProjectDir string
+	ProjectName    string
+	SelfImage      string
+	DockerSocket   string
+	BuildTimeout   time.Duration
+	HealthTimeout  time.Duration
+}
+
+type Server struct {
+	cfg         Config
+	logger      *slog.Logger
+	composePath string
+
+	mu          sync.Mutex
+	state       Status
+	lastChecked time.Time
+}
+
+type composePlan struct {
+	helper            bool
+	projectName       string
+	containerCompose  string
+	hostProjectDir    string
+	hostComposePath   string
+	helperComposePath string
+}
+
+// NewServer 校验 sidecar 配置;参数不正确时拒绝启动。
+func NewServer(cfg Config, logger *slog.Logger) (*Server, error) {
+	cfg.SocketPath = strings.TrimSpace(cfg.SocketPath)
+	if cfg.SocketPath == "" {
+		cfg.SocketPath = "/run/havline-updater/updater.sock"
+	}
+	if strings.TrimSpace(cfg.ProjectDir) == "" {
+		cfg.ProjectDir = "/workspace"
+	}
+	if strings.TrimSpace(cfg.ComposeFile) == "" {
+		cfg.ComposeFile = "docker-compose.yml"
+	}
+	if strings.TrimSpace(cfg.Service) == "" {
+		cfg.Service = "havline"
+	}
+	if strings.TrimSpace(cfg.Mode) == "" {
+		cfg.Mode = "local"
+	}
+	if strings.TrimSpace(cfg.Branch) == "" {
+		cfg.Branch = "main"
+	}
+	if strings.TrimSpace(cfg.AppURL) == "" {
+		cfg.AppURL = "http://havline:6893"
+	}
+	if strings.TrimSpace(cfg.DockerSocket) == "" {
+		cfg.DockerSocket = "/var/run/docker.sock"
+	}
+	cfg.HostProjectDir = strings.TrimSpace(cfg.HostProjectDir)
+	cfg.ProjectName = strings.TrimSpace(cfg.ProjectName)
+	cfg.SelfImage = strings.TrimSpace(cfg.SelfImage)
+	if cfg.BuildTimeout <= 0 {
+		cfg.BuildTimeout = 30 * time.Minute
+	}
+	if cfg.HealthTimeout <= 0 {
+		cfg.HealthTimeout = 3 * time.Minute
+	}
+	if cfg.Mode != "local" && cfg.Mode != "git" {
+		return nil, fmt.Errorf("HAVLINE_UPDATE_MODE 只支持 local 或 git")
+	}
+	if !serviceNamePattern.MatchString(cfg.Service) {
+		return nil, fmt.Errorf("HAVLINE_UPDATE_SERVICE 含非法字符")
+	}
+	composePath, err := resolveComposePath(cfg.ProjectDir, cfg.ComposeFile)
+	if err != nil {
+		return nil, err
+	}
+	if cfg.Mode == "git" && strings.TrimSpace(cfg.RepoURL) == "" {
+		return nil, fmt.Errorf("git 模式必须设置 HAVLINE_UPDATE_REPO")
+	}
+	if logger == nil {
+		logger = slog.Default()
+	}
+	return &Server{
+		cfg:         cfg,
+		logger:      logger,
+		composePath: composePath,
+		state: Status{
+			Enabled: true,
+			Mode:    cfg.Mode,
+			Phase:   PhaseIdle,
+			Message: "尚未检查更新",
+		},
+	}, nil
+}
+
+func (s *Server) Handler() http.Handler {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /status", s.handleStatus)
+	mux.HandleFunc("POST /apply", s.handleApply)
+	return mux
+}
+
+// Serve 在共享 Unix Socket 上提供服务。
+func (s *Server) Serve(ctx context.Context) error {
+	if err := os.MkdirAll(filepath.Dir(s.cfg.SocketPath), 0o750); err != nil {
+		return err
+	}
+	_ = os.Remove(s.cfg.SocketPath)
+	listener, err := net.Listen("unix", s.cfg.SocketPath)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		_ = listener.Close()
+		_ = os.Remove(s.cfg.SocketPath)
+	}()
+	_ = os.Chmod(s.cfg.SocketPath, 0o660)
+
+	server := &http.Server{
+		Handler:           s.Handler(),
+		ReadHeaderTimeout: 5 * time.Second,
+		ReadTimeout:       30 * time.Second,
+		WriteTimeout:      30 * time.Second,
+	}
+	go func() {
+		<-ctx.Done()
+		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		_ = server.Shutdown(shutdownCtx)
+	}()
+	err = server.Serve(listener)
+	if errors.Is(err, http.ErrServerClosed) {
+		return nil
+	}
+	return err
+}
+
+func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
+	writeJSON(w, http.StatusOK, s.Status(r.Context()))
+}
+
+func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
+	status, err := s.Apply(r.Context())
+	if errors.Is(err, ErrBusy) {
+		writeJSON(w, http.StatusConflict, status)
+		return
+	}
+	if err != nil {
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+		return
+	}
+	writeJSON(w, http.StatusAccepted, status)
+}
+
+func (s *Server) ensureComposeFile(ctx context.Context) error {
+	_, err := s.resolveComposePlan(ctx)
+	return err
+}
+
+func (s *Server) resolveComposePlan(ctx context.Context) (composePlan, error) {
+	projectName, err := s.composeProjectName(ctx)
+	if err != nil {
+		return composePlan{}, err
+	}
+	info, err := os.Stat(s.composePath)
+	if err == nil && !info.IsDir() {
+		return composePlan{projectName: projectName, containerCompose: s.composePath}, nil
+	}
+	if err == nil && info.IsDir() {
+		return composePlan{}, fmt.Errorf("Compose 路径是目录而不是文件:%s", s.composePath)
+	}
+	hostDir, hostComposePath, discoverErr := s.discoverHostCompose(ctx)
+	if discoverErr == nil {
+		helperPath, pathErr := helperComposePath(hostDir, hostComposePath)
+		if pathErr == nil {
+			return composePlan{
+				helper:            true,
+				projectName:       projectName,
+				hostProjectDir:    hostDir,
+				hostComposePath:   hostComposePath,
+				helperComposePath: helperPath,
+			}, nil
+		}
+	}
+	return composePlan{}, fmt.Errorf("找不到 Compose 文件 %s,且无法自动定位宿主机目录;请设置 HAVLINE_UPDATE_HOST_DIR", s.composePath)
+}
+
+func (s *Server) composeProjectName(ctx context.Context) (string, error) {
+	projectName, err := s.dockerLabel(ctx, "com.docker.compose.project")
+	projectName = strings.TrimSpace(projectName)
+	if projectName != "" {
+		return projectName, nil
+	}
+	if s.cfg.ProjectName != "" {
+		return s.cfg.ProjectName, nil
+	}
+	if err != nil {
+		return "", fmt.Errorf("无法读取现有容器的 Compose 项目标签:%w", err)
+	}
+	return "", fmt.Errorf("现有 havline 容器不是由 Compose 管理,无法安全一键升级;请按 Compose 方式重新部署")
+}
+
+func (s *Server) discoverHostCompose(ctx context.Context) (string, string, error) {
+	if s.cfg.HostProjectDir != "" && !isContainerPath(s.cfg.HostProjectDir, s.cfg.ProjectDir) {
+		if !filepath.IsAbs(s.cfg.HostProjectDir) {
+			return "", "", fmt.Errorf("HAVLINE_UPDATE_HOST_DIR 必须是绝对路径")
+		}
+		return s.cfg.HostProjectDir, filepath.Join(s.cfg.HostProjectDir, filepath.Base(s.cfg.ComposeFile)), nil
+	}
+	workingDir, _ := s.dockerLabel(ctx, "com.docker.compose.project.working_dir")
+	configFiles, _ := s.dockerLabel(ctx, "com.docker.compose.project.config_files")
+	hostDir := strings.TrimSpace(workingDir)
+	hostCompose := chooseComposeFile(configFiles, filepath.Base(s.cfg.ComposeFile))
+	if hostCompose == "" {
+		if hostDir == "" || isContainerPath(hostDir, s.cfg.ProjectDir) {
+			return "", "", fmt.Errorf("无法从容器标签读取宿主机 Compose 目录")
+		}
+		hostCompose = filepath.Join(hostDir, filepath.Base(s.cfg.ComposeFile))
+	}
+	if hostDir == "" || isContainerPath(hostDir, s.cfg.ProjectDir) {
+		hostDir = filepath.Dir(hostCompose)
+	}
+	if !filepath.IsAbs(hostDir) || !filepath.IsAbs(hostCompose) {
+		return "", "", fmt.Errorf("容器标签中的 Compose 路径不是绝对路径")
+	}
+	if isContainerPath(hostDir, s.cfg.ProjectDir) {
+		return "", "", fmt.Errorf("容器标签只提供了容器内路径 %s,无法定位宿主机 Compose 目录", hostDir)
+	}
+	return filepath.Clean(hostDir), filepath.Clean(hostCompose), nil
+}
+
+func isContainerPath(path, containerDir string) bool {
+	path = filepath.Clean(strings.TrimSpace(path))
+	containerDir = filepath.Clean(strings.TrimSpace(containerDir))
+	if path == "" || containerDir == "" || path == "." {
+		return false
+	}
+	return path == containerDir || strings.HasPrefix(path, containerDir+string(filepath.Separator))
+}
+
+func (s *Server) dockerLabel(ctx context.Context, label string) (string, error) {
+	format := fmt.Sprintf(`{{ index .Config.Labels %q }}`, label)
+	checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+	defer cancel()
+	output, err := runCommand(checkCtx, "", nil, "docker", "inspect", "--format", format, s.cfg.Service)
+	if err != nil {
+		return "", err
+	}
+	return strings.TrimSpace(output), nil
+}
+
+func chooseComposeFile(configFiles, expectedBase string) string {
+	for _, item := range strings.Split(configFiles, ",") {
+		path := strings.TrimSpace(item)
+		if path == "" {
+			continue
+		}
+		if filepath.Base(path) == expectedBase {
+			return path
+		}
+	}
+	for _, item := range strings.Split(configFiles, ",") {
+		if path := strings.TrimSpace(item); path != "" {
+			return path
+		}
+	}
+	return ""
+}
+
+func helperComposePath(hostDir, hostCompose string) (string, error) {
+	rel, err := filepath.Rel(hostDir, hostCompose)
+	if err != nil {
+		return "", err
+	}
+	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+		return "", fmt.Errorf("Compose 文件不在宿主机项目目录内")
+	}
+	return filepath.Join("/workspace", rel), nil
+}
+
+// Status 返回当前版本、远端版本和任务状态。
+func (s *Server) Status(ctx context.Context) Status {
+	s.mu.Lock()
+	if busyPhase(s.state.Phase) {
+		status := cloneStatus(s.state)
+		s.mu.Unlock()
+		return status
+	}
+	s.mu.Unlock()
+	if err := s.ensureComposeFile(ctx); err != nil {
+		s.mu.Lock()
+		defer s.mu.Unlock()
+		s.state.UpdateAvailable = false
+		s.state.Message = err.Error()
+		s.state.CheckedAt = time.Now().UTC().Format(time.RFC3339)
+		return cloneStatus(s.state)
+	}
+	s.mu.Lock()
+	if !s.lastChecked.IsZero() && time.Since(s.lastChecked) < time.Minute {
+		status := cloneStatus(s.state)
+		s.mu.Unlock()
+		return status
+	}
+	s.mu.Unlock()
+
+	checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
+	defer cancel()
+	current, currentErr := readCurrentVersion(checkCtx, s.cfg.AppURL)
+	latest, latestErr := s.latestVersion(checkCtx)
+
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.lastChecked = time.Now()
+	s.state.CurrentVersion = current
+	s.state.LatestVersion = latest
+	s.state.UpdateAvailable = latest != "" && current != "" && CompareVersions(latest, current) > 0
+	s.state.CheckedAt = time.Now().UTC().Format(time.RFC3339)
+	switch {
+	case currentErr != nil:
+		s.state.Message = "无法读取当前运行版本:" + currentErr.Error()
+	case latestErr != nil:
+		s.state.Message = "无法读取远端最新版本:" + latestErr.Error()
+	case s.state.UpdateAvailable:
+		s.state.Message = fmt.Sprintf("发现新版本 %s,当前 %s", latest, current)
+	default:
+		s.state.Message = "当前已是最新版本"
+	}
+	return cloneStatus(s.state)
+}
+
+// Apply 立即返回排队状态,后台完成构建、重建、健康检查和必要回滚。
+func (s *Server) Apply(_ context.Context) (Status, error) {
+	s.mu.Lock()
+	if busyPhase(s.state.Phase) {
+		status := cloneStatus(s.state)
+		s.mu.Unlock()
+		return status, ErrBusy
+	}
+	s.state.Phase = PhaseScheduled
+	s.state.Busy = true
+	s.state.Message = "升级任务已排队,即将重启 Havline"
+	s.state.Log = []string{"升级任务已排队"}
+	status := cloneStatus(s.state)
+	s.mu.Unlock()
+	go s.runScheduled()
+	return status, nil
+}
+
+func (s *Server) runScheduled() {
+	time.Sleep(2 * time.Second)
+	if err := s.runUpdate(context.Background()); err != nil {
+		s.logf("升级失败:%v", err)
+		s.finish(PhaseFailed, err.Error())
+		return
+	}
+	s.finish(PhaseSuccess, "升级完成")
+}
+
+func (s *Server) finish(phase Phase, message string) {
+	s.mu.Lock()
+	s.state.Phase = phase
+	s.state.Busy = false
+	s.state.Message = message
+	s.lastChecked = time.Time{}
+	s.mu.Unlock()
+}
+
+func (s *Server) logf(format string, args ...any) {
+	line := fmt.Sprintf(format, args...)
+	s.logger.Info(line, "module", "UPDATER")
+	s.mu.Lock()
+	s.state.Log = append(s.state.Log, line)
+	if len(s.state.Log) > 200 {
+		s.state.Log = s.state.Log[len(s.state.Log)-200:]
+	}
+	s.mu.Unlock()
+}
+
+func cloneStatus(status Status) Status {
+	status.Log = append([]string(nil), status.Log...)
+	return status
+}
+
+func resolveComposePath(projectDir, composeFile string) (string, error) {
+	dir, err := filepath.Abs(strings.TrimSpace(projectDir))
+	if err != nil {
+		return "", err
+	}
+	path := strings.TrimSpace(composeFile)
+	if !filepath.IsAbs(path) {
+		path = filepath.Join(dir, path)
+	}
+	path = filepath.Clean(path)
+	rel, err := filepath.Rel(dir, path)
+	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+		return "", fmt.Errorf("compose 文件必须位于项目目录内")
+	}
+	switch filepath.Base(path) {
+	case "docker-compose.yml", "docker-compose.hub.yml", "docker-compose.host.yml":
+		return path, nil
+	default:
+		return "", fmt.Errorf("不支持的 compose 文件:%s", filepath.Base(path))
+	}
+}
+
+func writeJSON(w http.ResponseWriter, status int, value any) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(value)
+}
diff --git a/app-src/internal/updater/server_test.go b/app-src/internal/updater/server_test.go
new file mode 100644
index 0000000..bd8ce58
--- /dev/null
+++ b/app-src/internal/updater/server_test.go
@@ -0,0 +1,104 @@
+package updater
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestResolveComposePath(t *testing.T) {
+	dir := t.TempDir()
+	got, err := resolveComposePath(dir, "docker-compose.hub.yml")
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := filepath.Join(dir, "docker-compose.hub.yml")
+	if got != want {
+		t.Fatalf("got %q, want %q", got, want)
+	}
+	if _, err := resolveComposePath(dir, "../docker-compose.yml"); err == nil {
+		t.Fatal("路径上跳应被拒绝")
+	}
+	if _, err := resolveComposePath(dir, "compose.yml"); err == nil {
+		t.Fatal("不在白名单中的 compose 文件应被拒绝")
+	}
+}
+
+func TestNewServerDefaultsAndValidation(t *testing.T) {
+	server, err := NewServer(Config{
+		ProjectDir:  t.TempDir(),
+		ComposeFile: "docker-compose.yml",
+	}, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !server.state.Enabled || server.cfg.Mode != "local" {
+		t.Fatalf("默认状态不正确: %#v", server.state)
+	}
+	if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "invalid"}, nil); err == nil {
+		t.Fatal("非法 mode 应被拒绝")
+	}
+	if _, err := NewServer(Config{ProjectDir: t.TempDir(), Mode: "git"}, nil); err == nil {
+		t.Fatal("git 模式缺少 RepoURL 应被拒绝")
+	}
+}
+
+func TestEnsureComposeFile(t *testing.T) {
+	dir := t.TempDir()
+	server, err := NewServer(Config{ProjectDir: dir, ComposeFile: "docker-compose.yml", ProjectName: "havline"}, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := server.ensureComposeFile(context.Background()); err == nil {
+		t.Fatal("缺少 compose 文件时应返回错误")
+	}
+	path := filepath.Join(dir, "docker-compose.yml")
+	if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
+		t.Fatal(err)
+	}
+	if err := server.ensureComposeFile(context.Background()); err != nil {
+		t.Fatalf("存在 compose 文件时不应报错: %v", err)
+	}
+}
+
+func TestResolveComposePlanUsesHostProjectDir(t *testing.T) {
+	containerDir := t.TempDir()
+	hostDir := t.TempDir()
+	server, err := NewServer(Config{
+		ProjectDir:     containerDir,
+		ComposeFile:    "docker-compose.hub.yml",
+		HostProjectDir: hostDir,
+		ProjectName:    "havline",
+	}, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	plan, err := server.resolveComposePlan(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !plan.helper {
+		t.Fatal("容器内看不到 Compose 文件时应使用 helper 模式")
+	}
+	if plan.helperComposePath != filepath.Join("/workspace", "docker-compose.hub.yml") {
+		t.Fatalf("helper compose 路径不正确: %s", plan.helperComposePath)
+	}
+}
+
+func TestIsContainerPath(t *testing.T) {
+	cases := []struct {
+		path string
+		want bool
+	}{
+		{"/workspace", true},
+		{"/workspace/docker-compose.yml", true},
+		{"/vol1/1000/Docker/mafrp", false},
+		{"/vol1/1000/Docker/mafrp/docker-compose.yml", false},
+	}
+	for _, tc := range cases {
+		if got := isContainerPath(tc.path, "/workspace"); got != tc.want {
+			t.Fatalf("isContainerPath(%q) = %v, want %v", tc.path, got, tc.want)
+		}
+	}
+}
diff --git a/app-src/internal/validate/basicauth.go b/app-src/internal/validate/basicauth.go
new file mode 100644
index 0000000..3034b1c
--- /dev/null
+++ b/app-src/internal/validate/basicauth.go
@@ -0,0 +1,21 @@
+package validate
+
+import (
+	"fmt"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+func BasicAuthPasswordHash(username, password string) (string, error) {
+	if err := Username(username); err != nil {
+		return "", err
+	}
+	if err := Password(password); err != nil {
+		return "", err
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return "", fmt.Errorf("密码加密失败")
+	}
+	return string(hash), nil
+}
diff --git a/app-src/internal/validate/ip.go b/app-src/internal/validate/ip.go
new file mode 100644
index 0000000..715e9f9
--- /dev/null
+++ b/app-src/internal/validate/ip.go
@@ -0,0 +1,38 @@
+package validate
+
+import (
+	"fmt"
+	"net"
+	"strings"
+)
+
+func IPOrCIDR(raw string) (string, error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return "", fmt.Errorf("IP 或 CIDR 不能为空")
+	}
+	if strings.Contains(raw, "/") {
+		_, ipNet, err := net.ParseCIDR(raw)
+		if err != nil {
+			return "", fmt.Errorf("CIDR 格式无效")
+		}
+		return ipNet.String(), nil
+	}
+	ip := net.ParseIP(raw)
+	if ip == nil {
+		return "", fmt.Errorf("IP 格式无效")
+	}
+	if v4 := ip.To4(); v4 != nil {
+		return v4.String(), nil
+	}
+	return ip.String(), nil
+}
+
+func IPOrCIDRList(list []string) error {
+	for _, item := range list {
+		if _, err := IPOrCIDR(item); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/app-src/internal/validate/validate.go b/app-src/internal/validate/validate.go
new file mode 100644
index 0000000..6b690a3
--- /dev/null
+++ b/app-src/internal/validate/validate.go
@@ -0,0 +1,138 @@
+package validate
+
+import (
+	"fmt"
+	"net"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var domainRe = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$`)
+
+func CertDomain(domain string) error {
+	domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
+	if domain == "" {
+		return fmt.Errorf("域名不能为空")
+	}
+	if strings.HasPrefix(domain, "*.") {
+		return Domain(strings.TrimPrefix(domain, "*."))
+	}
+	return Domain(domain)
+}
+
+func Domain(domain string) error {
+	domain = strings.TrimSpace(domain)
+	if domain == "" {
+		return fmt.Errorf("域名不能为空")
+	}
+	if len(domain) > 253 {
+		return fmt.Errorf("域名过长")
+	}
+	if strings.Contains(domain, "..") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
+		return fmt.Errorf("域名格式无效")
+	}
+	if !domainRe.MatchString(domain) {
+		return fmt.Errorf("域名格式无效")
+	}
+	return nil
+}
+
+func ListenPort(port int) error {
+	if port < 1 || port > 65535 {
+		return fmt.Errorf("监听端口无效")
+	}
+	return nil
+}
+
+// FrontendAddress parses a frontend binding like "example.com" or "example.com:8011".
+// A zero port means the rule-level default should be used.
+func FrontendAddress(raw string) (hostname string, port int, err error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return "", 0, fmt.Errorf("前端地址不能为空")
+	}
+
+	host := raw
+	port = 0
+	if strings.Count(raw, ":") == 1 && !strings.Contains(raw, "]") {
+		parts := strings.SplitN(raw, ":", 2)
+		host = parts[0]
+		p, parseErr := strconv.Atoi(parts[1])
+		if parseErr != nil || p < 1 || p > 65535 {
+			return "", 0, fmt.Errorf("前端地址端口无效")
+		}
+		port = p
+	}
+
+	host = strings.ToLower(strings.TrimSpace(host))
+	if err := Domain(host); err != nil {
+		return "", 0, err
+	}
+	return host, port, nil
+}
+
+func Upstream(raw string) (string, error) {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return "", fmt.Errorf("目标地址不能为空")
+	}
+
+	u, err := url.Parse(raw)
+	if err != nil {
+		return "", fmt.Errorf("目标地址格式无效")
+	}
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return "", fmt.Errorf("目标地址仅支持 http:// 或 https://")
+	}
+	if u.User != nil {
+		return "", fmt.Errorf("目标地址不能包含用户名或密码")
+	}
+	if u.Host == "" {
+		return "", fmt.Errorf("目标地址缺少主机名")
+	}
+	if u.Path != "" && u.Path != "/" {
+		return "", fmt.Errorf("目标地址不能包含路径")
+	}
+	if u.RawQuery != "" || u.Fragment != "" {
+		return "", fmt.Errorf("目标地址不能包含查询参数或片段")
+	}
+
+	host := u.Hostname()
+	port := u.Port()
+	if host == "" {
+		return "", fmt.Errorf("目标地址主机名无效")
+	}
+	if ip := net.ParseIP(host); ip != nil {
+		if port == "" {
+			return "", fmt.Errorf("IP 地址目标必须指定端口")
+		}
+	} else if err := Domain(host); err != nil {
+		return "", fmt.Errorf("目标地址主机名无效")
+	}
+
+	normalized := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+	if !strings.HasPrefix(normalized, "http://") && !strings.HasPrefix(normalized, "https://") {
+		return "", fmt.Errorf("目标地址格式无效")
+	}
+	return normalized, nil
+}
+
+func Password(password string) error {
+	if len(password) < 8 {
+		return fmt.Errorf("密码长度至少 8 位")
+	}
+	return nil
+}
+
+func Username(username string) error {
+	username = strings.TrimSpace(username)
+	if username == "" {
+		return fmt.Errorf("用户名不能为空")
+	}
+	if len(username) > 64 {
+		return fmt.Errorf("用户名过长")
+	}
+	return nil
+}
diff --git a/app-src/internal/validate/validate_test.go b/app-src/internal/validate/validate_test.go
new file mode 100644
index 0000000..478ddae
--- /dev/null
+++ b/app-src/internal/validate/validate_test.go
@@ -0,0 +1,21 @@
+package validate
+
+import "testing"
+
+func TestFrontendAddress(t *testing.T) {
+	host, port, err := FrontendAddress("example.com:8011")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if host != "example.com" || port != 8011 {
+		t.Fatalf("got %q %d", host, port)
+	}
+
+	host, port, err = FrontendAddress("nas.example.com")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if host != "nas.example.com" || port != 0 {
+		t.Fatalf("got %q %d", host, port)
+	}
+}
diff --git a/app-src/internal/version/version.go b/app-src/internal/version/version.go
new file mode 100644
index 0000000..2076ecc
--- /dev/null
+++ b/app-src/internal/version/version.go
@@ -0,0 +1,4 @@
+package version
+
+// Version is set at build time via -ldflags.
+var Version = "dev"
diff --git a/app-src/migrations/001_init.sql b/app-src/migrations/001_init.sql
new file mode 100644
index 0000000..b366980
--- /dev/null
+++ b/app-src/migrations/001_init.sql
@@ -0,0 +1,36 @@
+CREATE TABLE IF NOT EXISTS schema_migrations (
+    version INTEGER PRIMARY KEY,
+    applied_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS admins (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    username TEXT NOT NULL UNIQUE,
+    password_hash TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+    id TEXT PRIMARY KEY,
+    admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
+    expires_at TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
+
+CREATE TABLE IF NOT EXISTS proxy_rules (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    domain TEXT NOT NULL UNIQUE COLLATE NOCASE,
+    upstream TEXT NOT NULL,
+    https_enabled INTEGER NOT NULL DEFAULT 1 CHECK (https_enabled IN (0, 1)),
+    http_redirect INTEGER NOT NULL DEFAULT 1 CHECK (http_redirect IN (0, 1)),
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+    key TEXT PRIMARY KEY,
+    value TEXT NOT NULL
+);
diff --git a/app-src/migrations/002_phase2.sql b/app-src/migrations/002_phase2.sql
new file mode 100644
index 0000000..28c14bb
--- /dev/null
+++ b/app-src/migrations/002_phase2.sql
@@ -0,0 +1,31 @@
+CREATE TABLE IF NOT EXISTS ddns_configs (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    provider TEXT NOT NULL DEFAULT 'cloudflare',
+    root_domain TEXT NOT NULL,
+    record_name TEXT NOT NULL,
+    ipv4_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ipv4_enabled IN (0, 1)),
+    ipv6_enabled INTEGER NOT NULL DEFAULT 0 CHECK (ipv6_enabled IN (0, 1)),
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    api_token_enc TEXT,
+    last_ipv4 TEXT,
+    last_ipv6 TEXT,
+    last_status TEXT NOT NULL DEFAULT 'unknown',
+    last_error TEXT,
+    last_updated_at TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS certificates (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    domain TEXT NOT NULL UNIQUE,
+    wildcard INTEGER NOT NULL DEFAULT 0 CHECK (wildcard IN (0, 1)),
+    cert_path TEXT,
+    key_path TEXT,
+    expires_at TEXT,
+    last_renew_at TEXT,
+    status TEXT NOT NULL DEFAULT 'pending',
+    last_error TEXT,
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/app-src/migrations/003_ddns_multi.sql b/app-src/migrations/003_ddns_multi.sql
new file mode 100644
index 0000000..678556b
--- /dev/null
+++ b/app-src/migrations/003_ddns_multi.sql
@@ -0,0 +1,2 @@
+CREATE UNIQUE INDEX IF NOT EXISTS idx_ddns_configs_domain_record
+ON ddns_configs(root_domain, record_name);
diff --git a/app-src/migrations/004_cert_acme_ca.sql b/app-src/migrations/004_cert_acme_ca.sql
new file mode 100644
index 0000000..9481a61
--- /dev/null
+++ b/app-src/migrations/004_cert_acme_ca.sql
@@ -0,0 +1 @@
+ALTER TABLE certificates ADD COLUMN acme_ca TEXT NOT NULL DEFAULT 'letsencrypt';
diff --git a/app-src/migrations/005_cert_domains.sql b/app-src/migrations/005_cert_domains.sql
new file mode 100644
index 0000000..aa121dd
--- /dev/null
+++ b/app-src/migrations/005_cert_domains.sql
@@ -0,0 +1 @@
+ALTER TABLE certificates ADD COLUMN domains TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/006_proxy_multi_host.sql b/app-src/migrations/006_proxy_multi_host.sql
new file mode 100644
index 0000000..ed9bcdc
--- /dev/null
+++ b/app-src/migrations/006_proxy_multi_host.sql
@@ -0,0 +1,50 @@
+CREATE TEMP TABLE proxy_domain_backup AS
+SELECT id, domain FROM proxy_rules;
+
+CREATE TABLE proxy_rules_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    upstream TEXT NOT NULL,
+    listen_port INTEGER NOT NULL DEFAULT 80 CHECK (listen_port > 0 AND listen_port <= 65535),
+    listen_ipv4 INTEGER NOT NULL DEFAULT 1 CHECK (listen_ipv4 IN (0, 1)),
+    listen_ipv6 INTEGER NOT NULL DEFAULT 0 CHECK (listen_ipv6 IN (0, 1)),
+    https_enabled INTEGER NOT NULL DEFAULT 1 CHECK (https_enabled IN (0, 1)),
+    http_redirect INTEGER NOT NULL DEFAULT 1 CHECK (http_redirect IN (0, 1)),
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO proxy_rules_new (id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, created_at, updated_at)
+SELECT
+    id,
+    upstream,
+    CASE WHEN https_enabled = 1 THEN 443 ELSE 80 END,
+    1,
+    0,
+    https_enabled,
+    http_redirect,
+    enabled,
+    created_at,
+    updated_at
+FROM proxy_rules;
+
+DROP TABLE proxy_rules;
+ALTER TABLE proxy_rules_new RENAME TO proxy_rules;
+
+CREATE TABLE IF NOT EXISTS proxy_hosts (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    rule_id INTEGER NOT NULL REFERENCES proxy_rules(id) ON DELETE CASCADE,
+    hostname TEXT NOT NULL COLLATE NOCASE,
+    listen_port INTEGER CHECK (listen_port IS NULL OR (listen_port > 0 AND listen_port <= 65535)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(hostname)
+);
+
+CREATE INDEX IF NOT EXISTS idx_proxy_hosts_rule_id ON proxy_hosts(rule_id);
+
+INSERT INTO proxy_hosts (rule_id, hostname, listen_port)
+SELECT id, domain, NULL
+FROM proxy_domain_backup
+WHERE domain IS NOT NULL AND TRIM(domain) != '';
+
+DROP TABLE proxy_domain_backup;
diff --git a/app-src/migrations/007_ddns_record_names.sql b/app-src/migrations/007_ddns_record_names.sql
new file mode 100644
index 0000000..1a43f7f
--- /dev/null
+++ b/app-src/migrations/007_ddns_record_names.sql
@@ -0,0 +1,5 @@
+ALTER TABLE ddns_configs ADD COLUMN record_names TEXT NOT NULL DEFAULT '[]';
+
+UPDATE ddns_configs
+SET record_names = json_array(record_name)
+WHERE record_names = '[]' OR record_names IS NULL OR record_names = '';
diff --git a/app-src/migrations/008_ddns_domain_records.sql b/app-src/migrations/008_ddns_domain_records.sql
new file mode 100644
index 0000000..1bcd97e
--- /dev/null
+++ b/app-src/migrations/008_ddns_domain_records.sql
@@ -0,0 +1 @@
+ALTER TABLE ddns_configs ADD COLUMN domain_records TEXT NOT NULL DEFAULT '[]';
diff --git a/app-src/migrations/009_proxy_traffic.sql b/app-src/migrations/009_proxy_traffic.sql
new file mode 100644
index 0000000..788cefe
--- /dev/null
+++ b/app-src/migrations/009_proxy_traffic.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS proxy_traffic (
+    host TEXT PRIMARY KEY,
+    upload_bytes INTEGER NOT NULL DEFAULT 0,
+    download_bytes INTEGER NOT NULL DEFAULT 0,
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/app-src/migrations/010_proxy_host_port_unique.sql b/app-src/migrations/010_proxy_host_port_unique.sql
new file mode 100644
index 0000000..ff880b7
--- /dev/null
+++ b/app-src/migrations/010_proxy_host_port_unique.sql
@@ -0,0 +1,22 @@
+UPDATE proxy_hosts
+SET listen_port = (
+    SELECT listen_port FROM proxy_rules WHERE proxy_rules.id = proxy_hosts.rule_id
+)
+WHERE listen_port IS NULL;
+
+CREATE TABLE proxy_hosts_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    rule_id INTEGER NOT NULL REFERENCES proxy_rules(id) ON DELETE CASCADE,
+    hostname TEXT NOT NULL COLLATE NOCASE,
+    listen_port INTEGER NOT NULL CHECK (listen_port > 0 AND listen_port <= 65535),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(hostname, listen_port)
+);
+
+INSERT INTO proxy_hosts_new (id, rule_id, hostname, listen_port, created_at)
+SELECT id, rule_id, hostname, listen_port, created_at FROM proxy_hosts;
+
+DROP TABLE proxy_hosts;
+ALTER TABLE proxy_hosts_new RENAME TO proxy_hosts;
+
+CREATE INDEX IF NOT EXISTS idx_proxy_hosts_rule_id ON proxy_hosts(rule_id);
diff --git a/app-src/migrations/011_proxy_remark.sql b/app-src/migrations/011_proxy_remark.sql
new file mode 100644
index 0000000..492c6fe
--- /dev/null
+++ b/app-src/migrations/011_proxy_remark.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN remark TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/012_proxy_name_sort.sql b/app-src/migrations/012_proxy_name_sort.sql
new file mode 100644
index 0000000..e233e59
--- /dev/null
+++ b/app-src/migrations/012_proxy_name_sort.sql
@@ -0,0 +1,7 @@
+ALTER TABLE proxy_rules RENAME COLUMN remark TO name;
+
+ALTER TABLE proxy_rules ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
+
+UPDATE proxy_rules SET sort_order = id;
+
+CREATE INDEX IF NOT EXISTS idx_proxy_rules_sort_order ON proxy_rules(sort_order);
diff --git a/app-src/migrations/013_proxy_security.sql b/app-src/migrations/013_proxy_security.sql
new file mode 100644
index 0000000..ab49f29
--- /dev/null
+++ b/app-src/migrations/013_proxy_security.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN security_json TEXT NOT NULL DEFAULT '{}';
diff --git a/app-src/migrations/014_nginx_custom.sql b/app-src/migrations/014_nginx_custom.sql
new file mode 100644
index 0000000..bc4ac52
--- /dev/null
+++ b/app-src/migrations/014_nginx_custom.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN nginx_mode TEXT NOT NULL DEFAULT 'auto';
diff --git a/app-src/migrations/015_frp.sql b/app-src/migrations/015_frp.sql
new file mode 100644
index 0000000..b832ceb
--- /dev/null
+++ b/app-src/migrations/015_frp.sql
@@ -0,0 +1,44 @@
+CREATE TABLE IF NOT EXISTS frp_servers (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL UNIQUE COLLATE NOCASE,
+    server_addr TEXT NOT NULL,
+    server_port INTEGER NOT NULL CHECK (server_port > 0 AND server_port <= 65535),
+    auth_token_enc TEXT,
+    tls_enabled INTEGER NOT NULL DEFAULT 1 CHECK (tls_enabled IN (0, 1)),
+    tls_server_name TEXT NOT NULL DEFAULT '',
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS frp_proxies (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+    name TEXT NOT NULL COLLATE NOCASE,
+    type TEXT NOT NULL CHECK (type IN ('tcp', 'http', 'https')),
+    local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
+    local_port INTEGER NOT NULL CHECK (local_port > 0 AND local_port <= 65535),
+    remote_port INTEGER CHECK (remote_port IS NULL OR (remote_port > 0 AND remote_port <= 65535)),
+    custom_domains_json TEXT NOT NULL DEFAULT '[]',
+    host_header_rewrite TEXT NOT NULL DEFAULT '',
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    remark TEXT NOT NULL DEFAULT '',
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(server_id, name)
+);
+
+CREATE TABLE IF NOT EXISTS frp_runtime_status (
+    server_id INTEGER PRIMARY KEY REFERENCES frp_servers(id) ON DELETE CASCADE,
+    status TEXT NOT NULL DEFAULT 'stopped',
+    pid INTEGER,
+    frpc_version TEXT NOT NULL DEFAULT '',
+    last_started_at TEXT,
+    last_reloaded_at TEXT,
+    last_error TEXT NOT NULL DEFAULT '',
+    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_tcp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'tcp' AND remote_port IS NOT NULL;
diff --git a/app-src/migrations/016_frp_server_options.sql b/app-src/migrations/016_frp_server_options.sql
new file mode 100644
index 0000000..6ac997b
--- /dev/null
+++ b/app-src/migrations/016_frp_server_options.sql
@@ -0,0 +1,2 @@
+ALTER TABLE frp_servers ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
+ALTER TABLE frp_servers ADD COLUMN oidc_client_secret_enc TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/017_frp_server_diagnostics.sql b/app-src/migrations/017_frp_server_diagnostics.sql
new file mode 100644
index 0000000..10c3940
--- /dev/null
+++ b/app-src/migrations/017_frp_server_diagnostics.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS frp_server_diagnostics (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+    checked_at TEXT NOT NULL DEFAULT (datetime('now')),
+    available INTEGER NOT NULL DEFAULT 0 CHECK (available IN (0, 1)),
+    latency_ms INTEGER,
+    error TEXT NOT NULL DEFAULT ''
+);
+
+CREATE INDEX IF NOT EXISTS idx_frp_server_diagnostics_server_time
+ON frp_server_diagnostics(server_id, checked_at);
diff --git a/app-src/migrations/018_frp_proxy_options.sql b/app-src/migrations/018_frp_proxy_options.sql
new file mode 100644
index 0000000..cd39db9
--- /dev/null
+++ b/app-src/migrations/018_frp_proxy_options.sql
@@ -0,0 +1 @@
+ALTER TABLE frp_proxies ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
diff --git a/app-src/migrations/019_frp_runtime_states.sql b/app-src/migrations/019_frp_runtime_states.sql
new file mode 100644
index 0000000..a759d80
--- /dev/null
+++ b/app-src/migrations/019_frp_runtime_states.sql
@@ -0,0 +1,18 @@
+ALTER TABLE frp_runtime_status ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'stopped';
+ALTER TABLE frp_runtime_status ADD COLUMN process_state TEXT NOT NULL DEFAULT 'stopped';
+ALTER TABLE frp_runtime_status ADD COLUMN connection_state TEXT NOT NULL DEFAULT 'unknown';
+ALTER TABLE frp_runtime_status ADD COLUMN state_source TEXT NOT NULL DEFAULT 'legacy';
+ALTER TABLE frp_runtime_status ADD COLUMN process_pid INTEGER;
+ALTER TABLE frp_runtime_status ADD COLUMN exit_code INTEGER;
+ALTER TABLE frp_runtime_status ADD COLUMN last_connected_at TEXT;
+ALTER TABLE frp_runtime_status ADD COLUMN last_disconnected_at TEXT;
+
+UPDATE frp_runtime_status
+SET desired_state = CASE WHEN status IN ('running', 'starting') THEN 'running' ELSE 'stopped' END,
+    process_state = CASE WHEN status IN ('running', 'starting') THEN 'unknown' ELSE 'stopped' END,
+    connection_state = CASE WHEN status = 'running' THEN 'unknown' ELSE 'unknown' END,
+    state_source = 'migration'
+WHERE desired_state = 'stopped' AND status IN ('running', 'starting');
+
+CREATE INDEX IF NOT EXISTS idx_frp_runtime_process_state
+ON frp_runtime_status(process_state, connection_state);
diff --git a/app-src/migrations/020_frp_dashboard.sql b/app-src/migrations/020_frp_dashboard.sql
new file mode 100644
index 0000000..4d6185b
--- /dev/null
+++ b/app-src/migrations/020_frp_dashboard.sql
@@ -0,0 +1 @@
+ALTER TABLE frp_servers ADD COLUMN dashboard_password_enc TEXT;
diff --git a/app-src/migrations/021_frp_proxy_types.sql b/app-src/migrations/021_frp_proxy_types.sql
new file mode 100644
index 0000000..220c085
--- /dev/null
+++ b/app-src/migrations/021_frp_proxy_types.sql
@@ -0,0 +1,42 @@
+-- FRP 规则类型与端口约束修正(文档 FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)
+-- 原 014_frp.sql 的 type CHECK 只有 tcp/http/https,但校验器与前端已支持
+-- udp/tcpmux/stcp/sudp/xtcp:这些类型会在校验通过后因 SQLite CHECK 约束插入失败。
+-- SQLite 无法 ALTER CHECK 约束,这里重建 frp_proxies 表并统一约束。
+CREATE TABLE IF NOT EXISTS frp_proxies_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+    name TEXT NOT NULL COLLATE NOCASE,
+    type TEXT NOT NULL CHECK (type IN ('tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp')),
+    local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
+    local_port INTEGER NOT NULL CHECK (local_port > 0 AND local_port <= 65535),
+    remote_port INTEGER CHECK (
+        remote_port IS NULL
+        OR (remote_port > 0 AND remote_port <= 65535 AND type IN ('tcp', 'udp'))
+    ),
+    custom_domains_json TEXT NOT NULL DEFAULT '[]',
+    host_header_rewrite TEXT NOT NULL DEFAULT '',
+    config_json TEXT NOT NULL DEFAULT '{}',
+    enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+    remark TEXT NOT NULL DEFAULT '',
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(server_id, name)
+);
+
+INSERT INTO frp_proxies_new
+    (id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, config_json, enabled, remark, created_at, updated_at)
+SELECT
+    id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, COALESCE(config_json, '{}'), enabled, remark, created_at, updated_at
+FROM frp_proxies;
+
+DROP TABLE frp_proxies;
+ALTER TABLE frp_proxies_new RENAME TO frp_proxies;
+
+-- tcp 与 udp 的远程端口在同一 server_id 内分别查重(原索引混用两种协议)
+DROP INDEX IF EXISTS idx_frp_proxies_tcp_remote_port;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_tcp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'tcp' AND remote_port IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_udp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'udp' AND remote_port IS NOT NULL;
diff --git a/app-src/migrations/022_ddns_custom_ip.sql b/app-src/migrations/022_ddns_custom_ip.sql
new file mode 100644
index 0000000..995a30f
--- /dev/null
+++ b/app-src/migrations/022_ddns_custom_ip.sql
@@ -0,0 +1,3 @@
+-- DDNS 支持自定义 IP:填写后更新记录时直接使用该 IP(如公网服务器 IP),不填则检测本机公网 IP。
+ALTER TABLE ddns_configs ADD COLUMN custom_ipv4 TEXT NOT NULL DEFAULT '';
+ALTER TABLE ddns_configs ADD COLUMN custom_ipv6 TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/023_frp_server_agent.sql b/app-src/migrations/023_frp_server_agent.sql
new file mode 100644
index 0000000..8af1420
--- /dev/null
+++ b/app-src/migrations/023_frp_server_agent.sql
@@ -0,0 +1,9 @@
+-- 公网 havline-agent 远程管理配置
+ALTER TABLE frp_servers ADD COLUMN agent_url TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_token_enc TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_host TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_port INTEGER NOT NULL DEFAULT 22;
+ALTER TABLE frp_servers ADD COLUMN ssh_user TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_auth TEXT NOT NULL DEFAULT 'key';
+ALTER TABLE frp_servers ADD COLUMN ssh_secret_enc TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN mgmt_enabled INTEGER NOT NULL DEFAULT 0 CHECK (mgmt_enabled IN (0, 1));
diff --git a/app-src/migrations/024_proxy_custom_endpoints.sql b/app-src/migrations/024_proxy_custom_endpoints.sql
new file mode 100644
index 0000000..4ea0ec7
--- /dev/null
+++ b/app-src/migrations/024_proxy_custom_endpoints.sql
@@ -0,0 +1,14 @@
+-- 手动(custom)Nginx 规则的监听占用:custom 配置的正本在文件系统(rules.nginx_mode 只记开关),
+-- 本表只存解析出的 (域名, 端口),专供保存时做「同域名 + 同端口」冲突校验;
+-- 不参与配置生成、流量统计与前端表单预填(那些是 proxy_hosts 的语义)。
+CREATE TABLE IF NOT EXISTS proxy_custom_endpoints (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    rule_id INTEGER NOT NULL REFERENCES proxy_rules(id) ON DELETE CASCADE,
+    hostname TEXT NOT NULL COLLATE NOCASE,
+    listen_port INTEGER NOT NULL CHECK (listen_port > 0 AND listen_port <= 65535),
+    created_at TEXT NOT NULL DEFAULT (datetime('now')),
+    UNIQUE(rule_id, hostname, listen_port)
+);
+
+CREATE INDEX IF NOT EXISTS idx_proxy_custom_endpoints_rule_id ON proxy_custom_endpoints(rule_id);
+CREATE INDEX IF NOT EXISTS idx_proxy_custom_endpoints_binding ON proxy_custom_endpoints(hostname, listen_port);
diff --git a/app-src/migrations/025_api_tokens.sql b/app-src/migrations/025_api_tokens.sql
new file mode 100644
index 0000000..7ebc87f
--- /dev/null
+++ b/app-src/migrations/025_api_tokens.sql
@@ -0,0 +1,12 @@
+-- API Token:给无法保存会话 cookie 的调用方(脚本 / HomeAssistant / 手机快捷指令)用。
+-- token_hash 是明文令牌的 SHA-256:明文只在创建响应里返回一次,库里不留。
+-- scope: read = 只允许 GET/HEAD,write = 与登录会话同权。
+CREATE TABLE IF NOT EXISTS api_tokens (
+    id           INTEGER PRIMARY KEY AUTOINCREMENT,
+    name         TEXT NOT NULL,
+    token_hash   TEXT NOT NULL UNIQUE,
+    scope        TEXT NOT NULL DEFAULT 'read',
+    expires_at   TEXT,
+    last_used_at TEXT,
+    created_at   TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/app-src/migrations/026_route_health.sql b/app-src/migrations/026_route_health.sql
new file mode 100644
index 0000000..6c56236
--- /dev/null
+++ b/app-src/migrations/026_route_health.sql
@@ -0,0 +1,14 @@
+-- 反代规则健康状态变更记录:只存「状态变化」(up / down / unknown),
+-- 可用率与不可用区间由变更序列推导——避免每 2 分钟一行原始采样把表撑大。
+-- unknown 表示「frps 管理接口不可用、判不出来」,与 down 严格区分(判不了不等于不通)。
+CREATE TABLE IF NOT EXISTS route_health_events (
+    id        INTEGER PRIMARY KEY AUTOINCREMENT,
+    server_id INTEGER NOT NULL,
+    domain    TEXT NOT NULL,
+    state     TEXT NOT NULL,
+    reason    TEXT,
+    detail    TEXT,
+    at        TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_route_health_events_lookup ON route_health_events(server_id, domain, at);
diff --git a/app-src/migrations/027_metrics_samples.sql b/app-src/migrations/027_metrics_samples.sql
new file mode 100644
index 0000000..0e012a8
--- /dev/null
+++ b/app-src/migrations/027_metrics_samples.sql
@@ -0,0 +1,12 @@
+-- 指标历史:按 (scope, metric) 纵向存点,避免「每加一个指标就改一次表结构」。
+-- scope 形如 host / server:3 / route:3:example.com;采样跟随巡检轮次(2 分钟)。
+-- 行量很小(本机 3 条指标约每天 2160 行),因此不做降采样表,查询时按桶取平均。
+CREATE TABLE IF NOT EXISTS metrics_samples (
+    id     INTEGER PRIMARY KEY AUTOINCREMENT,
+    scope  TEXT NOT NULL,
+    metric TEXT NOT NULL,
+    value  REAL NOT NULL,
+    at     TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_metrics_samples_lookup ON metrics_samples(scope, metric, at);
diff --git a/app-src/migrations/028_agent_transport.sql b/app-src/migrations/028_agent_transport.sql
new file mode 100644
index 0000000..0fd7028
--- /dev/null
+++ b/app-src/migrations/028_agent_transport.sql
@@ -0,0 +1,7 @@
+-- Agent 传输安全:默认支持 SSH 隧道回环访问,保留 http 作为升级兼容模式。
+ALTER TABLE frp_servers ADD COLUMN agent_transport TEXT NOT NULL DEFAULT 'http';
+ALTER TABLE frp_servers ADD COLUMN agent_local_port INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE frp_servers ADD COLUMN agent_tls_pin TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_listen_addr TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_transport_state TEXT NOT NULL DEFAULT 'unknown';
+ALTER TABLE frp_servers ADD COLUMN agent_transport_error TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/029_agent_tls_rotation.sql b/app-src/migrations/029_agent_tls_rotation.sql
new file mode 100644
index 0000000..86b0b53
--- /dev/null
+++ b/app-src/migrations/029_agent_tls_rotation.sql
@@ -0,0 +1,5 @@
+-- Agent TLS 轮换与主机防火墙辅助状态。
+ALTER TABLE frp_servers ADD COLUMN agent_tls_pin_prev TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_tls_pin_prev_until TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_tls_not_after TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_firewall_state TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/030_cloudflare_tunnels.sql b/app-src/migrations/030_cloudflare_tunnels.sql
new file mode 100644
index 0000000..b867ff4
--- /dev/null
+++ b/app-src/migrations/030_cloudflare_tunnels.sql
@@ -0,0 +1,20 @@
+-- Cloudflare Tunnel(cloudflared)独立模块:每条隧道一份本地配置 + 子进程 + 日志。
+CREATE TABLE IF NOT EXISTS cf_tunnels (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    name            TEXT NOT NULL UNIQUE,
+    mode            TEXT NOT NULL DEFAULT 'token-local',
+    tunnel_id       TEXT NOT NULL DEFAULT '',
+    account_tag     TEXT NOT NULL DEFAULT '',
+    credentials_enc TEXT NOT NULL DEFAULT '',
+    cert_pem_enc    TEXT NOT NULL DEFAULT '',
+    config_path     TEXT NOT NULL DEFAULT '',
+    creds_path      TEXT NOT NULL DEFAULT '',
+    log_path        TEXT NOT NULL DEFAULT '',
+    network_json    TEXT NOT NULL DEFAULT '{}',
+    status          TEXT NOT NULL DEFAULT 'stopped',
+    last_error      TEXT NOT NULL DEFAULT '',
+    metrics_port    INTEGER NOT NULL DEFAULT 0,
+    auto_start      INTEGER NOT NULL DEFAULT 0,
+    created_at      TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/app-src/migrations/031_cf_settings.sql b/app-src/migrations/031_cf_settings.sql
new file mode 100644
index 0000000..f3f34e4
--- /dev/null
+++ b/app-src/migrations/031_cf_settings.sql
@@ -0,0 +1,9 @@
+-- Cloudflare 隧道模块的应用级配置:默认 Token、下载镜像与默认网络设置。
+CREATE TABLE IF NOT EXISTS cf_settings (
+    id                INTEGER PRIMARY KEY CHECK (id = 1),
+    default_token_enc TEXT NOT NULL DEFAULT '',
+    mirror            TEXT NOT NULL DEFAULT 'official',
+    network_json      TEXT NOT NULL DEFAULT '{}',
+    updated_at        TEXT NOT NULL DEFAULT (datetime('now'))
+);
+INSERT OR IGNORE INTO cf_settings(id) VALUES (1);
diff --git a/app-src/migrations/032_cf_api_token.sql b/app-src/migrations/032_cf_api_token.sql
new file mode 100644
index 0000000..e9ef7ae
--- /dev/null
+++ b/app-src/migrations/032_cf_api_token.sql
@@ -0,0 +1,2 @@
+-- Cloudflare API Token(cfat_...):用于连接验证与后续云端同步,与隧道 Token 分开存储。
+ALTER TABLE cf_settings ADD COLUMN api_token_enc TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/033_cf_account_id.sql b/app-src/migrations/033_cf_account_id.sql
new file mode 100644
index 0000000..cbef781
--- /dev/null
+++ b/app-src/migrations/033_cf_account_id.sql
@@ -0,0 +1,2 @@
+-- Cloudflare 账号 ID:与 API Token 一起用于创建与管理隧道。
+ALTER TABLE cf_settings ADD COLUMN account_id TEXT NOT NULL DEFAULT '';
diff --git a/app-src/migrations/034_cf_global_enabled.sql b/app-src/migrations/034_cf_global_enabled.sql
new file mode 100644
index 0000000..a5c58d6
--- /dev/null
+++ b/app-src/migrations/034_cf_global_enabled.sql
@@ -0,0 +1,2 @@
+-- Cloudflare 隧道全局托管开关:关闭后停止所有隧道,且应用重启不再自动拉起。
+ALTER TABLE cf_settings ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
diff --git a/app-src/migrations/035_proxy_exits.sql b/app-src/migrations/035_proxy_exits.sql
new file mode 100644
index 0000000..7657853
--- /dev/null
+++ b/app-src/migrations/035_proxy_exits.sql
@@ -0,0 +1,19 @@
+-- 服务发布规则出口:默认仍走本机 Nginx,保持旧数据行为不变。
+ALTER TABLE proxy_rules ADD COLUMN exits_json TEXT NOT NULL DEFAULT '["local"]';
+ALTER TABLE proxy_rules ADD COLUMN cf_tunnel_id INTEGER NOT NULL DEFAULT 0;
+
+-- 托管隧道标记:只有托管隧道才从反向代理规则派生 ingress,避免覆盖手工隧道。
+ALTER TABLE cf_tunnels ADD COLUMN managed INTEGER NOT NULL DEFAULT 0;
+
+-- 记录 Havline 自动创建或接管的 Cloudflare DNS 记录,删除时只清理自己管理的记录。
+CREATE TABLE IF NOT EXISTS cf_dns_records (
+    id          INTEGER PRIMARY KEY AUTOINCREMENT,
+    tunnel_id   INTEGER NOT NULL,
+    zone_id     TEXT NOT NULL,
+    record_id   TEXT NOT NULL,
+    hostname    TEXT NOT NULL UNIQUE,
+    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+    updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_cf_dns_records_tunnel ON cf_dns_records(tunnel_id);
diff --git a/app-src/migrations/036_cf_health.sql b/app-src/migrations/036_cf_health.sql
new file mode 100644
index 0000000..5c4b66b
--- /dev/null
+++ b/app-src/migrations/036_cf_health.sql
@@ -0,0 +1,13 @@
+-- Cloudflare 隧道健康事件:只记录状态变化,供巡检通知与公开状态页计算 24h 可用率。
+CREATE TABLE IF NOT EXISTS cf_health_events (
+    id        INTEGER PRIMARY KEY AUTOINCREMENT,
+    tunnel_id INTEGER NOT NULL,
+    domain    TEXT NOT NULL,
+    state     TEXT NOT NULL,
+    reason    TEXT NOT NULL DEFAULT '',
+    detail    TEXT NOT NULL DEFAULT '',
+    at        TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_cf_health_events_lookup ON cf_health_events(tunnel_id, domain, id);
+CREATE INDEX IF NOT EXISTS idx_cf_health_events_at ON cf_health_events(at);
diff --git a/app-src/scripts/agent-install.sh b/app-src/scripts/agent-install.sh
new file mode 100644
index 0000000..fd8c8d6
--- /dev/null
+++ b/app-src/scripts/agent-install.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+set -eu
+
+if [ "$(id -u)" -ne 0 ]; then
+  echo "请使用 root 或 sudo 运行" >&2
+  exit 1
+fi
+
+AGENT_BINARY=${1:-havline-agent}
+PREFIX=/usr/local/bin/havline-agent
+CONF_DIR=/etc/havline-agent
+CONF_FILE="$CONF_DIR/agent.env"
+UNIT=/etc/systemd/system/havline-agent.service
+mkdir -p "$CONF_DIR" /var/lib/havline-agent/certs
+systemctl stop havline-agent 2>/dev/null || true
+TMP_PREFIX="${PREFIX}.new"
+# 迁移旧版 Agent 留在 nginx include 范围内的 <配置>.versions 目录(保留历史版本内容)
+for NGINX_DIR in /etc/nginx/sites-enabled /etc/nginx/conf.d; do
+  [ -d "$NGINX_DIR" ] || continue
+  for LEGACY in "$NGINX_DIR"/*.versions; do
+    [ -d "$LEGACY" ] || continue
+    TARGET="$NGINX_DIR/.$(basename "$LEGACY")"
+    [ -e "$TARGET" ] || mv "$LEGACY" "$TARGET" || true
+  done
+done
+install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX"
+if [ ! -x "$TMP_PREFIX" ]; then
+  echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
+  exit 1
+fi
+AGENT_VERSION=$($TMP_PREFIX -version 2>/dev/null) || {
+  echo "agent 二进制无法执行:$TMP_PREFIX" >&2
+  rm -f "$TMP_PREFIX"
+  exit 1
+}
+mv -f "$TMP_PREFIX" "$PREFIX"
+if [ ! -x "$PREFIX" ]; then
+  echo "agent 原子替换失败:$PREFIX 不存在或不可执行" >&2
+  exit 1
+fi
+printf 'agent version: %s\n' "$AGENT_VERSION"
+
+if [ ! -f "$CONF_FILE" ]; then
+  umask 077
+  # 用 /dev/urandom 生成 64 位 hex token,不依赖 openssl(与 Havline 内嵌副本保持同一份逻辑)
+  printf 'HAVLINE_AGENT_TOKEN=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > "$CONF_FILE"
+fi
+
+# 默认只监听回环;显式删除旧公网监听,由中央 Havline 通过 SSH 隧道访问。
+cp "$CONF_FILE" "$CONF_FILE.previous" 2>/dev/null || true
+touch "$CONF_FILE"
+sed -i '/^HAVLINE_AGENT_LISTEN=/d; /^HAVLINE_AGENT_ALLOW_REMOTE=/d' "$CONF_FILE"
+printf 'HAVLINE_AGENT_LISTEN=127.0.0.1:7700\nHAVLINE_AGENT_ALLOW_REMOTE=0\n' >> "$CONF_FILE"
+
+cat > "$UNIT" <<'UNIT'
+[Unit]
+Description=Havline FRPS management agent
+After=network-online.target nginx.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+EnvironmentFile=/etc/havline-agent/agent.env
+ExecStart=/usr/local/bin/havline-agent
+Restart=on-failure
+RestartSec=3
+User=root
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+systemctl daemon-reload
+systemctl restart havline-agent
+systemctl enable havline-agent
+TOKEN=$(sed -n 's/^HAVLINE_AGENT_TOKEN=//p' "$CONF_FILE")
+echo "havline-agent 已安装并启动"
+echo "API: SSH tunnel -> 127.0.0.1:7700"
+echo "Token: $TOKEN"
diff --git a/app-src/scripts/build-linux.ps1 b/app-src/scripts/build-linux.ps1
new file mode 100644
index 0000000..bbb76eb
--- /dev/null
+++ b/app-src/scripts/build-linux.ps1
@@ -0,0 +1,111 @@
+<#
+.SYNOPSIS
+  构建 Havline 的 Linux 安装包(tar.gz:二进制 + migrations + systemd 安装脚本)。
+
+.DESCRIPTION
+  与 Dockerfile 同源的三步:
+    1) 前端构建(VITE_APP_VERSION 取根目录 VERSION,dist 会被 //go:embed 进二进制)
+    2) 交叉编译 havline-agent 双架构到 internal/frp/agentbin(主程序构建时内嵌)
+    3) CGO_ENABLED=0 交叉编译主程序并打包
+  依赖是纯 Go 的(modernc.org/sqlite),所以 Windows 上就能产出 Linux 包,不需要 Docker/WSL。
+
+.EXAMPLE
+  ./scripts/build-linux.ps1                  # 双架构
+  ./scripts/build-linux.ps1 -Arch amd64      # 只出 amd64
+  ./scripts/build-linux.ps1 -SkipWeb         # 前端已构建过,跳过 npm
+#>
+param(
+  [ValidateSet('amd64', 'arm64', 'both')] [string]$Arch = 'both',
+  [string]$OutDir = 'dist',
+  [switch]$SkipWeb,
+  [switch]$SkipAgent
+)
+
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+Set-Location $root
+
+$version = (Get-Content (Join-Path $root 'VERSION') -Raw).Trim()
+if (-not $version) { throw 'VERSION 文件为空,无法确定版本号' }
+Write-Host "==> Havline $version" -ForegroundColor Cyan
+
+# ---- 1) 前端:dist 缺失会让主程序编译失败(//go:embed all:web/dist)
+if (-not $SkipWeb) {
+  Push-Location (Join-Path $root 'web')
+  try {
+    if (-not (Test-Path 'node_modules')) {
+      Write-Host '==> npm ci(首次构建,需要网络)'
+      npm ci
+      if ($LASTEXITCODE -ne 0) { throw "npm ci 失败(exit $LASTEXITCODE)" }
+    }
+    $env:VITE_APP_VERSION = $version
+    npm run build
+    if ($LASTEXITCODE -ne 0) { throw "前端构建失败(exit $LASTEXITCODE)" }
+  } finally {
+    Pop-Location
+    Remove-Item Env:VITE_APP_VERSION -ErrorAction SilentlyContinue
+  }
+}
+if (-not (Test-Path (Join-Path $root 'cmd/havline/web/dist/index.html'))) {
+  throw '缺少 cmd/havline/web/dist/index.html:请先构建前端(或去掉 -SkipWeb)'
+}
+
+# ---- 2) havline-agent 双架构:主程序会内嵌这两个文件,必须在构建主程序前就位
+if (-not $SkipAgent) {
+  Write-Host '==> 交叉编译 havline-agent(amd64/arm64)'
+  $env:CGO_ENABLED = '0'
+  $env:GOOS = 'linux'
+  foreach ($a in @('amd64', 'arm64')) {
+    $env:GOARCH = $a
+    go build -trimpath -o "internal/frp/agentbin/havline-agent-linux-$a" ./cmd/havline-agent
+    if ($LASTEXITCODE -ne 0) { throw "havline-agent($a) 构建失败(exit $LASTEXITCODE)" }
+  }
+}
+
+# ---- 3) 主程序 + 打包
+$archs = if ($Arch -eq 'both') { @('amd64', 'arm64') } else { @($Arch) }
+$outPath = Join-Path $root $OutDir
+New-Item -ItemType Directory -Force -Path $outPath | Out-Null
+
+try {
+  foreach ($a in $archs) {
+    $name = "havline-$version-linux-$a"
+    $stage = Join-Path $outPath $name
+    if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
+    New-Item -ItemType Directory -Force -Path (Join-Path $stage 'migrations') | Out-Null
+
+    Write-Host "==> 交叉编译 havline(linux/$a)"
+    $env:CGO_ENABLED = '0'
+    $env:GOOS = 'linux'
+    $env:GOARCH = $a
+    go build -trimpath `
+      -ldflags "-s -w -X github.com/havline/havline/internal/version.Version=$version" `
+      -o (Join-Path $stage 'havline') ./cmd/havline
+    if ($LASTEXITCODE -ne 0) { throw "havline($a) 构建失败(exit $LASTEXITCODE)" }
+
+    # 包内容与 Dockerfile 的运行时 stage 对齐:migrations 必须与二进制同级(程序按工作目录找)
+    Copy-Item (Join-Path $root 'migrations/*.sql') (Join-Path $stage 'migrations') -Force
+    Copy-Item (Join-Path $root 'scripts/havline-install.sh') (Join-Path $stage 'install.sh') -Force
+    Copy-Item (Join-Path $root 'VERSION') $stage -Force
+    Copy-Item (Join-Path $root 'LICENSE') $stage -Force
+    if (Test-Path (Join-Path $root 'web/assets/image')) {
+      New-Item -ItemType Directory -Force -Path (Join-Path $stage 'web/assets') | Out-Null
+      Copy-Item -Recurse -Force (Join-Path $root 'web/assets/image') (Join-Path $stage 'web/assets')
+    }
+
+    $tarball = Join-Path $outPath "$name.tar.gz"
+    if (Test-Path $tarball) { Remove-Item -Force $tarball }
+    tar -czf $tarball -C $outPath $name
+    if ($LASTEXITCODE -ne 0) { throw "打包失败(exit $LASTEXITCODE)" }
+
+    $hash = (Get-FileHash $tarball -Algorithm SHA256).Hash.ToLower()
+    "$hash  $name.tar.gz" | Out-File -Encoding ascii "$tarball.sha256"
+    $sizeMB = [math]::Round((Get-Item $tarball).Length / 1MB, 2)
+    Write-Host ("    {0}.tar.gz  {1} MB  sha256 {2}" -f $name, $sizeMB, $hash.Substring(0, 16)) -ForegroundColor Green
+  }
+} finally {
+  # 交叉编译用的环境变量必须复位,否则后续 go vet/test 会继续按 linux 目标跑
+  Remove-Item Env:GOOS, Env:GOARCH, Env:CGO_ENABLED -ErrorAction SilentlyContinue
+}
+
+Write-Host "==> 产物在 $outPath" -ForegroundColor Cyan
diff --git a/app-src/scripts/havline-install.sh b/app-src/scripts/havline-install.sh
new file mode 100644
index 0000000..3062498
--- /dev/null
+++ b/app-src/scripts/havline-install.sh
@@ -0,0 +1,229 @@
+#!/usr/bin/env bash
+# Havline Linux 安装脚本(随 tar.gz 一起分发)
+#
+#   sudo bash ./install.sh                 安装并启动
+#   sudo bash ./install.sh --port 8080     换监听端口
+#   sudo bash ./install.sh --uninstall     卸载(保留数据目录)
+#   sudo bash ./install.sh --uninstall --purge   卸载并删除数据目录
+#
+# 用 `bash ./install.sh` 而不是 `./install.sh`:Windows 上打出来的 tar 不带执行位
+# (本机 tar 不支持 --mode,待装本时无法保留 +x),显式调 bash 就不需要先 chmod。
+#
+# 关于初始口令:Havline 首次启动会随机生成一个 16 位管理员口令(用户名 admin),
+# 打进攻日志与日志文件(<数据目录>/logs/app.log),本脚本装完会直接把它打印出来。
+# 忘了 / 想重新初始化:sudo bash ./install.sh --reset-admin
+#
+# 安装布局(与 Docker 版同一套环境变量,见 Dockerfile 的 ENV 块):
+#   /opt/havline/havline                 主程序
+#   /opt/havline/migrations/*.sql      迁移脚本(程序按工作目录找它,必须与二进制同级)
+#   /var/lib/havline/                  数据目录(库、日志、证书、nginx 与 frp 的运行数据)
+#   /etc/havline/havline.env              环境变量(含随机生成的会话密钥,0600)
+#   /etc/systemd/system/havline.service
+set -euo pipefail
+
+PREFIX=/opt/havline
+DATA_DIR=/var/lib/havline
+ENV_DIR=/etc/havline
+ENV_FILE=$ENV_DIR/havline.env
+UNIT=/etc/systemd/system/havline.service
+SERVICE_NAME=havline
+PORT=6893
+UNINSTALL=0
+PURGE=0
+RESET_ADMIN=0
+
+usage() {
+  sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
+  exit "${1:-0}"
+}
+
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --prefix) PREFIX=${2:?}; shift 2 ;;
+    --data) DATA_DIR=${2:?}; shift 2 ;;
+    --port) PORT=${2:?}; shift 2 ;;
+    --uninstall) UNINSTALL=1; shift ;;
+    --purge) PURGE=1; shift ;;
+    --reset-admin) RESET_ADMIN=1; shift ;;
+    -h|--help) usage 0 ;;
+    *) echo "未知参数:$1" >&2; usage 1 ;;
+  esac
+done
+
+if [ "$(id -u)" -ne 0 ]; then
+  echo "请用 root 运行(sudo bash ./install.sh)" >&2
+  exit 1
+fi
+
+if [ "$UNINSTALL" -eq 1 ]; then
+  systemctl stop "$SERVICE_NAME" 2>/dev/null || true
+  systemctl disable "$SERVICE_NAME" 2>/dev/null || true
+  rm -f "$UNIT"
+  systemctl daemon-reload
+  rm -rf "$PREFIX"
+  if [ "$PURGE" -eq 1 ]; then
+    rm -rf "$DATA_DIR" "$ENV_DIR"
+    echo "已卸载,并删除了数据目录 $DATA_DIR 与 $ENV_DIR"
+  else
+    echo "已卸载。数据目录保留在 $DATA_DIR(如需一并删除:--uninstall --purge)"
+  fi
+  exit 0
+fi
+
+# 从日志里拽出最新一次生成的管理员口令(应用启动时写的是 JSON 行,stdout 与 app.log 都有)
+print_initial_password() {
+  local line
+  line=$(journalctl -u "$SERVICE_NAME" -o cat --no-pager 2>/dev/null \
+          | grep -o '"password":"[^"]*"' | tail -n 1)
+  if [ -z "$line" ]; then
+    line=$(grep -o '"password":"[^"]*"' "$DATA_DIR/logs/app.log" 2>/dev/null | tail -n 1)
+  fi
+  if [ -n "$line" ]; then
+    pw=$(printf '%s' "$line" | sed 's/^"password":"//; s/"$//')
+    echo "    初始口令:$pw"
+    echo "    用户名:admin(首次登录后请在「设置」里改掉)"
+    echo "    这条口令只在首次启动时生成一次,日志文件:$DATA_DIR/logs/app.log"
+  else
+    echo "    未从日志里读到初始口令(可能不是首次启动);手动查看:"
+    echo "      journalctl -u $SERVICE_NAME --no-pager | grep -i password | tail -n 3"
+  fi
+}
+
+# 重置管理员:删掉 admins 行后重启,启动流程会重新生成一个随机口令并写进日志
+reset_admin() {
+  if [ ! -f "$DATA_DIR/havline.db" ]; then
+    echo "找不到数据库 $DATA_DIR/havline.db,无需重置" >&2
+    exit 1
+  fi
+  systemctl stop "$SERVICE_NAME" 2>/dev/null || true
+  local backup="$DATA_DIR/havline.db.bak.$(date +%Y%m%d-%H%M%S)"
+  cp "$DATA_DIR/havline.db" "$backup"
+  if command -v sqlite3 >/dev/null 2>&1; then
+    sqlite3 "$DATA_DIR/havline.db" 'DELETE FROM sessions; DELETE FROM admins;'
+  elif command -v python3 >/dev/null 2>&1; then
+    python3 - "$DATA_DIR/havline.db" <<'PY'
+import sqlite3, sys
+conn = sqlite3.connect(sys.argv[1])
+conn.execute('DELETE FROM sessions')
+conn.execute('DELETE FROM admins')
+conn.commit()
+PY
+  else
+    echo "需要 sqlite3 或 python3 才能改库(已备份到 $backup)" >&2
+    systemctl start "$SERVICE_NAME" 2>/dev/null || true
+    exit 1
+  fi
+  echo "==> 已清空管理员与先前的会话(数据库已备份:$backup)"
+  systemctl start "$SERVICE_NAME"
+  sleep 2
+  systemctl is-active --quiet "$SERVICE_NAME" || {
+    echo "服务未能启动,请看:journalctl -u $SERVICE_NAME -n 50 --no-pager" >&2
+    exit 1
+  }
+  echo "==> 已重置,下面是新生成的管理员口令:"
+  print_initial_password
+  exit 0
+}
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+if [ "$RESET_ADMIN" -eq 1 ]; then
+  reset_admin
+fi
+
+for f in havline migrations; do
+  if [ ! -e "$SCRIPT_DIR/$f" ]; then
+    echo "安装包不完整:缺少 $f(请在解压后的目录里运行 ./install.sh)" >&2
+    exit 1
+  fi
+done
+
+# 架构自检:避免把 amd64 包装到 arm64 机器上,起来才报 exec format error
+case "$(uname -m)" in
+  x86_64) PKG_ARCH=amd64 ;;
+  aarch64|arm64) PKG_ARCH=arm64 ;;
+  *) PKG_ARCH=unknown ;;
+esac
+case "$SCRIPT_DIR" in
+  *"-linux-amd64") BIN_ARCH=amd64 ;;
+  *"-linux-arm64") BIN_ARCH=arm64 ;;
+  *) BIN_ARCH=$PKG_ARCH ;;
+esac
+if [ "$PKG_ARCH" != unknown ] && [ "$BIN_ARCH" != "$PKG_ARCH" ]; then
+  echo "警告:当前机器是 $PKG_ARCH,但这个包看起来是 $BIN_ARCH(可能无法运行)" >&2
+fi
+
+# nginx 的 mime.types 路径各发行版不同(Debian/Ubuntu 在 /etc/nginx,RHEL 系在 /usr/share/nginx)
+MIME_TYPES=/etc/nginx/mime.types
+[ -f "$MIME_TYPES" ] || MIME_TYPES=/usr/share/nginx/mime.types
+
+echo "==> 安装到 $PREFIX(数据目录 $DATA_DIR,监听 :$PORT)"
+systemctl stop "$SERVICE_NAME" 2>/dev/null || true
+
+install -d -m 0755 "$PREFIX"
+install -d -m 0750 "$DATA_DIR"
+install -d -m 0750 "$ENV_DIR"
+install -d -m 0755 "$PREFIX/migrations"
+
+install -m 0755 "$SCRIPT_DIR/havline" "$PREFIX/havline"
+rm -f "$PREFIX"/migrations/*.sql
+cp "$SCRIPT_DIR"/migrations/*.sql "$PREFIX/migrations/"
+install -m 0644 "$SCRIPT_DIR/VERSION" "$PREFIX/VERSION" 2>/dev/null || true
+
+# 环境变量只在首次安装时生成:重装不会换掉会话密钥(换了所有人都会掉线)
+if [ ! -f "$ENV_FILE" ]; then
+  SECRET=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
+  cat > "$ENV_FILE" < 已生成 $ENV_FILE(含随机会话密钥)"
+else
+  echo "==> 保留已有 $ENV_FILE(如需改端口请编辑它后重启服务)"
+fi
+
+cat > "$UNIT" </dev/null | awk '{print $1}')
+  echo
+  echo "==> 安装完成,服务已启动"
+  echo "    访问:http://${IP:-<本机IP>}:$PORT"
+  print_initial_password
+  echo "    状态:systemctl status $SERVICE_NAME"
+  echo "    日志:journalctl -u $SERVICE_NAME -f"
+  echo "    升级:先停止服务,再重跑本脚本(数据与密钥都会保留)"
+else
+  echo "服务未能启动,请看日志:journalctl -u $SERVICE_NAME -n 50 --no-pager" >&2
+  exit 1
+fi
diff --git a/app-src/scripts/push-oci-manual.ps1 b/app-src/scripts/push-oci-manual.ps1
new file mode 100644
index 0000000..0eb7a0d
--- /dev/null
+++ b/app-src/scripts/push-oci-manual.ps1
@@ -0,0 +1,126 @@
+<#
+Manual OCI layout pusher for registries whose token realm is misconfigured.
+
+This script is an emergency fallback. It reads Docker's OCI layout output,
+obtains a Bearer token over HTTPS from the Git credential store, and uploads
+the blobs and image manifest directly through the Registry HTTP API.
+#>
+param(
+  [Parameter(Mandatory = $true)][string]$LayoutDir,
+  [Parameter(Mandatory = $true)][string]$Platform,
+  [Parameter(Mandatory = $true)][string]$Tag,
+  [string]$Registry = "jayhub.top",
+  [string]$Repository = "mrlee/havline",
+  [string]$CredentialHost = "jayhub.top"
+)
+
+$ErrorActionPreference = "Stop"
+$script:Registry = $Registry
+$script:Repository = $Repository
+
+function Get-RegistryBasicAuth {
+  $cred = "protocol=https`nhost=$CredentialHost`n`n" | git credential fill 2>$null
+  $values = @{}
+  $cred -split "`n" | ForEach-Object {
+    if ($_ -match '^(.*?)=(.*)$') { $values[$matches[1]] = $matches[2] }
+  }
+  if (-not $values.username -or -not $values.password) {
+    throw "No Git credential found for $CredentialHost"
+  }
+  $raw = $values.username + ":" + $values.password
+  return "Basic " + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($raw))
+}
+
+function Get-BearerToken([string]$Scope) {
+  $basic = Get-RegistryBasicAuth
+  $url = "https://$Registry/v2/token?service=container_registry&scope=$Scope"
+  $response = Invoke-RestMethod -Uri $url -Headers @{ Authorization = $basic }
+  if (-not $response.token) { throw "Registry did not return a token" }
+  return $response.token
+}
+
+function Get-BlobPath([string]$LayoutRoot, [string]$Digest) {
+  return Join-Path $LayoutRoot ("blobs\sha256\" + ($Digest -replace '^sha256:', ''))
+}
+
+function Read-JsonBlob([string]$LayoutRoot, [string]$Digest) {
+  $path = Get-BlobPath $LayoutRoot $Digest
+  return Get-Content $path -Raw | ConvertFrom-Json
+}
+
+function Find-PlatformManifest($Index, [string]$Arch, [string]$Os) {
+  foreach ($entry in $Index.manifests) {
+    if ($entry.platform.architecture -eq $Arch -and $entry.platform.os -eq $Os) {
+      return $entry
+    }
+  }
+  return $null
+}
+
+function Resolve-ImageManifest([string]$LayoutRoot, [string]$Arch, [string]$Os) {
+  $index = Get-Content (Join-Path $LayoutRoot "index.json") -Raw | ConvertFrom-Json
+  foreach ($entry in $index.manifests) {
+    $document = Read-JsonBlob $LayoutRoot $entry.digest
+    if ($document.mediaType -eq "application/vnd.oci.image.manifest.v1+json") {
+      return @{ Digest = $entry.digest; Document = $document }
+    }
+    $match = Find-PlatformManifest $document $Arch $Os
+    if ($match) {
+      return @{ Digest = $match.digest; Document = (Read-JsonBlob $LayoutRoot $match.digest) }
+    }
+  }
+  throw "No image manifest found for $Os/$Arch"
+}
+
+function Test-BlobExists([string]$Digest, [string]$Token) {
+  $url = "https://$Registry/v2/$Repository/blobs/$Digest"
+  $response = Invoke-WebRequest -Uri $url -Method Head -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
+  return $response.StatusCode -eq 200
+}
+
+function Push-Blob([string]$LayoutRoot, [string]$Digest, [string]$Token) {
+  if (Test-BlobExists $Digest $Token) {
+    Write-Output "Blob already exists: $Digest"
+    return
+  }
+  $postUrl = "https://$Registry/v2/$Repository/blobs/uploads/"
+  $post = Invoke-WebRequest -Uri $postUrl -Method Post -Headers @{ Authorization = "Bearer $Token" } -SkipHttpErrorCheck
+  if ($post.StatusCode -ne 202) { throw "Create upload session failed: HTTP $($post.StatusCode)" }
+  $location = $post.Headers["Location"]
+  if ($location -is [array]) { $location = $location[0] }
+  if ($location -notmatch '^https?://') { $location = "https://$Registry$location" }
+  $separator = "?"
+  if ($location -match '\?') { $separator = "&" }
+  $putUrl = $location + $separator + "digest=" + $Digest
+  $path = Get-BlobPath $LayoutRoot $Digest
+  $curlArgs = @(
+    "-sS", "-o", "NUL", "-w", "%{http_code}", "-X", "PUT",
+    "-H", "Authorization: Bearer $Token",
+    "-H", "Content-Type: application/octet-stream",
+    "--data-binary", ("@" + $path), $putUrl
+  )
+  $status = (& curl.exe @curlArgs).Trim()
+  if ($status -ne "201") { throw "Upload blob failed: $Digest -> HTTP $status" }
+  Write-Output "Uploaded blob: $Digest"
+}
+
+function Push-Manifest([string]$TagValue, [string]$MediaType, [string]$Json, [string]$Token) {
+  $url = "https://$Registry/v2/$Repository/manifests/$TagValue"
+  $response = Invoke-WebRequest -Uri $url -Method Put -Headers @{ Authorization = "Bearer $Token" } -ContentType $MediaType -Body ([Text.Encoding]::UTF8.GetBytes($Json)) -SkipHttpErrorCheck
+  if ($response.StatusCode -ne 201) {
+    throw "Push manifest failed: HTTP $($response.StatusCode) $($response.Content)"
+  }
+  return $response.Headers["Docker-Content-Digest"]
+}
+
+$parts = $Platform -split "/"
+if ($parts.Count -ne 2) { throw "Platform must look like linux/amd64" }
+$layoutRoot = (Resolve-Path $LayoutDir).Path
+$manifest = Resolve-ImageManifest $layoutRoot $parts[1] $parts[0]
+$allBlobs = @($manifest.Document.config.digest) + @($manifest.Document.layers | ForEach-Object { $_.digest })
+$token = Get-BearerToken "repository:$Repository`:push,pull"
+foreach ($blob in $allBlobs) { Push-Blob $layoutRoot $blob $token }
+$manifestJson = Get-Content (Get-BlobPath $layoutRoot $manifest.Digest) -Raw
+$digest = Push-Manifest $Tag $manifest.Document.mediaType $manifestJson $token
+Write-Output "Manifest: $digest"
+Write-Output ("Tag: {0}/{1}:{2}" -f $Registry, $Repository, $Tag)
diff --git a/app-src/scripts/upgrade-hub.sh b/app-src/scripts/upgrade-hub.sh
new file mode 100644
index 0000000..b02083f
--- /dev/null
+++ b/app-src/scripts/upgrade-hub.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Havline 一键升级(适用于「只有编排文件」的部署目录,即 docker-compose.hub.yml 那种)
+#
+#   sudo bash upgrade-hub.sh
+#
+# 可选环境变量:
+#   COMPOSE_FILE=docker-compose.hub.yml   # 要用的编排文件
+#   REPO=http://.../mrlee/Havline.git       # 源码仓库
+#   BRANCH=main                           # 分支
+#
+# 它做四件事:
+#   1) 尽量取到仓库里的 VERSION,作为本次镜像 tag(有 tag 才能回滚);
+#      取不到就用时间戳当 tag,同样是唯一的、可回滚的
+#   2) docker compose up -d --build —— 构建上下文是 Git 地址,会重新拉最新分支
+#   3) 轮询 /api/version 确认服务起来
+#   4) 打印「回滚到上一个 tag」的命令
+set -euo pipefail
+
+REPO="${REPO:-https://jayhub.top/mrlee/Havline.git}"
+BRANCH="${BRANCH:-main}"
+COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.hub.yml}"
+APP_CONTAINER="${APP_CONTAINER:-havline}"
+HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6893/api/version}"
+
+if ! command -v docker >/dev/null 2>&1; then
+  echo "没有找到 docker:这个脚本要在运行 Havline 容器的那台机器上执行" >&2
+  exit 1
+fi
+if [ ! -f "$COMPOSE_FILE" ]; then
+  echo "找不到 $COMPOSE_FILE:请把它放在本脚本同目录(或用 COMPOSE_FILE=... 指定)" >&2
+  exit 1
+fi
+
+# 升级前容器用的镜像 tag,升级失败时可以切回去
+previous_image=$(docker inspect --format '{{.Config.Image}}' "$APP_CONTAINER" 2>/dev/null || echo '')
+echo "==> 当前:${previous_image:-(容器未运行)}"
+
+# 取版本号:优先仓库里的 VERSION;没 git 就退回时间戳(同样唯一,可回滚)
+version=""
+if command -v git >/dev/null 2>&1; then
+  work="$(mktemp -d)"
+  trap 'rm -rf "$work"' EXIT
+  if git clone --depth 1 --branch "$BRANCH" "$REPO" "$work/src" >/dev/null 2>&1 && [ -f "$work/src/VERSION" ]; then
+    version="$(tr -d ' \t\n\r' < "$work/src/VERSION")"
+    echo "==> 仓库版本:$version"
+  fi
+fi
+if [ -z "$version" ]; then
+  version="$(date +%Y%m%d-%H%M%S)"
+  echo "==> 未能从仓库读到 VERSION(可能没装 git),改用时间戳 tag:$version"
+fi
+
+export HAVLINE_VERSION="$version"
+echo "==> 构建并重建:havline:$version(构建上下文是 Git 地址,会自动拉最新 $BRANCH;有缓存时只重编改动的部分)"
+docker compose -f "$COMPOSE_FILE" up -d --build
+
+echo '==> 等待服务就绪…'
+for _ in $(seq 1 30); do
+  if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
+    echo "==> 升级完成"
+    echo "    当前版本:$(curl -fsS "$HEALTH_URL" 2>/dev/null)"
+    if [ -n "$previous_image" ]; then
+      echo "    回滚:HAVLINE_VERSION=${previous_image#havline:} docker compose -f $COMPOSE_FILE up -d"
+    fi
+    echo "    日志:docker logs --tail 50 $APP_CONTAINER"
+    exit 0
+  fi
+  sleep 2
+done
+
+echo "服务在 60 秒内没有响应 $HEALTH_URL,请看:docker logs --tail 100 $APP_CONTAINER" >&2
+if [ -n "$previous_image" ]; then
+  echo "如需回滚:HAVLINE_VERSION=${previous_image#havline:} docker compose -f $COMPOSE_FILE up -d" >&2
+fi
+exit 1
diff --git a/app-src/scripts/upgrade.sh b/app-src/scripts/upgrade.sh
new file mode 100644
index 0000000..c905a7c
--- /dev/null
+++ b/app-src/scripts/upgrade.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# Havline 容器升级:在部署机(NAS)上直接构建,不需要再从别处传镜像包
+#
+#   sudo bash scripts/upgrade.sh                     # 拉取最新源码 → 构建 → 重建容器
+#   sudo bash scripts/upgrade.sh --no-pull           # 不拉代码,用当前源码重建
+#   sudo bash scripts/upgrade.sh --rollback 1.2.13   # 回滚到已存在的镜像 tag(不构建)
+#
+# 用 COMPOSE_FILE 指定编排文件(默认 docker-compose.yml):
+#   COMPOSE_FILE=docker-compose.host.yml sudo bash scripts/upgrade.sh
+#
+# 数据在 havline-data 卷里,重建容器不会丢。每次构建按仓库 VERSION 打一个新 tag
+# (旧的 tag 会留着),所以出问题可以直接 --rollback 回上一个版本。
+set -euo pipefail
+
+COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
+APP_CONTAINER="${APP_CONTAINER:-havline}"
+HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6893/api/version}"
+NO_PULL=0
+ROLLBACK_TAG=""
+
+usage() {
+  sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
+  exit "${1:-0}"
+}
+
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --no-pull) NO_PULL=1; shift ;;
+    --rollback) ROLLBACK_TAG="${2:?--rollback 需要一个镜像 tag,例如 1.2.13}"; shift 2 ;;
+    -h|--help) usage 0 ;;
+    *) echo "未知参数:$1" >&2; usage 1 ;;
+  esac
+done
+
+if ! command -v docker >/dev/null 2>&1; then
+  echo "没有找到 docker 命令:这个脚本要在运行 Havline 容器的那台机器上执行" >&2
+  exit 1
+fi
+if [ ! -f "$COMPOSE_FILE" ]; then
+  echo "找不到 $COMPOSE_FILE:请在仓库根目录运行(或用 COMPOSE_FILE=... 指定编排文件)" >&2
+  exit 1
+fi
+
+# 当前容器实际使用的镜像 tag,升级前后各打印一次,回滚时心里有数
+current_image() {
+  docker inspect --format '{{.Config.Image}}' "$APP_CONTAINER" 2>/dev/null || echo '(容器未运行)'
+}
+
+echo "==> 升级前:$(current_image)"
+
+if [ -n "$ROLLBACK_TAG" ]; then
+  if ! docker image inspect "havline:$ROLLBACK_TAG" >/dev/null 2>&1; then
+    echo "本机没有 havline:$ROLLBACK_TAG;已有 tag:$(docker images --format '{{.Tag}}' havline | tr '\n' ' ')" >&2
+    exit 1
+  fi
+  HAVLINE_VERSION="$ROLLBACK_TAG" docker compose -f "$COMPOSE_FILE" up -d
+  echo "==> 已切回 havline:$ROLLBACK_TAG"
+else
+  if [ "$NO_PULL" -eq 0 ]; then
+    if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+      echo '==> git pull --ff-only'
+      git pull --ff-only
+    else
+      echo '==> 当前目录不是 git 仓库,跳过拉取(请先自行更新源码)'
+    fi
+  fi
+  if [ ! -f VERSION ]; then
+    echo '找不到 VERSION 文件:请在仓库根目录运行' >&2
+    exit 1
+  fi
+  HAVLINE_VERSION="$(tr -d ' \t\n\r' < VERSION)"
+  export HAVLINE_VERSION
+  echo "==> 构建并重建:havline:$HAVLINE_VERSION(首次构建较慢,之后有构建缓存)"
+  docker compose -f "$COMPOSE_FILE" up -d --build
+fi
+
+echo '==> 等待服务就绪…'
+for _ in $(seq 1 30); do
+  if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
+    echo "==> 升级后:$(current_image)"
+    echo "    版本接口:$(curl -fsS "$HEALTH_URL" 2>/dev/null)"
+    echo "    日志:docker logs --tail 50 $APP_CONTAINER"
+    exit 0
+  fi
+  sleep 2
+done
+
+echo "服务在 60 秒内没有响应 $HEALTH_URL,请看:docker logs --tail 100 $APP_CONTAINER" >&2
+exit 1
diff --git a/app-src/web/assets/image/429.png b/app-src/web/assets/image/429.png
new file mode 100644
index 0000000..ab1071c
Binary files /dev/null and b/app-src/web/assets/image/429.png differ
diff --git a/app-src/web/assets/image/aliyun.png b/app-src/web/assets/image/aliyun.png
new file mode 100644
index 0000000..a32460f
Binary files /dev/null and b/app-src/web/assets/image/aliyun.png differ
diff --git a/app-src/web/assets/image/banner.png b/app-src/web/assets/image/banner.png
new file mode 100644
index 0000000..9f745c4
Binary files /dev/null and b/app-src/web/assets/image/banner.png differ
diff --git a/app-src/web/assets/image/cloudflare.png b/app-src/web/assets/image/cloudflare.png
new file mode 100644
index 0000000..252ef9c
Binary files /dev/null and b/app-src/web/assets/image/cloudflare.png differ
diff --git a/app-src/web/assets/image/dnspod.png b/app-src/web/assets/image/dnspod.png
new file mode 100644
index 0000000..c06f2cd
Binary files /dev/null and b/app-src/web/assets/image/dnspod.png differ
diff --git a/app-src/web/assets/image/error.png b/app-src/web/assets/image/error.png
new file mode 100644
index 0000000..f68b754
Binary files /dev/null and b/app-src/web/assets/image/error.png differ
diff --git a/app-src/web/assets/image/havline-logo.svg b/app-src/web/assets/image/havline-logo.svg
new file mode 100644
index 0000000..81645c5
--- /dev/null
+++ b/app-src/web/assets/image/havline-logo.svg
@@ -0,0 +1,21 @@
+
+  
+    
+      
+      
+    
+    
+      
+      
+    
+    
+      
+      
+    
+  
+  
+  
+  
+  
+  
+
diff --git a/app-src/web/assets/image/issue.png b/app-src/web/assets/image/issue.png
new file mode 100644
index 0000000..4f77555
Binary files /dev/null and b/app-src/web/assets/image/issue.png differ
diff --git a/app-src/web/assets/image/logo.png b/app-src/web/assets/image/logo.png
new file mode 100644
index 0000000..45a4760
Binary files /dev/null and b/app-src/web/assets/image/logo.png differ
diff --git a/app-src/web/assets/image/tencentcloud.png b/app-src/web/assets/image/tencentcloud.png
new file mode 100644
index 0000000..e00336f
Binary files /dev/null and b/app-src/web/assets/image/tencentcloud.png differ
diff --git a/app-src/web/index.html b/app-src/web/index.html
new file mode 100644
index 0000000..283f5c4
--- /dev/null
+++ b/app-src/web/index.html
@@ -0,0 +1,13 @@
+
+
+  
+    
+    
+    
+    Havline
+  
+  
+    
+ + + diff --git a/app-src/web/package-lock.json b/app-src/web/package-lock.json new file mode 100644 index 0000000..4293508 --- /dev/null +++ b/app-src/web/package-lock.json @@ -0,0 +1,2355 @@ +{ + "name": "havline-web", + "version": "1.9.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "havline-web", + "version": "1.9.0", + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.12", + "@vicons/ionicons5": "^0.13.0", + "codemirror": "^6.0.2", + "echarts": "^6.1.0", + "naive-ui": "^2.40.1", + "sortablejs": "^1.15.7", + "vue": "^3.5.13", + "vue-codemirror": "^6.1.1", + "vue-echarts": "^8.3.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/sortablejs": "^1.15.9", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vitest": "^5.0.1", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.4", + "resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz", + "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.5", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.7.5.tgz", + "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.12", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.12.tgz", + "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vicons/ionicons5": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz", + "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/naive-ui": { + "version": "2.45.3", + "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.45.3.tgz", + "integrity": "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.66" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-codemirror": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz", + "integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==", + "license": "MIT", + "dependencies": { + "@codemirror/commands": "6.x", + "@codemirror/language": "6.x", + "@codemirror/state": "6.x", + "@codemirror/view": "6.x" + }, + "peerDependencies": { + "codemirror": "6.x", + "vue": "3.x" + } + }, + "node_modules/vue-echarts": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.3.0.tgz", + "integrity": "sha512-gqOZ5uuBF4x4KTXQpTmgH5BuAwUSO+y9U7L1pHCIESQvgnTitXtnyjrfePH+9YXMRhmTLjchEx3EnsPhoiIX/A==", + "license": "MIT", + "peerDependencies": { + "echarts": "^6.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vueuc": { + "version": "0.4.66", + "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.66.tgz", + "integrity": "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/app-src/web/package.json b/app-src/web/package.json new file mode 100644 index 0000000..d30092b --- /dev/null +++ b/app-src/web/package.json @@ -0,0 +1,40 @@ +{ + "name": "havline-web", + "private": true, + "version": "1.9.0", + "packageManager": "npm@10.9.4", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.4", + "@codemirror/state": "^6.7.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.12", + "@vicons/ionicons5": "^0.13.0", + "codemirror": "^6.0.2", + "echarts": "^6.1.0", + "naive-ui": "^2.40.1", + "sortablejs": "^1.15.7", + "vue": "^3.5.13", + "vue-codemirror": "^6.1.1", + "vue-echarts": "^8.3.0", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/sortablejs": "^1.15.9", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vitest": "^5.0.1", + "vue-tsc": "^2.2.0" + } +} diff --git a/app-src/web/pnpm-lock.yaml b/app-src/web/pnpm-lock.yaml new file mode 100644 index 0000000..c83fcb9 --- /dev/null +++ b/app-src/web/pnpm-lock.yaml @@ -0,0 +1,1423 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codemirror/commands': + specifier: ^6.11.1 + version: 6.11.1 + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/language': + specifier: ^6.12.4 + version: 6.12.4 + '@codemirror/legacy-modes': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/state': + specifier: ^6.7.5 + version: 6.7.5 + '@codemirror/theme-one-dark': + specifier: ^6.1.3 + version: 6.1.3 + '@codemirror/view': + specifier: ^6.43.12 + version: 6.43.12 + '@vicons/ionicons5': + specifier: ^0.13.0 + version: 0.13.0 + codemirror: + specifier: ^6.0.2 + version: 6.0.2 + echarts: + specifier: ^6.1.0 + version: 6.1.0 + naive-ui: + specifier: ^2.40.1 + version: 2.45.3(vue@3.5.43(typescript@5.9.3)) + sortablejs: + specifier: ^1.15.7 + version: 1.15.7 + vue: + specifier: ^3.5.13 + version: 3.5.43(typescript@5.9.3) + vue-codemirror: + specifier: ^6.1.1 + version: 6.1.1(codemirror@6.0.2)(vue@3.5.43(typescript@5.9.3)) + vue-echarts: + specifier: ^8.3.0 + version: 8.3.0(echarts@6.1.0)(vue@3.5.43(typescript@5.9.3)) + vue-router: + specifier: ^4.5.0 + version: 4.6.4(vue@3.5.43(typescript@5.9.3)) + devDependencies: + '@types/sortablejs': + specifier: ^1.15.9 + version: 1.15.9 + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@6.4.3)(vue@3.5.43(typescript@5.9.3)) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.0.3 + version: 6.4.3 + vue-tsc: + specifier: ^2.2.0 + version: 2.2.12(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/legacy-modes@6.5.4': + resolution: {integrity: sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.2': + resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} + + '@codemirror/state@6.7.5': + resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.12': + resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} + + '@css-render/plugin-bem@0.15.14': + resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} + peerDependencies: + css-render: ~0.15.14 + + '@css-render/vue3-ssr@0.15.14': + resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==} + peerDependencies: + vue: ^3.0.11 + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.3': + resolution: {integrity: sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.3': + resolution: {integrity: sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.3': + resolution: {integrity: sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.3': + resolution: {integrity: sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.3': + resolution: {integrity: sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.3': + resolution: {integrity: sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.3': + resolution: {integrity: sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.3': + resolution: {integrity: sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.3': + resolution: {integrity: sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.3': + resolution: {integrity: sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.3': + resolution: {integrity: sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.3': + resolution: {integrity: sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.3': + resolution: {integrity: sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.3': + resolution: {integrity: sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.3': + resolution: {integrity: sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.3': + resolution: {integrity: sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.3': + resolution: {integrity: sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.3': + resolution: {integrity: sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.3': + resolution: {integrity: sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.3': + resolution: {integrity: sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.3': + resolution: {integrity: sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.3': + resolution: {integrity: sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.3': + resolution: {integrity: sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.3': + resolution: {integrity: sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.3': + resolution: {integrity: sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + + '@vicons/ionicons5@0.13.0': + resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + css-render@0.15.14: + resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} + + csstype@3.0.11: + resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + evtd@0.2.4: + resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + engines: {node: '>=12.0.0'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + naive-ui@2.45.3: + resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==} + engines: {node: '>=20'} + peerDependencies: + vue: ^3.0.0 + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.63.3: + resolution: {integrity: sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + seemly@0.3.10: + resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} + + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + treemate@0.3.11: + resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vdirs@0.1.8: + resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} + peerDependencies: + vue: ^3.0.11 + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vooks@0.2.12: + resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} + peerDependencies: + vue: ^3.0.0 + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-codemirror@6.1.1: + resolution: {integrity: sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==} + peerDependencies: + codemirror: 6.x + vue: 3.x + + vue-echarts@8.3.0: + resolution: {integrity: sha512-gqOZ5uuBF4x4KTXQpTmgH5BuAwUSO+y9U7L1pHCIESQvgnTitXtnyjrfePH+9YXMRhmTLjchEx3EnsPhoiIX/A==} + peerDependencies: + echarts: ^6.0.0 + vue: ^3.3.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vueuc@0.4.66: + resolution: {integrity: sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==} + peerDependencies: + vue: ^3.0.11 + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.4': + dependencies: + '@codemirror/language': 6.12.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + crelt: 1.0.7 + + '@codemirror/search@6.7.2': + dependencies: + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + crelt: 1.0.7 + + '@codemirror/state@6.7.5': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.12': + dependencies: + '@codemirror/state': 6.7.5 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': + dependencies: + css-render: 0.15.14 + + '@css-render/vue3-ssr@0.15.14(vue@3.5.43(typescript@5.9.3))': + dependencies: + vue: 3.5.43(typescript@5.9.3) + + '@emotion/hash@0.8.0': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@juggle/resize-observer@3.4.0': {} + + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.4': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.3': + optional: true + + '@rollup/rollup-android-arm64@4.63.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.3': + optional: true + + '@rollup/rollup-darwin-x64@4.63.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.3': + optional: true + + '@types/estree@1.0.9': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/sortablejs@1.15.9': {} + + '@vicons/ionicons5@0.13.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@6.4.3)(vue@3.5.43(typescript@5.9.3))': + dependencies: + vite: 6.4.3 + vue: 3.5.43(typescript@5.9.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.43 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + alien-signals@1.0.13: {} + + async-validator@4.2.5: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.11.1 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.2 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + + crelt@1.0.7: {} + + css-render@0.15.14: + dependencies: + '@emotion/hash': 0.8.0 + csstype: 3.0.11 + + csstype@3.0.11: {} + + csstype@3.2.3: {} + + date-fns-tz@3.2.0(date-fns@4.4.0): + dependencies: + date-fns: 4.4.0 + + date-fns@4.4.0: {} + + de-indent@1.0.2: {} + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + + entities@7.0.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + estree-walker@2.0.2: {} + + evtd@0.2.4: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + he@1.2.0: {} + + highlight.js@11.12.0: {} + + lodash-es@4.18.1: {} + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + muggle-string@0.4.1: {} + + naive-ui@2.45.3(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) + '@css-render/vue3-ssr': 0.15.14(vue@3.5.43(typescript@5.9.3)) + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + async-validator: 4.2.5 + css-render: 0.15.14 + csstype: 3.2.3 + date-fns: 4.4.0 + date-fns-tz: 3.2.0(date-fns@4.4.0) + evtd: 0.2.4 + highlight.js: 11.12.0 + lodash: 4.18.1 + lodash-es: 4.18.1 + seemly: 0.3.10 + treemate: 0.3.11 + vdirs: 0.1.8(vue@3.5.43(typescript@5.9.3)) + vooks: 0.2.12(vue@3.5.43(typescript@5.9.3)) + vue: 3.5.43(typescript@5.9.3) + vueuc: 0.4.66(vue@3.5.43(typescript@5.9.3)) + + nanoid@3.3.19: {} + + path-browserify@1.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.63.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.3 + '@rollup/rollup-android-arm64': 4.63.3 + '@rollup/rollup-darwin-arm64': 4.63.3 + '@rollup/rollup-darwin-x64': 4.63.3 + '@rollup/rollup-freebsd-arm64': 4.63.3 + '@rollup/rollup-freebsd-x64': 4.63.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.3 + '@rollup/rollup-linux-arm-musleabihf': 4.63.3 + '@rollup/rollup-linux-arm64-gnu': 4.63.3 + '@rollup/rollup-linux-arm64-musl': 4.63.3 + '@rollup/rollup-linux-loong64-gnu': 4.63.3 + '@rollup/rollup-linux-loong64-musl': 4.63.3 + '@rollup/rollup-linux-ppc64-gnu': 4.63.3 + '@rollup/rollup-linux-ppc64-musl': 4.63.3 + '@rollup/rollup-linux-riscv64-gnu': 4.63.3 + '@rollup/rollup-linux-riscv64-musl': 4.63.3 + '@rollup/rollup-linux-s390x-gnu': 4.63.3 + '@rollup/rollup-linux-x64-gnu': 4.63.3 + '@rollup/rollup-linux-x64-musl': 4.63.3 + '@rollup/rollup-openbsd-x64': 4.63.3 + '@rollup/rollup-openharmony-arm64': 4.63.3 + '@rollup/rollup-win32-arm64-msvc': 4.63.3 + '@rollup/rollup-win32-ia32-msvc': 4.63.3 + '@rollup/rollup-win32-x64-gnu': 4.63.3 + '@rollup/rollup-win32-x64-msvc': 4.63.3 + fsevents: 2.3.3 + + seemly@0.3.10: {} + + sortablejs@1.15.7: {} + + source-map-js@1.2.1: {} + + style-mod@4.1.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + treemate@0.3.11: {} + + tslib@2.3.0: {} + + typescript@5.9.3: {} + + vdirs@0.1.8(vue@3.5.43(typescript@5.9.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.43(typescript@5.9.3) + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vooks@0.2.12(vue@3.5.43(typescript@5.9.3)): + dependencies: + evtd: 0.2.4 + vue: 3.5.43(typescript@5.9.3) + + vscode-uri@3.2.0: {} + + vue-codemirror@6.1.1(codemirror@6.0.2)(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@codemirror/commands': 6.11.1 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.5 + '@codemirror/view': 6.43.12 + codemirror: 6.0.2 + vue: 3.5.43(typescript@5.9.3) + + vue-echarts@8.3.0(echarts@6.1.0)(vue@3.5.43(typescript@5.9.3)): + dependencies: + echarts: 6.1.0 + vue: 3.5.43(typescript@5.9.3) + + vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.43(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15(typescript@5.9.3) + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.43(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.9.3 + + vueuc@0.4.66(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@css-render/vue3-ssr': 0.15.14(vue@3.5.43(typescript@5.9.3)) + '@juggle/resize-observer': 3.4.0 + css-render: 0.15.14 + evtd: 0.2.4 + seemly: 0.3.10 + vdirs: 0.1.8(vue@3.5.43(typescript@5.9.3)) + vooks: 0.2.12(vue@3.5.43(typescript@5.9.3)) + vue: 3.5.43(typescript@5.9.3) + + w3c-keyname@2.2.8: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/app-src/web/public/favicon.png b/app-src/web/public/favicon.png new file mode 100644 index 0000000..54f3ee8 Binary files /dev/null and b/app-src/web/public/favicon.png differ diff --git a/app-src/web/public/favicon.svg b/app-src/web/public/favicon.svg new file mode 100644 index 0000000..81645c5 --- /dev/null +++ b/app-src/web/public/favicon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app-src/web/src/App.vue b/app-src/web/src/App.vue new file mode 100644 index 0000000..80fb565 --- /dev/null +++ b/app-src/web/src/App.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/app-src/web/src/api/base.ts b/app-src/web/src/api/base.ts new file mode 100644 index 0000000..4cef78a --- /dev/null +++ b/app-src/web/src/api/base.ts @@ -0,0 +1,6 @@ +export const APP_BASE = import.meta.env.BASE_URL.replace(/\/$/, '') + +export function withBase(path: string): string { + if (!path.startsWith('/')) return path + return `${APP_BASE}${path}` +} diff --git a/app-src/web/src/api/client.ts b/app-src/web/src/api/client.ts new file mode 100644 index 0000000..517b626 --- /dev/null +++ b/app-src/web/src/api/client.ts @@ -0,0 +1,559 @@ +import type { + AccessLogEntry, + ApiError, + AppVersion, + AuthStatus, + CertificateCAOption, + CertificateRecord, + DashboardStatus, + DDNSConfig, + DDNSSavePayload, + DDNSTestPayload, + FRPResponse, + FRPSavePayload, + FRPSaveResponse, + FRPStatus, + FrpBinary, + FrpDownloadTask, + FrpProxy, + FrpProxyPayload, + FrpProxiesTraffic, + FrpRelease, + FrpRuntimeInfo, + FrpServer, + FrpServerDiagnostics, + FrpServerDetail, + FrpServerPayload, + FrpStatus, + DiscoveredService, + ProxyClientConn, + ChinaCIDRStatus, + ProxyRule, + ProxySavePayload, + ProxyTraffic, + GlobalNginxView, + RuleNginxView, + SettingsMap, + SystemLogEntry, + NotifyTestPayload, + UpdateStatus, + CfMirror, + CfRuntimeStatus, + CfTunnel, + CfTunnelPayload, + CfAppSettings, + CfAppSettingsInput, + CfDriftResult, + CfDiagnosticsResult, + CfAccessStatus, + CfZone, + CfTunnelRoutes, + CfPreflightResult, + CfRouteTestResult, +} from './types' +import { withBase } from './base' + +export function asList(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} + +const REQUEST_TIMEOUT_MS = 30_000 +const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000 + +async function request(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await requestWithSignal(path, init, controller.signal) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error( + timeoutMs > REQUEST_TIMEOUT_MS + ? '证书操作超时,请稍后在证书列表查看状态,或在「日志 → 实时日志」查看 Nginx 输出' + : '请求超时,请检查 Havline 服务或 Nginx 状态', + ) + } + throw error + } finally { + clearTimeout(timeout) + } +} + +async function requestWithSignal(path: string, init?: RequestInit, signal?: AbortSignal): Promise { + const response = await fetch(withBase(path), { + credentials: 'include', + signal, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + ...init, + }) + + if (!response.ok) { + let message = '请求失败' + try { + const body = (await response.json()) as ApiError + if (body.error) { + message = body.error + } + } catch { + // ignore + } + throw new Error(message) + } + + if (response.status === 204) { + return undefined as T + } + return (await response.json()) as T +} + +export const api = { + getVersion: () => request('/api/version'), + getUpdateStatus: () => request('/api/system/update/status'), + applyUpdate: () => request('/api/system/update/apply', { method: 'POST' }), + authStatus: () => request('/api/auth/status'), + login: (username: string, password: string) => + request<{ message: string }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }), + logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }), + changePassword: (old_password: string, new_password: string) => + request<{ message: string }>('/api/auth/password', { + method: 'POST', + body: JSON.stringify({ old_password, new_password }), + }), + + getStatus: () => request('/api/status'), + listProxies: () => request('/api/proxies'), + createProxy: (payload: ProxySavePayload) => + request('/api/proxies', { method: 'POST', body: JSON.stringify(payload) }), + updateProxy: (id: number, payload: Partial) => + request(`/api/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteProxy: (id: number) => request(`/api/proxies/${id}`, { method: 'DELETE' }), + reorderProxies: (ids: number[]) => + request('/api/proxies/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }), + getProxyTraffic: () => request('/api/proxies/traffic'), + getProxyClients: (id: number) => request(`/api/proxies/${id}/clients`), + getProxyNginx: (id: number) => request(`/api/proxies/${id}/nginx`), + saveProxyNginx: (id: number, payload: { mode: 'auto' | 'custom'; content?: string }) => + request(`/api/proxies/${id}/nginx`, { method: 'PUT', body: JSON.stringify(payload) }), + rollbackProxyNginx: (id: number, backup?: string) => + request(`/api/proxies/${id}/nginx/rollback`, { + method: 'POST', + body: JSON.stringify(backup ? { backup } : {}), + }), + + listDDNS: () => request('/api/ddns'), + listDDNSLite: () => request('/api/ddns?lite=1'), + createDDNS: (payload: DDNSSavePayload) => + request('/api/ddns', { method: 'POST', body: JSON.stringify(payload) }), + updateDDNS: (id: number, payload: DDNSSavePayload) => + request(`/api/ddns/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteDDNS: (id: number) => request(`/api/ddns/${id}`, { method: 'DELETE' }), + testDDNS: (payload: DDNSTestPayload) => + request<{ message: string }>('/api/ddns/test', { + method: 'POST', + body: JSON.stringify(payload), + }), + updateAllDDNS: () => request('/api/ddns/update', { method: 'POST' }), + updateDDNSOne: (id: number) => request(`/api/ddns/${id}/update`, { method: 'POST' }), + + listCertificates: () => request('/api/certificates'), + listCertificateCAOptions: () => request('/api/certificates/ca-options'), + applyCertificate: (payload: { + dns_zone?: string + ddns_config_id?: number + domains: string[] + ca?: string + email?: string + }) => + request<{ job_id: string }>('/api/certificates/apply', { + method: 'POST', + body: JSON.stringify({ + dns_zone: payload.dns_zone ?? '', + ddns_config_id: payload.ddns_config_id ?? 0, + domains: payload.domains, + ca: payload.ca ?? '', + email: payload.email ?? '', + }), + }), + certificateApplyStreamURL: (jobId: string) => withBase(`/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`), + importCertificate: (payload: { + certificate?: string + private_key?: string + cert_path?: string + key_path?: string + }) => + request('/api/certificates/import', { + method: 'POST', + body: JSON.stringify(payload), + }), + async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') { + const response = await fetch( + withBase(`/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`), + { credentials: 'include' }, + ) + if (!response.ok) { + let message = '下载证书失败' + try { + const body = (await response.json()) as ApiError + if (body.error) message = body.error + } catch { + // ignore + } + throw new Error(message) + } + const blob = await response.blob() + const disposition = response.headers.get('Content-Disposition') ?? '' + const match = disposition.match(/filename="?([^";\n]+)"?/) + const filename = match?.[1] ?? `${domain}.zip` + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) + }, + deleteCertificate: (domain: string) => + request<{ message: string }>(`/api/certificates/${encodeURIComponent(domain)}`, { method: 'DELETE' }), + renewCertificate: (domain?: string, ca?: string) => + request( + '/api/certificates/renew', + { + method: 'POST', + body: JSON.stringify({ domain: domain ?? '', ca: ca ?? '' }), + }, + CERT_REQUEST_TIMEOUT_MS, + ), + + getSettings: () => request('/api/settings'), + saveSettings: (payload: SettingsMap) => + request('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }), + getGlobalNginx: () => request('/api/settings/nginx/global'), + saveGlobalNginx: (payload: { mode: 'auto' | 'custom'; content?: string }) => + request('/api/settings/nginx/global', { method: 'PUT', body: JSON.stringify(payload) }), + rollbackGlobalNginx: (backup?: string) => + request('/api/settings/nginx/global/rollback', { + method: 'POST', + body: JSON.stringify(backup ? { backup } : {}), + }), + getNginxConfigVersions: () => + request('/api/settings/nginx/config/versions'), + getNginxConfigVersion: (name: string) => + request(`/api/settings/nginx/config/versions/${encodeURIComponent(name)}`), + rollbackNginxConfigVersion: (name: string) => + request( + `/api/settings/nginx/config/versions/${encodeURIComponent(name)}/rollback`, + { method: 'POST' }, + ), + getChinaCIDRStatus: () => request('/api/settings/china-cidr'), + listApiTokens: () => request<{ tokens: import('./types').ApiToken[] }>('/api/settings/tokens'), + createApiToken: (payload: { name: string; scope: 'read' | 'write'; expires_in_days: number }) => + request('/api/settings/tokens', { + method: 'POST', + body: JSON.stringify(payload), + }), + deleteApiToken: (id: number) => + request<{ ok: boolean }>(`/api/settings/tokens/${id}`, { method: 'DELETE' }), + refreshChinaCIDR: () => + request('/api/settings/china-cidr/refresh', { method: 'POST' }), + testNotify: (payload: NotifyTestPayload) => + request<{ message: string }>('/api/settings/notify/test', { + method: 'POST', + body: JSON.stringify(payload), + }), + + getFRP: () => request('/api/frp'), + saveFRP: (payload: FRPSavePayload) => + request('/api/frp', { + method: 'PUT', + body: JSON.stringify(payload), + }), + getFRPStatus: () => request('/api/frp/status'), + syncFRPDomains: () => + request('/api/frp/sync-domains', { + method: 'POST', + }), + getFRPLogs: (limit = 200) => request(`/api/frp/logs?limit=${limit}`), + + // ===== FRP 多服务端模块(/api/frpmulti)===== + getFrpStatus: () => request('/api/frpmulti/status'), + getFrpRuntime: () => request('/api/frpmulti/runtime'), + setFrpDownloadProxy: (proxy: string) => request('/api/frpmulti/runtime/download-proxy', { method: 'PUT', body: JSON.stringify({ proxy }) }), + listFrpReleases: () => request('/api/frpmulti/releases'), + listFrpBinaries: () => request('/api/frpmulti/binaries'), + getFrpDownloadStatus: () => request('/api/frpmulti/download'), + downloadFrpRelease: (version: string) => request(`/api/frpmulti/releases/${encodeURIComponent(version)}/download`, { method: 'POST' }), + activateFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}/activate`, { method: 'POST' }), + deleteFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}`, { method: 'DELETE' }), + listFrpServers: () => request('/api/frpmulti/servers'), + getFrpServerDiagnostics: (id: number) => request(`/api/frpmulti/servers/${id}/diagnostics`), + getFrpServerDetail: (id: number) => request(`/api/frpmulti/servers/${id}/detail`), + startFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/start`, { method: 'POST' }), + stopFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/stop`, { method: 'POST' }), + createFrpServer: (payload: FrpServerPayload) => + request('/api/frpmulti/servers', { method: 'POST', body: JSON.stringify(payload) }), + updateFrpServer: (id: number, payload: FrpServerPayload) => + request(`/api/frpmulti/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}`, { method: 'DELETE' }), + testFrpServer: (id: number) => request<{ message: string }>(`/api/frpmulti/servers/${id}/test`, { method: 'POST' }), + listFrpProxies: () => request('/api/frpmulti/proxies'), + getFrpProxyLogs: (id: number, limit = 200) => request(`/api/frpmulti/proxies/${id}/logs?limit=${limit}`), + listFrpProxiesTraffic: () => request('/api/frpmulti/proxies/traffic'), + createFrpProxy: (payload: FrpProxyPayload) => + request('/api/frpmulti/proxies', { method: 'POST', body: JSON.stringify(payload) }), + updateFrpProxy: (id: number, payload: FrpProxyPayload) => + request(`/api/frpmulti/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteFrpProxy: (id: number) => request(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }), + startFrp: () => request('/api/frpmulti/start', { method: 'POST' }), + stopFrp: () => request('/api/frpmulti/stop', { method: 'POST' }), + reloadFrp: () => request('/api/frpmulti/reload', { method: 'POST' }), + getFrpLogs: (limit = 200, serverID?: number) => { + const query = new URLSearchParams({ limit: String(limit) }) + if (serverID) query.set('server_id', String(serverID)) + return request(`/api/frpmulti/logs?${query.toString()}`) + }, + exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'), + getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`), + testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }), + restartFrpAgentTunnel: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/tunnel/restart`, { method: 'POST' }), + setFrpAgentTransport: (id: number, transport: 'tunnel' | 'https-pin', port = 0) => + request(`/api/frpmulti/servers/${id}/agent/transport`, { + method: 'POST', + body: JSON.stringify({ transport, port }), + }), + getFrpAgentTransport: (id: number) => + request>(`/api/frpmulti/servers/${id}/agent/transport`), + rotateFrpAgentTLSCert: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/transport/rotate`, { method: 'POST' }), + getFrpAgentFirewall: (id: number, port: number) => + request>(`/api/frpmulti/servers/${id}/agent/firewall?port=${port}`), + allowFrpAgentFirewallPort: (id: number, port: number) => + request>(`/api/frpmulti/servers/${id}/agent/firewall/allow`, { + method: 'POST', + body: JSON.stringify({ port }), + }), + probeFrpAgent: (id: number) => request>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }), + installFrpAgent: (id: number) => request(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }), + uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }), + sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }), + getFrpAgentStatus: (id: number) => request(`/api/frpmulti/servers/${id}/agent/status`), + getFrpAgentCerts: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/certs`), + reloadFrpAgentNginx: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/nginx/reload`, { + method: 'POST', + }), + getFrpAgentMetrics: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/metrics`), + getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) => + request( + `/api/frpmulti/servers/${id}/agent/logs/nginx?type=${type}&lines=${lines}`, + ), + getFrpAgentFRPSConfig: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/frps-config`), + getFrpAgentRouteDrift: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/routes/drift`), + redeployFrpAgentRoutes: (id: number) => + request(`/api/frpmulti/servers/${id}/agent/routes/redeploy`, { + method: 'POST', + }), + getFrpAgentRoutes: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes`), + getFrpAgentRouteCandidates: (id: number) => request(`/api/frpmulti/servers/${id}/agent/route-candidates`), + deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean; basic_auth_user?: string; basic_auth_password?: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }), + getFrpAgentRouteConf: (serverId: number, proxyId: number) => + request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`), + getFrpAgentRouteVersions: (serverId: number, proxyId: number) => + request(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions`), + getFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) => + request( + `/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}`, + ), + rollbackFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) => + request<{ ok: boolean; domain: string; name: string }>( + `/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}/rollback`, + { method: 'POST' }, + ), + installFrpAgentNginx: (serverId: number) => + request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }), + getFrpAgentRouteHealth: (serverId: number) => + request(`/api/frpmulti/servers/${serverId}/agent/routes/health`), + getFrpRouteHealthSummary: () => + request('/api/frpmulti/routes/summary'), + getFrpRouteUptime: (hours = 24) => + request<{ hours: number; items: import('./types').FrpRouteUptime[] }>(`/api/frpmulti/routes/uptime?hours=${hours}`), + + // 指标历史:窗口内的序列已按桶取平均,拿到就能直接画 + getMetricsHistory: (hours = 24, buckets = 48) => + request(`/api/metrics/history?hours=${hours}&buckets=${buckets}`), + + // 匿名状态页:未开启或访问码不对时后端回 404 + getStatusPage: (code?: string) => + request( + `/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`, + ), + removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }), + pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }), + frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') => + request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }), + frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') => + request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }), + installFrpServer: (id: number, version: string, proxy: string) => + request>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }), + + getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => { + const q = new URLSearchParams() + if (params?.limit) q.set('limit', String(params.limit)) + if (params?.keyword) q.set('keyword', params.keyword) + if (params?.status) q.set('status', String(params.status)) + return request(`/api/logs/access?${q}`) + }, + getErrorLogs: (params?: { limit?: number; keyword?: string }) => { + const q = new URLSearchParams() + if (params?.limit) q.set('limit', String(params.limit)) + if (params?.keyword) q.set('keyword', params.keyword) + return request(`/api/logs/error?${q}`) + }, + getSystemLogs: (params?: { limit?: number; level?: string; keyword?: string }) => { + const q = new URLSearchParams() + if (params?.limit) q.set('limit', String(params.limit)) + if (params?.level) q.set('level', params.level) + if (params?.keyword) q.set('keyword', params.keyword) + return request(`/api/logs/system?${q}`) + }, + + scanDiscovery: (host?: string) => { + const q = new URLSearchParams() + if (host) q.set('host', host) + return request(`/api/discovery/scan?${q}`) + }, + + async exportBackup() { + const response = await fetch(withBase('/api/backup/export'), { credentials: 'include' }) + if (!response.ok) { + throw new Error('导出备份失败') + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `havline-backup-${new Date().toISOString().slice(0, 10)}.tar.gz` + link.click() + URL.revokeObjectURL(url) + }, + + listBackupArchives: () => + request<{ archives: import('./types').BackupArchive[]; keep: number }>('/api/backup/archives'), + createBackupArchive: () => + request<{ name: string; removed: number }>('/api/backup/archives', { method: 'POST' }), + restoreBackupArchive: (name: string) => + request<{ message: string }>(`/api/backup/archives/${encodeURIComponent(name)}/restore`, { method: 'POST' }), + deleteBackupArchive: (name: string) => + request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }), + async downloadBackupArchive(name: string) { + const response = await fetch(withBase(`/api/backup/archives/${encodeURIComponent(name)}`), { credentials: 'include' }) + if (!response.ok) { + throw new Error('下载备份失败') + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = name + link.click() + URL.revokeObjectURL(url) + }, + + async restoreBackup(file: File) { + const form = new FormData() + form.append('file', file) + const response = await fetch(withBase('/api/backup/restore'), { + method: 'POST', + credentials: 'include', + body: form, + }) + if (!response.ok) { + let message = '恢复备份失败' + try { + const body = (await response.json()) as ApiError + if (body.error) message = body.error + } catch { + // ignore + } + throw new Error(message) + } + return (await response.json()) as { message: string } + }, + + listCfTunnels: () => request('/api/cloudflare/tunnels'), + getCfTunnel: (id: number) => request(`/api/cloudflare/tunnels/${id}`), + createCfTunnel: (payload: CfTunnelPayload) => + request('/api/cloudflare/tunnels', { method: 'POST', body: JSON.stringify(payload) }), + updateCfTunnel: (id: number, payload: CfTunnelPayload) => + request(`/api/cloudflare/tunnels/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteCfTunnel: (id: number) => + request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}`, { method: 'DELETE' }), + startCfTunnel: (id: number) => + request(`/api/cloudflare/tunnels/${id}/start`, { method: 'POST' }), + stopCfTunnel: (id: number) => + request(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }), + restartCfTunnel: (id: number) => + request(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }), + refreshCfCredentials: (id: number) => + request(`/api/cloudflare/tunnels/${id}/credentials/refresh`, { method: 'POST' }), + getCfTunnelStatus: (id: number) => + request(`/api/cloudflare/tunnels/${id}/status`), + getCfTunnelLogs: (id: number, lines = 200) => + request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`), + getCfTunnelConfig: (id: number) => + request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`), + getCfTunnelRoutes: (id: number) => + request(`/api/cloudflare/tunnels/${id}/routes`), + replaceCfTunnelRoutes: (id: number, routes: Array<{ hostname: string; path: string; service: string }>) => + request(`/api/cloudflare/tunnels/${id}/routes`, { + method: 'PUT', + body: JSON.stringify({ routes }), + }), + getCfTunnelDrift: (id: number) => request(`/api/cloudflare/tunnels/${id}/drift`), + syncCfTunnelRoutes: (id: number) => + request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}/sync`, { method: 'POST' }), + getCfTunnelDiagnostics: (id: number) => + request(`/api/cloudflare/tunnels/${id}/diagnostics`), + getCfBinary: () => request<{ version: string; mirrors: CfMirror[] }>('/api/cloudflare/binary'), + downloadCfBinary: (mirror: string, version = '') => + request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', { + method: 'POST', + body: JSON.stringify({ mirror, version }), + }), + getLatestCfBinary: () => request<{ version: string }>('/api/cloudflare/binary/latest'), + rollbackCfBinary: () => + request<{ ok: boolean; version: string }>('/api/cloudflare/binary/rollback', { method: 'POST' }), + getCfIngressCandidates: () => + request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'), + getCfZones: () => request<{ zones: CfZone[] }>('/api/cloudflare/zones'), + getCfAccessStatus: (hostname: string) => + request(`/api/cloudflare/access-status?hostname=${encodeURIComponent(hostname)}`), + getCfPreflight: () => request('/api/cloudflare/preflight'), + testCfRoute: (service: string) => + request('/api/cloudflare/routes/test', { + method: 'POST', + body: JSON.stringify({ service }), + }), + getCfSettings: () => request('/api/cloudflare/settings'), + saveCfSettings: (payload: CfAppSettingsInput) => + request('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }), + testCfToken: (accountId: string, token: string, kind: 'tunnel' | 'api') => + request<{ ok: boolean; message: string }>('/api/cloudflare/settings/test', { + method: 'POST', + body: JSON.stringify({ account_id: accountId, token, kind }), + }, 60_000), + setCfEnabled: (enabled: boolean) => + request('/api/cloudflare/enabled', { + method: 'POST', + body: JSON.stringify({ enabled }), + }), +} diff --git a/app-src/web/src/api/types.ts b/app-src/web/src/api/types.ts new file mode 100644 index 0000000..e5cc2b0 --- /dev/null +++ b/app-src/web/src/api/types.ts @@ -0,0 +1,1203 @@ +export interface ProxyHost { + id: number + hostname: string + listen_port?: number | null +} + +export interface BasicAuthConfig { + enabled?: boolean + username?: string + has_password?: boolean +} + +export interface RateLimitConfig { + enabled?: boolean + rate?: number + burst?: number +} + +export interface ConnLimitConfig { + enabled?: boolean + max?: number +} + +export interface ProxySecurityConfig { + ip_blacklist?: string[] + ip_whitelist?: string[] + ip_whitelist_mode?: boolean + china_only?: boolean + basic_auth?: BasicAuthConfig + rate_limit?: RateLimitConfig + conn_limit?: ConnLimitConfig + proxy_ssl_verify_off?: boolean + proxy_host_upstream?: boolean + tls_min_13_only?: boolean + security_headers?: boolean +} + +export interface ProxyRule { + id: number + domain: string + upstream: string + listen_port: number + listen_ipv4: boolean + listen_ipv6: boolean + hosts: ProxyHost[] + https_enabled: boolean + http_redirect: boolean + enabled: boolean + nginx_mode?: string + name: string + sort_order: number + security: ProxySecurityConfig + exits: string[] + cf_tunnel_id: number + sync_warning?: string + created_at: string + updated_at: string +} + +export interface ChinaCIDRStatus { + updated_at?: string + entry_count_v4: number + entry_count_v6: number + last_error?: string + updating?: boolean + ready?: boolean +} + +export interface ProxyTraffic { + rule_id: number + upload_total: number + download_total: number + upload_rate: number + download_rate: number + connections: number +} + +export interface ProxyClientConn { + ip: string + last_seen: string +} + +export interface ProxySecurityPayload { + ip_blacklist?: string[] + ip_whitelist?: string[] + ip_whitelist_mode?: boolean + ip_blacklist_text?: string + ip_whitelist_text?: string + china_only?: boolean + basic_auth?: { + enabled?: boolean + username?: string + password?: string + } + rate_limit?: RateLimitConfig + conn_limit?: ConnLimitConfig + proxy_ssl_verify_off?: boolean + proxy_host_upstream?: boolean + tls_min_13_only?: boolean + security_headers?: boolean +} + +export interface ProxySavePayload { + upstream: string + listen_port?: number + listen_ipv4?: boolean + listen_ipv6?: boolean + hosts: string[] + https_enabled?: boolean + http_redirect?: boolean + enabled?: boolean + name?: string + security?: ProxySecurityPayload + exits?: string[] + cf_tunnel_id?: number +} + +export interface AuthStatus { + initialized: boolean + authenticated: boolean +} + +export interface AppVersion { + version: string + nginx_http_port: number + nginx_https_port: number +} + +export interface UpdateStatus { + enabled: boolean + mode: string + current_version?: string + latest_version?: string + update_available: boolean + busy: boolean + phase: 'idle' | 'scheduled' | 'running' | 'success' | 'failed' | string + message: string + log?: string[] + checked_at?: string +} + +export interface ApiError { + error: string +} + +export interface DashboardStatus { + public_ipv4: string + public_ipv6: string + public_ip_source?: 'ddns' | 'detect' | 'none' + ddns_status: string + ddns_count: number + ddns_last_updated?: string + certificate_status: string + certificate_days: number + certificate_count: number + certificate_summary?: string + proxy_count: number + nginx_status: string + request_today: number + request_yesterday?: number + request_trend?: number | null + requests_hourly: number[] + requests_hourly_labels?: string[] + error_today: number + avg_response_ms: number + started_at: string + uptime_seconds: number + cpu_percent: number + mem_used_bytes: number + mem_total_bytes: number + disk_used_bytes: number + disk_total_bytes: number + cloudflare_count: number + cloudflare_online: number + cloudflare_status: string + cloudflare_message?: string +} + +export interface DDNSDomainRecord { + domain: string + ipv4?: string + ipv6?: string + status: string + message?: string +} + +export interface DDNSConfig { + id: number + provider: string + root_domain: string + record_name: string + record_names?: string[] + ipv4_enabled: boolean + ipv6_enabled: boolean + enabled: boolean + has_token: boolean + last_ipv4?: string + last_ipv6?: string + last_status?: string + last_error?: string + custom_ipv4?: string + custom_ipv6?: string + domain_records?: DDNSDomainRecord[] + last_updated_at?: string +} + +export interface CertificateCAOption { + value: string + label: string +} + +export interface CertificateJobDone { + ok: boolean + error?: string + domain?: string + cert_path?: string + key_path?: string + cert_dir?: string + expires_at?: string +} + +export interface CertificateJobEvent { + type: 'log' | 'done' + level?: string + message?: string + result?: CertificateJobDone +} + +export interface CertificateRecord { + id: number + domain: string + domains?: string[] + wildcard: boolean + acme_ca?: string + status: string + days_left: number + last_error?: string + expires_at?: string + last_renew_at?: string +} + +export interface AccessLogEntry { + time: string + domain: string + server_port?: number + method: string + path: string + status: number + response_time: number + client_ip: string + upstream: string +} + +export interface SystemLogEntry { + time: string + level: string + module: string + message: string +} + +export type NotifyType = '' | 'email' | 'webhook' | 'telegram' +export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'wecom' | 'dingtalk' | 'feishu' | 'custom' + +export interface NotifyEmailConfig { + host: string + port: number + username: string + from: string + to: string[] + tls: boolean + has_password?: boolean +} + +export interface NotifyWebhookConfig { + provider: WebhookProvider + server: string + key: string + topic: string + url: string + has_secret?: boolean +} + +export interface NotifyTelegramConfig { + chat_id: string + proxy_url: string + has_bot_token?: boolean +} + +export interface NotifyTestPayload { + type: NotifyType + email: NotifyEmailConfig + webhook: NotifyWebhookConfig + telegram: NotifyTelegramConfig + on_ddns_ip_change: boolean + on_ddns_failure: boolean + on_cert_expiry: boolean + on_cert_renew_success: boolean + on_cert_renew_failure: boolean + on_ip_frequent_access: boolean + on_login_failure: boolean + on_nginx_reload_failure: boolean + ip_frequent_threshold: number + ip_frequent_window_sec: number + login_failure_threshold: number + login_failure_window_sec: number + smtp_password?: string + webhook_secret?: string + telegram_token?: string +} + +export type SettingsMap = Record + +export interface DiscoveredService { + name: string + port: number + host: string + upstream: string + detected: boolean + platform: string + title?: string + suggestion?: string +} + +export interface DDNSSavePayload { + provider?: string + root_domain?: string + record_name?: string + record_names?: string[] + domains?: string[] + ipv4_enabled?: boolean + ipv6_enabled?: boolean + enabled?: boolean + custom_ipv4?: string + custom_ipv6?: string + api_token?: string + api_token_id?: string + api_secret?: string +} + +export interface DDNSTestPayload { + config_id?: number + provider?: string + api_token?: string + api_token_id?: string + api_secret?: string +} + +export interface FRPStatus { + enabled: boolean + connected: boolean + message: string + last_error?: string + server_endpoint?: string + connected_at?: string + uptime_seconds?: number + client_version?: string + http_gateway_enabled?: boolean + https_gateway_enabled?: boolean + synced_domain_count?: number +} + +export interface FRPConfig { + enabled: boolean + server_addr: string + server_port: number + auth_token: string + has_auth_token: boolean + tls_enabled: boolean + custom_domains: string[] +} + +export interface FRPResponse { + enabled: boolean + server_addr: string + server_port: number + auth_token: string + has_auth_token: boolean + tls_enabled: boolean + custom_domains: string[] + status: FRPStatus + frps_config: string + nginx_http_port: number + nginx_https_port: number +} + +export interface FRPSaveResponse { + message: string + config: FRPConfig + status: FRPStatus + frps_config: string + nginx_http_port: number + nginx_https_port: number +} + +export interface FRPSavePayload { + enabled: boolean + server_addr: string + server_port: number + auth_token?: string + tls_enabled: boolean + custom_domains: string[] +} + +// ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)===== + +export interface FrpServer { + agent_url?: string + agent_transport: 'tunnel' | 'https-pin' | 'http' | string + agent_local_port?: number + agent_tls_pin?: string + agent_tls_pin_prev?: string + agent_tls_pin_prev_until?: string + agent_tls_not_after?: string + agent_firewall_state?: string + agent_listen_addr?: string + agent_transport_state: string + agent_transport_error?: string + agent_configured?: boolean + ssh_configured?: boolean + mgmt_enabled?: boolean + ssh_host?: string + ssh_port?: number + ssh_user?: string + ssh_auth?: string + id: number + name: string + server_addr: string + server_port: number + tls_enabled: boolean + tls_server_name?: string + enabled: boolean + has_token: boolean + has_oidc_client_secret: boolean + dashboard_has_pwd?: boolean + options: FrpServerOptions + boot_status: 'starting' | 'running' | 'stopped' | 'error' + desired_state: 'starting' | 'running' | 'stopped' | 'error' | string + process_state: 'starting' | 'running' | 'stopped' | 'error' | 'unknown' | string + connection_state: 'connected' | 'connecting' | 'disconnected' | 'error' | 'unknown' | string + state_source: string + process_pid?: number + exit_code?: number + last_connected_at?: string + last_disconnected_at?: string + last_error?: string + last_started_at?: string + created_at: string + updated_at: string +} + +export interface FrpServerOptions { + user?: string + location?: string + /** frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 和 Nginx upstream 同源;留空用默认 8080/8443 */ + vhost_http_port?: number + vhost_https_port?: number + /** frps 进阶:子域名根(规则 subdomain 生效前提)*/ + sub_domain_host?: string + /** HTTP vhost 404 页面路径(VPS 本地路径)*/ + custom_404_page?: string + /** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/ + tls_force?: boolean + /** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */ + allow_ports?: string + /** 每客户端可绑定端口上限;0/留空不限 */ + max_ports_per_client?: number + /** tcpmux 规则的 HTTP CONNECT 监听端口 */ + tcpmux_http_connect_port?: number + /** frps 写文件日志 /var/log/frps.log(journalctl 之外)*/ + frps_log_to_file?: boolean + /** 文件日志保留天数,默认 3 */ + frps_log_max_days?: number + /** frps 管理接口(webServer),仅用于 Havline 拉取穿透流量等只读信息 */ + dashboard_addr?: string + dashboard_port?: number + dashboard_user?: string + dashboard_has_pwd?: boolean + auth_method: 'none' | 'token' | 'oidc' + oidc_client_id?: string + oidc_audience?: string + oidc_scope?: string + oidc_token_endpoint_url?: string + oidc_trusted_ca_path?: string + oidc_insecure_skip_verify?: boolean + oidc_proxy_url?: string + log_level: 'trace' | 'debug' | 'info' | 'warn' | 'error' + log_max_days: number + protocol: 'tcp' | 'kcp' | 'quic' | 'websocket' | 'wss' + proxy_url?: string + dial_server_timeout: number + dial_server_keepalive: number + connect_server_local_ip?: string + dns_server?: string + pool_count: number + tcp_mux: boolean + tcp_mux_keepalive_interval: number + heartbeat_interval: number + heartbeat_timeout: number + login_fail_exit: boolean + tls_disable_custom_first_byte: boolean + auto_start: boolean + remark?: string + tls_certificate_configured?: boolean + tls_key_configured?: boolean + tls_trusted_ca_configured?: boolean +} + +export interface FrpProxy { + id: number + server_id: number + name: string + type: 'tcp' | 'udp' | 'http' | 'https' | 'tcpmux' | 'stcp' | 'sudp' | 'xtcp' + local_ip: string + local_port: number + remote_port?: number | null + custom_domains: string[] + host_header_rewrite?: string + options: FrpProxyOptions + enabled: boolean + remark?: string + created_at: string + updated_at: string +} + +export interface FrpProxyOptions { + subdomain?: string + /** WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头) */ + websocket?: boolean + /** 公网反代:HTTP 80 强制 301 跳转 HTTPS(需公网证书已部署)*/ + redirect_https?: boolean + /** 公网反代:IP 白名单(allow/deny),空则不限制 */ + allow_ips?: string[] + /** 公网反代:Basic Auth 用户名;密码由 agent 首次部署自动生成(见配置注释)*/ + basic_auth_user?: string + /** 公网反代:IP 黑名单(deny),与白名单可同时使用 */ + deny_ips?: string[] + /** 公网反代:Basic Auth 自定义密码;留空则首次部署自动生成 */ + basic_auth_password?: string + /** 公网反代:显式关闭 HTTPS(证书已推送也不监听 443);缺省 = 启用 */ + https_disabled?: boolean + /** 公网反代:客户端上传大小限制,如 50m */ + client_max_body_size?: string + /** 公网反代:代理读超时,如 3600s(长轮询 / 流媒体 / 大文件必需)*/ + proxy_read_timeout?: string + /** 公网反代:安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy)*/ + security_headers?: boolean + /** 公网反代:仅允许 TLS 1.3 */ + tls13_only?: boolean + /** 公网反代:请求限流(次/秒);0 = 不限制 */ + rate_limit_rate?: number + /** 公网反代:限流突发允许量 */ + rate_limit_burst?: number + /** 公网反代:每 IP 并发连接数上限;0 = 不限制 */ + conn_limit_max?: number + /** 公网反代:仅允许中国大陆 IP(部署时自动下发本地维护的 IP 段)*/ + china_only?: boolean + locations?: string[] + route_by_http_user?: string + http_user?: string + http_password?: string + multiplexer?: string + secret_key?: string + allow_users?: string[] + transport: { + bandwidth_limit?: string + bandwidth_limit_mode?: 'client' | 'server' | string + use_encryption?: boolean + use_compression?: boolean + proxy_protocol_version?: 'v1' | 'v2' | string + } + health_check: { + type?: 'tcp' | 'http' | string + path?: string + interval_seconds?: number + max_failed?: number + timeout_seconds?: number + http_headers?: Array<{ name: string; value: string }> + } + load_balancer: { group?: string; group_key?: string } + metadatas?: Record + request_headers?: Record + response_headers?: Record + plugin?: { + type: string + unix_path?: string + local_path?: string + strip_prefix?: string + local_addr?: string + http_user?: string + http_password?: string + username?: string + password?: string + crt_path?: string + key_path?: string + host_header_rewrite?: string + destination_ip?: string + } + nat_traversal: { disable_assisted_addrs?: boolean } +} + +/** frps 侧单条穿透规则的流量快照;速率为两次采样间隔内的均值 */ +export interface FrpProxyTraffic { + traffic_in: number + traffic_out: number + cur_conns: number + traffic_in_rate: number + traffic_out_rate: number +} + +/** 按 frp 规则 ID 聚合的流量数据;errors 记录采集失败的服务端/规则错误 */ +export interface FrpProxiesTraffic { + items: Record + errors?: Record + sampled_at: string +} + +export interface FrpStatus { + status: 'running' | 'stopped' | 'unknown' + pid?: number + frpc_version?: string + last_started_at?: string + last_reloaded_at?: string + last_error?: string + configured: boolean + enabled_proxies: number +} + +export interface FrpRuntimeInfo { + platform: string + configured_bin: string + active_bin?: string + active_version?: string + detected_version?: string + download_proxy: string + config_path: string + config_paths?: string[] + config_exists: boolean +} + +export interface FrpRelease { + version: string + published_at?: string + asset_name: string + size: number +} + +export interface FrpBinary { + version: string + path: string + size: number + installed_at: string + active: boolean +} + +/** 单项诊断结果;state 为 ok/failed/unknown,unknown 表示无法确认而非失败 */ +export interface FrpDiagnosticCheck { + name: 'tcp_reachable' | 'frpc_login' | 'proxy_reachable' | 'dns_resolution' | 'location' | string + state: 'ok' | 'failed' | 'unknown' | string + detail?: string + latency_ms?: number +} + +export interface FrpServerDiagnostics { + server_id: number + available: boolean + latency_ms?: number + location?: string + checks?: FrpDiagnosticCheck[] + error?: string +} + +export interface FrpServerSample { + checked_at: string + available: boolean + latency_ms?: number + error?: string +} + +export interface FrpServerStatusDetail { + available: boolean + availability: number + average_latency_ms: number + peak_latency_ms: number + samples: FrpServerSample[] + last_checked_at?: string + last_error?: string +} + +export interface FrpServerDetail { + server: FrpServer + status: FrpServerStatusDetail + proxies: FrpProxy[] +} + +export interface FrpDownloadTask { + version?: string + status: 'idle' | 'queued' | 'downloading' | 'verifying' | 'extracting' | 'completed' | 'failed' + message: string + downloaded: number + total: number + error?: string + updated_at?: string +} + +export interface FrpServerPayload { + agent_url?: string + agent_token?: string + clear_agent_token?: boolean + ssh_host?: string + ssh_port?: number + ssh_user?: string + ssh_auth?: string + ssh_secret?: string + clear_ssh_secret?: boolean + mgmt_enabled?: boolean + name: string + server_addr: string + server_port: number + tls_enabled?: boolean + tls_server_name?: string + enabled?: boolean + token?: string + clear_token?: boolean + options: FrpServerOptions + oidc_client_secret?: string + clear_oidc_client_secret?: boolean + dashboard_password?: string + clear_dashboard_password?: boolean + tls_certificate?: string + tls_key?: string + tls_trusted_ca?: string + clear_tls_certificate?: boolean + clear_tls_key?: boolean + clear_tls_trusted_ca?: boolean +} + +export interface FrpAgentRouteCandidate { + id: number + name: string + type: 'http' | 'https' + custom_domains: string[] + local_ip: string + local_port: number + enabled: boolean +} + +export interface FrpAgentRouteHealth { + proxy_id: number + domain: string + dns_ok: boolean + dns_detail?: string + cert_ok: boolean + cert_detail?: string + tunnel_ok: boolean + /** 隧道状态是否判得出来:frps 管理接口不可用时 tunnel_ok 恒为 false,但那不代表隧道不通 */ + tunnel_known?: boolean + tunnel_name?: string + tunnel_detail?: string + service_ok: boolean +} + +export interface FrpRouteHealthSummary { + servers: number + routes: number + healthy: number + dns_failed: number + cert_missing: number + tunnel_down: number + /** frps 管理接口不可用、隧道状态判不出来的条数 */ + tunnel_unknown?: number + service_down: number + failed_servers?: string[] +} + +export interface FrpAgentCertificate { + domain: string + cert_path: string + key_path: string + not_after?: string + days_left: number +} + +export interface FrpAgentCerts { + certs: FrpAgentCertificate[] + warnings?: string[] + dir?: string + checked_at: string +} + +export interface FrpAgentNginxReload { + ok: boolean + test_ok?: boolean + test_error?: string + reload_ok?: boolean + reload_error?: string + checked_at: string +} + +export interface FrpAgentMetrics { + cpu_percent: number + mem_used_bytes: number + mem_total_bytes: number + disk_used_bytes: number + disk_total_bytes: number + disk_path?: string + load_avg?: number[] + uptime_seconds?: number + checked_at: string +} + +export interface FrpAgentNginxLogs { + type: 'access' | 'error' + path: string + lines: string[] + truncated: boolean + checked_at: string +} + +export interface FrpAgentFRPSConfig { + path: string + exists: boolean + content: string + truncated?: boolean + mod_time?: string + checked_at: string +} + +export interface FrpRouteDriftItem { + domain: string + kind: 'missing' | 'orphan' | 'not_loaded' | 'content_mismatch' + detail: string + expected?: string + actual?: string +} + +export interface FrpRouteDriftReport { + server_id: number + expected: number + deployed: number + loaded: number + items: FrpRouteDriftItem[] + checked_at: string +} + +export interface FrpRedeployRouteResult { + proxy_id: number + name: string + domains: string + ok: boolean + error?: string +} + +export interface FrpRedeployAllResult { + server_id: number + total: number + succeeded: number + failed: number + results: FrpRedeployRouteResult[] +} + +export interface FrpAgentRouteDetail { + domain: string + file_exists: boolean + syntax_ok: boolean + loaded: boolean +} + +export interface FrpAgentStatus { + agent_version?: string + listen_addr?: string + listen_scope?: string + remote_access_enabled?: boolean + transport_security?: string + https_proxy_configured?: boolean + https_proxy_port?: number + ports?: Record + frps_active?: boolean + frps_enabled?: boolean + frps_version?: string + frps_log_tail?: string + frps_unit_exec?: string + dashboard_addr?: string + service: string + nginx_binary: string + nginx_conf_dir: string + /** nginx -t 是否通过 */ + nginx_ok?: boolean + /** 未找到 nginx 可执行文件 */ + nginx_missing?: boolean + /** 解析到的 nginx 路径 */ + nginx_path?: string + /** 有 nginx 进程在运行 */ + nginx_running?: boolean + /** nginx 版本,如 nginx/1.24.0 */ + nginx_version?: string + /** 探测失败原因(缺失或配置有误) */ + nginx_error?: string + frps_binary: string + frps_config_path: string + routes: string[] + checked_at: string +} + +export interface FrpProxyPayload { + server_id: number + name: string + type: FrpProxy['type'] + local_ip: string + local_port: number + remote_port?: number | null + custom_domains?: string[] + host_header_rewrite?: string + options?: FrpProxyOptions + enabled?: boolean + remark?: string +} + +export interface NginxBackupEntry { + name: string + created_at: string +} + +export interface RuleNginxView { + mode: 'auto' | 'custom' + enabled: boolean + active: boolean + generated: string + content: string + backups: NginxBackupEntry[] +} + +export interface GlobalNginxView { + mode: 'auto' | 'custom' + generated_framework: string + generated_snippet: string + content: string + backups: NginxBackupEntry[] +} + +// 配置版本(本机主配置与公网 vhost 共用同一形状) +export interface NginxConfigVersionEntry { + name: string + size: number + created_at: string +} + +export interface NginxConfigVersionsView { + message?: string + content: string + versions: NginxConfigVersionEntry[] +} + +export interface NginxConfigVersionView { + name: string + content: string +} + +// 公网 VPS 上某个域名 vhost 的版本历史(需 agent ≥ 0.12.2) +export interface FrpAgentRouteVersions { + domain: string + content: string + versions: NginxConfigVersionEntry[] +} + +export interface FrpAgentRouteVersion { + domain: string + name: string + content: string +} + +// API Token:明文只在创建响应里出现一次 +// (令牌本身不含密钥材料,是随机串的 SHA-256 存库) +export interface ApiToken { + id: number + name: string + scope: 'read' | 'write' + expires_at?: string + last_used_at?: string + created_at: string +} + +export interface CreatedApiToken extends ApiToken { + token: string +} + +// 留在服务器上的备份(自动备份 + 手动「立即备份」) +export interface BackupArchive { + name: string + size: number + mod_time: string +} + +// 规则可用率:由后台巡检落库的状态变更推导,不是页面现算 +// (不可用区间与判不出来的时长分开给,unknown 不计入可用率分母) +export interface FrpRouteUptimeIncident { + from: string + to?: string + reason?: string + detail?: string + minutes: number +} + +export interface FrpRouteUptime { + server_id: number + domain: string + state: string + uptime_pct: number + down_minutes: number + unknown_minutes: number + covered_minutes: number + incidents: FrpRouteUptimeIncident[] +} + +// 指标历史:scope=host 是本机,scope=route:: 是单条规则的流量 +export interface MetricsPoint { + at: string + value: number +} + +export interface MetricsSeries { + scope: string + metric: string + points: MetricsPoint[] +} + +export interface MetricsHistory { + hours: number + buckets: number + series: MetricsSeries[] +} + +// 公开状态页(匿名只读):只有域名与可用率,不含内网地址/端口/证书路径 +export interface StatusPageRoute { + domain: string + state: string + source?: 'frp' | 'cloudflare' | string + uptime_pct: number + down_minutes: number + unknown_minutes: number + incidents: StatusPageIncident[] +} + +export interface StatusPageIncident { + from: string + to?: string + reason?: string + detail?: string + minutes: number +} + +export interface StatusPagePayload { + title: string + code_required: boolean + checked_at: string + up: number + down: number + unknown: number + routes: StatusPageRoute[] +} + +// Cloudflare Tunnel 独立模块 +export interface CfNetworkSettings { + transport_protocol: string + edge_ip_version: string + ha_connections: number + proxy_mode: string + origin_ca_pool?: string + no_tls_verify?: boolean + http_host_header?: string +} + +export interface CfTunnel { + id: number + name: string + mode: string + tunnel_id: string + account_tag: string + config_path: string + creds_path: string + log_path: string + network: CfNetworkSettings + status: string + last_error?: string + metrics_port?: number + auto_start: boolean + managed: boolean + hostnames?: string[] + origin_service?: string + dns_warning?: string + created_at: string + updated_at: string +} + +export interface CfRuntimeStatus { + running: boolean + status: string + last_error?: string + metrics_port?: number + connections: number + latency_ms: number + bandwidth_in: number + bandwidth_out: number + transport: string + bandwidth_notice?: string +} + +export interface CfMirror { + id: string + name: string + base: string +} + +export interface CfZone { + id: string + name: string +} + +export interface CfTunnelPayload { + name: string + mode: string + token?: string + hostnames: string[] + origin_service?: string + network: CfNetworkSettings + auto_start: boolean + managed: boolean +} + +export interface CfAppSettings { + account_id: string + enabled: boolean + default_token_configured: boolean + default_token_masked: string + api_token_configured: boolean + api_token_masked: string + mirror: string + network: CfNetworkSettings +} + +export interface CfAppSettingsInput { + account_id: string + enabled?: boolean + token: string + api_token: string + mirror: string + network: CfNetworkSettings +} + +export interface CfDriftResult { + tunnel_id: number + managed: boolean + has_drift: boolean + items: string[] +} + +export interface CfRouteView { + id?: number + hostname: string + path: string + service: string + source: 'rule' | 'config' | string +} + +export interface CfTunnelRoutes { + managed: boolean + routes: CfRouteView[] + catch_all: string +} + +export interface CfDiagnosticCheck { + name: string + status: 'ok' | 'warning' | 'error' | string + message: string +} + +export interface CfDiagnosticsResult { + tunnel_id: number + generated_at: string + checks: CfDiagnosticCheck[] +} + +export interface CfPreflightCheck { + name: string + status: 'ok' | 'error' | string + message: string + action?: string +} + +export interface CfPreflightResult { + ready: boolean + checks: CfPreflightCheck[] +} + +export interface CfRouteTestResult { + ok: boolean + message: string + latency_ms: number +} + +export interface CfAccessStatus { + enabled: boolean + name?: string +} diff --git a/app-src/web/src/assets/brand/banner-art.png b/app-src/web/src/assets/brand/banner-art.png new file mode 100644 index 0000000..36928c0 Binary files /dev/null and b/app-src/web/src/assets/brand/banner-art.png differ diff --git a/app-src/web/src/assets/brand/banner.png b/app-src/web/src/assets/brand/banner.png new file mode 100644 index 0000000..0604b2a Binary files /dev/null and b/app-src/web/src/assets/brand/banner.png differ diff --git a/app-src/web/src/assets/brand/ca/buypass.png b/app-src/web/src/assets/brand/ca/buypass.png new file mode 100644 index 0000000..9d0aa72 Binary files /dev/null and b/app-src/web/src/assets/brand/ca/buypass.png differ diff --git a/app-src/web/src/assets/brand/ca/ca-logos.png b/app-src/web/src/assets/brand/ca/ca-logos.png new file mode 100644 index 0000000..2a3942e Binary files /dev/null and b/app-src/web/src/assets/brand/ca/ca-logos.png differ diff --git a/app-src/web/src/assets/brand/ca/letsencrypt.png b/app-src/web/src/assets/brand/ca/letsencrypt.png new file mode 100644 index 0000000..d963dee Binary files /dev/null and b/app-src/web/src/assets/brand/ca/letsencrypt.png differ diff --git a/app-src/web/src/assets/brand/ca/zerossl.png b/app-src/web/src/assets/brand/ca/zerossl.png new file mode 100644 index 0000000..270f348 Binary files /dev/null and b/app-src/web/src/assets/brand/ca/zerossl.png differ diff --git a/app-src/web/src/assets/brand/dns/aliyun.png b/app-src/web/src/assets/brand/dns/aliyun.png new file mode 100644 index 0000000..d6fb921 Binary files /dev/null and b/app-src/web/src/assets/brand/dns/aliyun.png differ diff --git a/app-src/web/src/assets/brand/dns/cloudflare.png b/app-src/web/src/assets/brand/dns/cloudflare.png new file mode 100644 index 0000000..eb8303d Binary files /dev/null and b/app-src/web/src/assets/brand/dns/cloudflare.png differ diff --git a/app-src/web/src/assets/brand/dns/dnspod.png b/app-src/web/src/assets/brand/dns/dnspod.png new file mode 100644 index 0000000..c6f2731 Binary files /dev/null and b/app-src/web/src/assets/brand/dns/dnspod.png differ diff --git a/app-src/web/src/assets/brand/dns/tencentcloud.png b/app-src/web/src/assets/brand/dns/tencentcloud.png new file mode 100644 index 0000000..e00336f Binary files /dev/null and b/app-src/web/src/assets/brand/dns/tencentcloud.png differ diff --git a/app-src/web/src/assets/brand/dns/volcengine.png b/app-src/web/src/assets/brand/dns/volcengine.png new file mode 100644 index 0000000..97deb99 Binary files /dev/null and b/app-src/web/src/assets/brand/dns/volcengine.png differ diff --git a/app-src/web/src/assets/brand/havline-logo-v1.svg b/app-src/web/src/assets/brand/havline-logo-v1.svg new file mode 100644 index 0000000..061be05 --- /dev/null +++ b/app-src/web/src/assets/brand/havline-logo-v1.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app-src/web/src/assets/brand/havline-logo.svg b/app-src/web/src/assets/brand/havline-logo.svg new file mode 100644 index 0000000..81645c5 --- /dev/null +++ b/app-src/web/src/assets/brand/havline-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app-src/web/src/assets/brand/logo.png b/app-src/web/src/assets/brand/logo.png new file mode 100644 index 0000000..ba8c5e1 Binary files /dev/null and b/app-src/web/src/assets/brand/logo.png differ diff --git a/app-src/web/src/components/ConfigDiffView.vue b/app-src/web/src/components/ConfigDiffView.vue new file mode 100644 index 0000000..ca77595 --- /dev/null +++ b/app-src/web/src/components/ConfigDiffView.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/app-src/web/src/components/ConfiguredSecretField.vue b/app-src/web/src/components/ConfiguredSecretField.vue new file mode 100644 index 0000000..04131f6 --- /dev/null +++ b/app-src/web/src/components/ConfiguredSecretField.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/app-src/web/src/components/EmptyState.vue b/app-src/web/src/components/EmptyState.vue new file mode 100644 index 0000000..b2a67bd --- /dev/null +++ b/app-src/web/src/components/EmptyState.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/app-src/web/src/components/HavlineCard.vue b/app-src/web/src/components/HavlineCard.vue new file mode 100644 index 0000000..36e865e --- /dev/null +++ b/app-src/web/src/components/HavlineCard.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/app-src/web/src/components/LoadError.vue b/app-src/web/src/components/LoadError.vue new file mode 100644 index 0000000..96fa60b --- /dev/null +++ b/app-src/web/src/components/LoadError.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/app-src/web/src/components/MetricsTrendChart.vue b/app-src/web/src/components/MetricsTrendChart.vue new file mode 100644 index 0000000..7f0e294 --- /dev/null +++ b/app-src/web/src/components/MetricsTrendChart.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/app-src/web/src/components/MetricsTrendPanel.vue b/app-src/web/src/components/MetricsTrendPanel.vue new file mode 100644 index 0000000..248fa0c --- /dev/null +++ b/app-src/web/src/components/MetricsTrendPanel.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/app-src/web/src/components/MiniBarChart.vue b/app-src/web/src/components/MiniBarChart.vue new file mode 100644 index 0000000..0e4f58f --- /dev/null +++ b/app-src/web/src/components/MiniBarChart.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/app-src/web/src/components/MiniDonutChart.vue b/app-src/web/src/components/MiniDonutChart.vue new file mode 100644 index 0000000..c6cf38c --- /dev/null +++ b/app-src/web/src/components/MiniDonutChart.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/app-src/web/src/components/MiniTrafficChart.vue b/app-src/web/src/components/MiniTrafficChart.vue new file mode 100644 index 0000000..7a9cd3c --- /dev/null +++ b/app-src/web/src/components/MiniTrafficChart.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/app-src/web/src/components/NginxCodeEditor.vue b/app-src/web/src/components/NginxCodeEditor.vue new file mode 100644 index 0000000..b6554b8 --- /dev/null +++ b/app-src/web/src/components/NginxCodeEditor.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/app-src/web/src/components/PageHeader.vue b/app-src/web/src/components/PageHeader.vue new file mode 100644 index 0000000..abca5a3 --- /dev/null +++ b/app-src/web/src/components/PageHeader.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/app-src/web/src/components/ProxyAccessLogBox.vue b/app-src/web/src/components/ProxyAccessLogBox.vue new file mode 100644 index 0000000..329fa49 --- /dev/null +++ b/app-src/web/src/components/ProxyAccessLogBox.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/app-src/web/src/components/StatCard.vue b/app-src/web/src/components/StatCard.vue new file mode 100644 index 0000000..32a8f5a --- /dev/null +++ b/app-src/web/src/components/StatCard.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/app-src/web/src/components/StatusBadge.vue b/app-src/web/src/components/StatusBadge.vue new file mode 100644 index 0000000..10a4e04 --- /dev/null +++ b/app-src/web/src/components/StatusBadge.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/app-src/web/src/components/TaskProviderForm.vue b/app-src/web/src/components/TaskProviderForm.vue new file mode 100644 index 0000000..acf9641 --- /dev/null +++ b/app-src/web/src/components/TaskProviderForm.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/app-src/web/src/components/UpdateModal.vue b/app-src/web/src/components/UpdateModal.vue new file mode 100644 index 0000000..7aed892 --- /dev/null +++ b/app-src/web/src/components/UpdateModal.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/app-src/web/src/composables/useTheme.ts b/app-src/web/src/composables/useTheme.ts new file mode 100644 index 0000000..339beae --- /dev/null +++ b/app-src/web/src/composables/useTheme.ts @@ -0,0 +1,136 @@ +import { computed, ref, watch } from 'vue' +import { darkTheme, type GlobalThemeOverrides } from 'naive-ui' + +const themeKey = 'havline-theme' +const themeMode = ref(localStorage.getItem(themeKey) ?? 'system') + +function systemDark() { + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +const shared = { + primaryColor: '#10B981', + primaryColorHover: '#059669', + primaryColorPressed: '#047857', + primaryColorSuppl: '#34D399', + borderRadius: '8px', + fontFamily: 'var(--havline-font)', +} + +const lightOverrides: GlobalThemeOverrides = { + common: { + ...shared, + bodyColor: '#F8FAFC', + cardColor: '#FFFFFF', + modalColor: '#FFFFFF', + popoverColor: '#FFFFFF', + borderColor: '#E5E7EB', + dividerColor: '#E5E7EB', + textColor1: '#111827', + textColor2: '#6B7280', + textColor3: '#9CA3AF', + hoverColor: 'rgba(16, 185, 129, 0.08)', + }, + Card: { borderRadius: '12px', color: '#FFFFFF' }, + Button: { borderRadiusMedium: '8px', heightMedium: '36px' }, + Input: { borderRadius: '8px' }, + DataTable: { borderRadius: '12px', thColor: '#F9FAFB', tdColor: '#FFFFFF' }, + Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' }, + Layout: { color: '#FFFFFF', siderColor: '#FFFFFF' }, + Menu: { + itemColorActive: 'rgba(16, 185, 129, 0.12)', + itemTextColorActive: '#047857', + itemIconColorActive: '#10B981', + itemColorActiveHover: 'rgba(16, 185, 129, 0.16)', + }, + Radio: { + buttonBorderColorActive: '#10B981', + buttonTextColorActive: '#047857', + buttonColorActive: 'rgba(16, 185, 129, 0.12)', + buttonBoxShadowHover: 'none', + buttonBoxShadowFocus: 'none', + }, +} + +const darkOverrides: GlobalThemeOverrides = { + common: { + ...shared, + bodyColor: '#0F172A', + cardColor: '#1E293B', + modalColor: '#1E293B', + popoverColor: '#1E293B', + borderColor: '#334155', + dividerColor: '#334155', + textColor1: '#F1F5F9', + textColor2: '#94A3B8', + textColor3: '#64748B', + hoverColor: 'rgba(16, 185, 129, 0.12)', + }, + Card: { borderRadius: '12px', color: '#1E293B', borderColor: '#334155' }, + Button: { borderRadiusMedium: '8px', heightMedium: '36px' }, + Input: { borderRadius: '8px', color: '#0F172A' }, + DataTable: { borderRadius: '12px', thColor: '#0F172A', tdColor: '#1E293B', borderColor: '#334155' }, + Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' }, + Layout: { color: '#0F172A', siderColor: '#1E293B' }, + Menu: { + itemColorActive: 'rgba(16, 185, 129, 0.18)', + itemTextColorActive: '#6EE7B7', + itemIconColorActive: '#34D399', + itemColorActiveHover: 'rgba(16, 185, 129, 0.24)', + }, + Radio: { + buttonBorderColorActive: '#34D399', + buttonTextColorActive: '#6EE7B7', + buttonColorActive: 'rgba(16, 185, 129, 0.22)', + buttonBoxShadowHover: 'none', + buttonBoxShadowFocus: 'none', + }, +} + +function resolveDark(mode: string) { + if (mode === 'dark') return true + if (mode === 'light') return false + return systemDark() +} + +function applyDocumentTheme(dark: boolean) { + document.documentElement.style.colorScheme = dark ? 'dark' : 'light' + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') + document.documentElement.classList.toggle('dark', dark) +} + +export function useTheme() { + const isDark = computed(() => resolveDark(themeMode.value)) + + const naiveTheme = computed(() => (isDark.value ? darkTheme : null)) + const themeOverrides = computed(() => + isDark.value ? darkOverrides : lightOverrides, + ) + + function setThemeMode(mode: string) { + themeMode.value = mode + } + + function toggleTheme() { + themeMode.value = isDark.value ? 'light' : 'dark' + } + + watch( + themeMode, + (value) => { + localStorage.setItem(themeKey, value) + applyDocumentTheme(resolveDark(value)) + }, + { immediate: true }, + ) + + if (typeof window !== 'undefined') { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (themeMode.value === 'system') { + applyDocumentTheme(systemDark()) + } + }) + } + + return { themeMode, naiveTheme, themeOverrides, isDark, toggleTheme, setThemeMode } +} diff --git a/app-src/web/src/composables/useVisibilityPolling.spec.ts b/app-src/web/src/composables/useVisibilityPolling.spec.ts new file mode 100644 index 0000000..5733956 --- /dev/null +++ b/app-src/web/src/composables/useVisibilityPolling.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import { createVisibilityPoller, type PollingHost } from './useVisibilityPolling' + +function fakeHost() { + let hidden = false + let intervalFn: (() => void) | null = null + let intervalId = 0 + const listeners: Array<() => void> = [] + const host: PollingHost = { + setInterval: (fn) => { + intervalFn = fn + return ++intervalId + }, + clearInterval: () => { + intervalFn = null + }, + isHidden: () => hidden, + addVisibilityListener: (fn) => { + listeners.push(fn) + }, + removeVisibilityListener: (fn) => { + const index = listeners.indexOf(fn) + if (index >= 0) listeners.splice(index, 1) + }, + } + return { + host, + setHidden: (value: boolean) => { + hidden = value + }, + fireInterval: () => intervalFn?.(), + fireVisibility: () => listeners.forEach((fn) => fn()), + hasTimer: () => intervalFn !== null, + listenerCount: () => listeners.length, + } +} + +describe('createVisibilityPoller', () => { + it('页面隐藏时不执行', async () => { + const h = fakeHost() + let calls = 0 + const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host) + h.setHidden(true) + await poller.tick() + expect(calls).toBe(0) + }) + + it('回到前台立即补一次', async () => { + const h = fakeHost() + let calls = 0 + const poller = createVisibilityPoller(() => { calls++ }, 1000, {}, h.host) + poller.attach() + h.fireVisibility() + await Promise.resolve() + expect(calls).toBe(1) + }) + + it('上一轮未结束时跳过下一轮', async () => { + const h = fakeHost() + let calls = 0 + let release: () => void = () => {} + const pending = new Promise((resolve) => { release = resolve }) + const poller = createVisibilityPoller(() => { + calls++ + return calls === 1 ? pending : Promise.resolve() + }, 1000, {}, h.host) + const first = poller.tick() + await Promise.resolve() + await poller.tick() + expect(calls).toBe(1) + release() + await first + await poller.tick() + expect(calls).toBe(2) + }) + + it('enabled=false 时不执行', async () => { + const h = fakeHost() + const enabled = ref(false) + let calls = 0 + const poller = createVisibilityPoller(() => { calls++ }, 1000, { enabled }, h.host) + await poller.tick() + expect(calls).toBe(0) + enabled.value = true + await poller.tick() + expect(calls).toBe(1) + }) + + it('卸载后不再保留计时器与监听', () => { + const h = fakeHost() + const poller = createVisibilityPoller(() => {}, 1000, {}, h.host) + poller.attach() + expect(h.hasTimer()).toBe(true) + expect(h.listenerCount()).toBe(1) + poller.detach() + expect(h.hasTimer()).toBe(false) + expect(h.listenerCount()).toBe(0) + }) + + it('onError 被调用且不影响后续轮询', async () => { + const h = fakeHost() + const errors: unknown[] = [] + let calls = 0 + const poller = createVisibilityPoller(() => { + calls++ + if (calls === 1) throw new Error('boom') + }, 1000, { onError: (error) => errors.push(error) }, h.host) + await poller.tick() + expect(errors).toHaveLength(1) + expect(poller.lastError.value).toBeInstanceOf(Error) + await poller.tick() + expect(calls).toBe(2) + expect(poller.lastError.value).toBeNull() + }) +}) diff --git a/app-src/web/src/composables/useVisibilityPolling.ts b/app-src/web/src/composables/useVisibilityPolling.ts new file mode 100644 index 0000000..9df8d1f --- /dev/null +++ b/app-src/web/src/composables/useVisibilityPolling.ts @@ -0,0 +1,115 @@ +import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue' + +export interface VisibilityPollingOptions { + /** 为 false 时整轮跳过(例如未选中服务端、弹窗未打开)。 */ + enabled?: Ref + /** 挂载后是否立即执行一次。 */ + immediate?: boolean + /** 轮询回调抛错时调用;不传则只记录到 lastError。 */ + onError?: (error: unknown) => void +} + +/** 注入的宿主接口,便于在无 DOM 环境下测试轮询核心。 */ +export interface PollingHost { + setInterval: (fn: () => void, ms: number) => number + clearInterval: (id: number) => void + isHidden: () => boolean + addVisibilityListener: (fn: () => void) => void + removeVisibilityListener: (fn: () => void) => void +} + +export interface VisibilityPoller { + start: () => void + stop: () => void + tick: () => Promise + attach: () => void + detach: () => void + busy: Ref + lastError: Ref +} + +function browserPollingHost(): PollingHost { + return { + setInterval: (fn, ms) => window.setInterval(fn, ms), + clearInterval: (id) => window.clearInterval(id), + isHidden: () => document.visibilityState === 'hidden', + addVisibilityListener: (fn) => document.addEventListener('visibilitychange', fn), + removeVisibilityListener: (fn) => document.removeEventListener('visibilitychange', fn), + } +} + +/** + * 可测试的轮询核心:隐藏时跳过、上一轮未结束时跳过、回到前台立即补一次。 + * 不依赖 Vue 生命周期,宿主能力全部由 host 注入。 + */ +export function createVisibilityPoller( + fn: () => void | Promise, + intervalMs: number, + options: VisibilityPollingOptions = {}, + host: PollingHost = browserPollingHost(), +): VisibilityPoller { + const busy = ref(false) + const lastError = ref(null) + let timer: number | undefined + + const tick = async () => { + if (host.isHidden()) return + if (options.enabled && !options.enabled.value) return + if (busy.value) return + busy.value = true + try { + await fn() + lastError.value = null + } catch (error) { + lastError.value = error + options.onError?.(error) + } finally { + busy.value = false + } + } + + const start = () => { + if (timer !== undefined) return + timer = host.setInterval(() => void tick(), intervalMs) + } + + const stop = () => { + if (timer === undefined) return + host.clearInterval(timer) + timer = undefined + } + + const onVisibilityChange = () => { + if (host.isHidden()) return + void tick() + } + + const attach = () => { + host.addVisibilityListener(onVisibilityChange) + start() + } + + const detach = () => { + host.removeVisibilityListener(onVisibilityChange) + stop() + } + + return { start, stop, tick, attach, detach, busy, lastError } +} + +/** + * 页面可见时才轮询:隐藏期间跳过,回到前台立即补一次,上一轮未结束时跳过下一轮。 + */ +export function useVisibilityPolling( + fn: () => void | Promise, + intervalMs: number, + options: VisibilityPollingOptions = {}, +): VisibilityPoller { + const poller = createVisibilityPoller(fn, intervalMs, options) + onMounted(() => { + poller.attach() + if (options.immediate) void poller.tick() + }) + onBeforeUnmount(() => poller.detach()) + return poller +} diff --git a/app-src/web/src/constants/app.ts b/app-src/web/src/constants/app.ts new file mode 100644 index 0000000..969b450 --- /dev/null +++ b/app-src/web/src/constants/app.ts @@ -0,0 +1,4 @@ +export const GITHUB_REPO = 'O96u/Fonu' +export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}` +export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}` +export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases` diff --git a/app-src/web/src/constants/ipPolicy.ts b/app-src/web/src/constants/ipPolicy.ts new file mode 100644 index 0000000..e747ddf --- /dev/null +++ b/app-src/web/src/constants/ipPolicy.ts @@ -0,0 +1,26 @@ +/** Mirrors internal/proxy.DefaultPrivateCIDRs — always exempt from china_only / global allow. */ +export const DEFAULT_PRIVATE_CIDRS = [ + '10.0.0.0/8', + '127.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '::1/128', + 'fc00::/7', +] + +export function mergeIPList(defaults: string[], custom: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const cidr of [...defaults, ...custom]) { + const value = cidr.trim() + if (!value || seen.has(value)) continue + seen.add(value) + out.push(value) + } + return out +} + +export function stripDefaultIPList(defaults: string[], list: string[]): string[] { + const defaultSet = new Set(defaults) + return list.map((item) => item.trim()).filter((item) => item && !defaultSet.has(item)) +} diff --git a/app-src/web/src/constants/metrics.ts b/app-src/web/src/constants/metrics.ts new file mode 100644 index 0000000..48def5e --- /dev/null +++ b/app-src/web/src/constants/metrics.ts @@ -0,0 +1,12 @@ +// 趋势图的时间档:后端按 hours 取窗口、按 buckets 取桶数(点位在该数量级最清楚) +export interface MetricsRange { + label: string + hours: number + buckets: number +} + +export const METRICS_RANGES: MetricsRange[] = [ + { label: '1 小时', hours: 1, buckets: 30 }, + { label: '24 小时', hours: 24, buckets: 48 }, + { label: '7 天', hours: 24 * 7, buckets: 84 }, +] diff --git a/app-src/web/src/constants/notifyEvents.ts b/app-src/web/src/constants/notifyEvents.ts new file mode 100644 index 0000000..6740d64 --- /dev/null +++ b/app-src/web/src/constants/notifyEvents.ts @@ -0,0 +1,128 @@ +export interface NotifyEventOption { + key: string + label: string + hint?: string + hasThreshold?: boolean + thresholdKey?: 'ip_frequent' | 'login_failure' | 'resource' | 'tunnel_fail' | 'frps_fail' | 'agent_fail' | 'drift_cooldown' +} + +export interface NotifyEventGroup { + title: string + events: NotifyEventOption[] +} + +export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [ + { + title: 'DDNS', + events: [ + { + key: 'on_ddns_ip_change', + label: 'IP 变更', + hint: '公网 IP 变更并成功同步到 DNS', + }, + { + key: 'on_ddns_failure', + label: '更新失败', + }, + ], + }, + { + title: '证书', + events: [ + { + key: 'on_cert_expiry', + label: '即将到期', + hint: '进入续签阈值后每天提醒一次', + }, + { + key: 'on_cert_renew_success', + label: '续签成功', + }, + { + key: 'on_cert_renew_failure', + label: '续签失败', + }, + ], + }, + { + title: '安全', + events: [ + { + key: 'on_ip_frequent_access', + label: 'IP 频繁访问', + hint: '同一 IP 在统计窗口内超过阈值', + hasThreshold: true, + thresholdKey: 'ip_frequent', + }, + { + key: 'on_login_failure', + label: '登录异常', + hint: '同一 IP 在窗口内多次登录失败', + hasThreshold: true, + thresholdKey: 'login_failure', + }, + ], + }, + { + title: '系统', + events: [ + { + key: 'on_nginx_reload_failure', + label: 'Nginx 重载失败', + hint: '保存反代或证书后 Nginx 未能成功重载', + }, + { + key: 'on_backup_failure', + label: '自动备份失败', + hint: '定时备份导出或校验失败——没有备份这件事,往往到需要它的那天才发现', + }, + { + key: 'on_resource_threshold', + label: '资源占用过高', + hint: 'CPU / 内存 / 磁盘持续超过阈值,同一项每 30 分钟最多提醒一次', + hasThreshold: true, + thresholdKey: 'resource', + }, + { + key: 'on_tunnel_down', + label: '隧道连续不通', + hint: '连续多次巡检未在 frps 上注册,恢复时再通知一次', + hasThreshold: true, + thresholdKey: 'tunnel_fail', + }, + { + key: 'on_frps_down', + label: '公网服务端掉线', + hint: '服务端进程没在跑或隧道连不上 frps(手动停止的不报),恢复时再通知一次', + hasThreshold: true, + thresholdKey: 'frps_fail', + }, + { + key: 'on_agent_unreachable', + label: '公网 agent 不可达', + hint: 'VPS 上的 havline-agent 连续无响应(重启后没起来最常见),恢复时再通知一次', + hasThreshold: true, + thresholdKey: 'agent_fail', + }, + { + key: 'on_route_drift', + label: '配置漂移', + hint: 'VPS 上的 vhost 与 Havline 记录不一致(被手工改过或重装后未重部署),同一台按提醒间隔重复提醒', + hasThreshold: true, + thresholdKey: 'drift_cooldown', + }, + ], + }, +] + +export const DEFAULT_IP_FREQUENT_THRESHOLD = 100 +export const DEFAULT_IP_FREQUENT_WINDOW_SEC = 60 +export const DEFAULT_LOGIN_FAILURE_THRESHOLD = 5 +export const DEFAULT_LOGIN_FAILURE_WINDOW_SEC = 300 +export const DEFAULT_CPU_THRESHOLD = 90 +export const DEFAULT_MEMORY_THRESHOLD = 90 +export const DEFAULT_DISK_THRESHOLD = 85 +export const DEFAULT_TUNNEL_FAIL_THRESHOLD = 3 +export const DEFAULT_FRPS_FAIL_THRESHOLD = 2 +export const DEFAULT_AGENT_FAIL_THRESHOLD = 3 +export const DEFAULT_DRIFT_COOLDOWN_HOURS = 24 diff --git a/app-src/web/src/constants/notifyProviders.ts b/app-src/web/src/constants/notifyProviders.ts new file mode 100644 index 0000000..ed44999 --- /dev/null +++ b/app-src/web/src/constants/notifyProviders.ts @@ -0,0 +1,26 @@ +import type { NotifyType, WebhookProvider } from '../api/types' + +export const NOTIFY_TYPE_OPTIONS: { label: string; value: NotifyType }[] = [ + { label: '关闭', value: '' }, + { label: '邮件(SMTP)', value: 'email' }, + // 企业微信 / 钉钉 / 飞书机器人都是「POST 一个 URL + JSON」,归在 Webhook 预设里,标签要说明白 + { label: 'Webhook / IM 机器人', value: 'webhook' }, + { label: 'Telegram 机器人', value: 'telegram' }, +] + +export const WEBHOOK_PROVIDER_OPTIONS: { label: string; value: WebhookProvider }[] = [ + { label: 'Bark', value: 'bark' }, + { label: 'ntfy', value: 'ntfy' }, + { label: 'Gotify', value: 'gotify' }, + { label: '企业微信机器人', value: 'wecom' }, + { label: '钉钉机器人', value: 'dingtalk' }, + { label: '飞书机器人', value: 'feishu' }, + { label: '自定义 Webhook', value: 'custom' }, +] + +export const DEFAULT_BARK_SERVER = 'https://api.day.app' +export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh' +export const DEFAULT_WECOM_SERVER = 'https://qyapi.weixin.qq.com' +export const DEFAULT_DINGTALK_SERVER = 'https://oapi.dingtalk.com' +export const DEFAULT_FEISHU_SERVER = 'https://open.feishu.cn' +export const MASKED_SECRET = '********' diff --git a/app-src/web/src/constants/secretField.ts b/app-src/web/src/constants/secretField.ts new file mode 100644 index 0000000..f9aa4b7 --- /dev/null +++ b/app-src/web/src/constants/secretField.ts @@ -0,0 +1,3 @@ +/** 敏感凭据字段:标签与占位符全站统一 */ +export const CONFIGURED_SECRET_TAG = '已配置' +export const CONFIGURED_SECRET_PLACEHOLDER = '已配置,留空表示不修改' diff --git a/app-src/web/src/env.d.ts b/app-src/web/src/env.d.ts new file mode 100644 index 0000000..fcde624 --- /dev/null +++ b/app-src/web/src/env.d.ts @@ -0,0 +1,14 @@ +/// + +declare const __APP_VERSION__: string + +declare module '*.png' { + const src: string + export default src +} + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/app-src/web/src/layouts/AppLayout.vue b/app-src/web/src/layouts/AppLayout.vue new file mode 100644 index 0000000..955c8c6 --- /dev/null +++ b/app-src/web/src/layouts/AppLayout.vue @@ -0,0 +1,515 @@ + + + + + diff --git a/app-src/web/src/main.ts b/app-src/web/src/main.ts new file mode 100644 index 0000000..5d4db1a --- /dev/null +++ b/app-src/web/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import naive from 'naive-ui' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.config.errorHandler = (err, _instance, info) => { + console.error('[havline]', info, err) +} + +app.use(router).use(naive).mount('#app') diff --git a/app-src/web/src/router/index.ts b/app-src/web/src/router/index.ts new file mode 100644 index 0000000..3466b9c --- /dev/null +++ b/app-src/web/src/router/index.ts @@ -0,0 +1,118 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { api } from '../api/client' +import AppLayout from '../layouts/AppLayout.vue' +import LoginView from '../views/LoginView.vue' +import ProxyView from '../views/ProxyView.vue' +import DashboardView from '../views/DashboardView.vue' +import DdnsView from '../views/DdnsView.vue' +import CertificatesView from '../views/CertificatesView.vue' +import LogsView from '../views/LogsView.vue' +import SettingsView from '../views/SettingsView.vue' +import FrpView from '../views/FrpView.vue' +import CloudflareView from '../views/CloudflareView.vue' +import CloudflareTunnelConfigView from '../views/CloudflareTunnelConfigView.vue' +import AgentView from '../views/AgentView.vue' +import ProxyRemoteView from '../views/ProxyRemoteView.vue' +import StatusPageView from '../views/StatusPageView.vue' +import AboutView from '../views/AboutView.vue' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { path: '/setup', redirect: '/login' }, + { path: '/login', name: 'login', component: LoginView, meta: { public: true } }, + // 公开状态页:挂到公网给别人看的,不经过登录守卫 + { path: '/status', name: 'status', component: StatusPageView, meta: { public: true } }, + { + path: '/', + component: AppLayout, + meta: { requiresAuth: true }, + children: [ + { path: '', redirect: '/dashboard' }, + { + path: 'dashboard', + name: 'dashboard', + component: DashboardView, + meta: { title: '仪表盘', description: '查看 Havline 运行状态与关键指标' }, + }, + { + path: 'ddns', + name: 'ddns', + component: DdnsView, + meta: { title: 'DDNS', description: '自动同步公网 IP 到 DNS 解析' }, + }, + { + path: 'certificates', + name: 'certificates', + component: CertificatesView, + meta: { title: '证书管理', description: '通过 ACME 自动申请和续签证书' }, + }, + { + path: 'proxies', + name: 'proxies', + component: ProxyView, + meta: { title: '反向代理', description: '管理通过域名访问的 NAS 服务' }, + }, + { + path: 'frp', + name: 'frp', + component: FrpView, + meta: { title: '内网穿透', description: '通过 FRP 将公网流量转发到 Havline Nginx' }, + }, + { + path: 'frp-agent', + name: 'frp-agent', + component: AgentView, + meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 havline-agent 与 frps' }, + }, + { + path: 'cloudflare', + name: 'cloudflare', + component: CloudflareView, + meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' }, + }, + { + path: 'cloudflare/:id/config', + name: 'cloudflare-tunnel-config', + component: CloudflareTunnelConfigView, + meta: { title: 'Cloudflare 隧道配置', description: '配置隧道、查看副本指标与已发布路由' }, + }, + { + path: 'public-proxy', + name: 'public-proxy', + component: ProxyRemoteView, + meta: { title: '公网反代', description: '管理 VPS 上 Nginx 反代的部署/移除与安全选项' }, + }, + { + path: 'logs', + name: 'logs', + component: LogsView, + meta: { title: '日志中心', description: '查看系统、访问与 Nginx 错误日志' }, + }, + { + path: 'settings', + name: 'settings', + component: SettingsView, + meta: { title: '系统设置', description: '配置系统参数与运行策略' }, + }, + { + path: 'about', + name: 'about', + component: AboutView, + meta: { title: '关于项目', description: '查看 Havline 版本、源开源项目和技术信息' }, + }, + ], + }, + ], +}) + +router.beforeEach(async (to) => { + // 公开页(状态页)不登录也能打开,也不必为它去问一次登录状态 + if (to.meta.public && to.name !== 'login') return true + const status = await api.authStatus() + if (to.meta.requiresAuth && !status.authenticated) return { name: 'login' } + if (to.name === 'login' && status.authenticated) return { name: 'dashboard' } + return true +}) + +export default router diff --git a/app-src/web/src/styles/variables.css b/app-src/web/src/styles/variables.css new file mode 100644 index 0000000..5de7191 --- /dev/null +++ b/app-src/web/src/styles/variables.css @@ -0,0 +1,65 @@ +:root { + --havline-brand: #10b981; + --havline-brand-hover: #059669; + --havline-brand-soft: rgba(16, 185, 129, 0.12); + --havline-brand-text: #047857; + --havline-brand-sidebar: #10b981; + + --havline-success: #10b981; + --havline-warning: #f59e0b; + --havline-error: #ef4444; + --havline-disabled: #94a3b8; + --havline-info: #3b82f6; + --havline-info-soft: rgba(59, 130, 246, 0.1); + --havline-error-soft: rgba(239, 68, 68, 0.1); + + --havline-bg: #f8fafc; + --havline-bg-muted: #f3f4f6; + --havline-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%); + --havline-banner-border: rgba(16, 185, 129, 0.2); + --havline-surface: #ffffff; + --havline-border: #e5e7eb; + --havline-text: #111827; + --havline-text-secondary: #6b7280; + --havline-text-muted: #9ca3af; + + --havline-radius: 12px; + --havline-radius-sm: 8px; + --havline-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); + --havline-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06); + + --havline-sidebar-width: 240px; + --havline-topbar-height: 56px; + --havline-form-max: 720px; + + --havline-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + --havline-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace; + + --havline-space-1: 4px; + --havline-space-2: 8px; + --havline-space-3: 12px; + --havline-space-4: 16px; + --havline-space-5: 24px; + --havline-space-6: 32px; +} + +html.dark, +[data-theme='dark'] { + --havline-brand-soft: rgba(16, 185, 129, 0.18); + --havline-brand-text: #6ee7b7; + --havline-info-soft: rgba(59, 130, 246, 0.16); + --havline-error-soft: rgba(239, 68, 68, 0.16); + + --havline-bg: #0f172a; + --havline-bg-muted: #1e293b; + --havline-surface: #1e293b; + --havline-border: #334155; + --havline-text: #f1f5f9; + --havline-text-secondary: #94a3b8; + --havline-text-muted: #64748b; + --havline-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%); + --havline-banner-border: rgba(52, 211, 153, 0.25); + + --havline-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --havline-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); +} diff --git a/app-src/web/src/utils/accessService.ts b/app-src/web/src/utils/accessService.ts new file mode 100644 index 0000000..168a269 --- /dev/null +++ b/app-src/web/src/utils/accessService.ts @@ -0,0 +1,138 @@ +import type { AccessLogEntry, ProxyHost, ProxyRule } from '../api/types' + +export type ProxyBinding = { + name: string + upstream: string + host: string + port: number +} + +function bindingKey(host: string, port: number): string { + return `${host.toLowerCase()}:${port}` +} + +function normalizeAccessHost(domain: string): string { + const raw = domain.trim().toLowerCase() + if (!raw) return '' + if (raw.startsWith('[')) { + const end = raw.indexOf(']') + if (end > 0) return raw.slice(0, end + 1) + } + if (raw.includes(':') && !raw.includes(']')) { + const idx = raw.lastIndexOf(':') + const maybePort = Number.parseInt(raw.slice(idx + 1), 10) + if (idx > 0 && maybePort > 0 && maybePort <= 65535) { + return raw.slice(0, idx) + } + } + return raw +} + +export function buildProxyBindingIndex(rules: ProxyRule[]): Map { + const index = new Map() + for (const rule of rules) { + const fallbackName = rule.domain?.trim() || rule.upstream?.trim() || `规则 #${rule.id}` + const name = rule.name?.trim() || fallbackName + const hosts: ProxyHost[] = rule.hosts?.length + ? rule.hosts + : rule.domain + ? [{ id: 0, hostname: rule.domain }] + : [] + for (const host of hosts) { + const hostname = host.hostname?.trim() + if (!hostname) continue + const port = host.listen_port ?? rule.listen_port + if (port <= 0) continue + index.set(bindingKey(hostname, port), { + name, + upstream: rule.upstream, + host: hostname.toLowerCase(), + port, + }) + } + } + return index +} + +function formatUpstreamShort(upstream: string): string { + return upstream.replace(/^https?:\/\//i, '') +} + +/** Raw host:port / domain / IP shown when no proxy rule matches. */ +export function formatAccessFallback(entry: AccessLogEntry): string { + const rawDomain = entry.domain?.trim() + const host = normalizeAccessHost(entry.domain) + const port = entry.server_port ?? 0 + + if (rawDomain?.includes(':') && (rawDomain.includes(']') || rawDomain.lastIndexOf(':') > rawDomain.lastIndexOf(']'))) { + return rawDomain + } + if (rawDomain?.includes(':') && !rawDomain.includes(']')) { + const idx = rawDomain.lastIndexOf(':') + const maybePort = Number.parseInt(rawDomain.slice(idx + 1), 10) + if (idx > 0 && maybePort > 0 && maybePort <= 65535) { + return rawDomain + } + } + + if (host && port > 0) return `${host}:${port}` + if (rawDomain) return rawDomain + if (host) return host + return entry.client_ip?.trim() || '—' +} + +export function resolveAccessServiceLabel( + entry: AccessLogEntry, + index: Map, +): string { + const host = normalizeAccessHost(entry.domain) + const port = entry.server_port ?? 0 + + // 域名 + 端口(同域多端口) + if (host && port > 0) { + const hit = index.get(bindingKey(host, port)) + if (hit) return hit.name + } + + // 仅域名(单规则或同域单端口) + if (host) { + const matches = [...index.values()].filter((binding) => binding.host === host) + if (matches.length === 1) return matches[0].name + if (matches.length > 1 && port > 0) { + const byPort = matches.find((binding) => binding.port === port) + if (byPort) return byPort.name + } + } + + // 仅 IP / 域名直连、无 Host 匹配时走 upstream + const upstream = entry.upstream?.trim() + if (upstream && upstream !== '-') { + return formatUpstreamShort(upstream) + } + + return formatAccessFallback(entry) +} + +export function accessServiceTooltip(entry: AccessLogEntry, index: Map): string { + const host = normalizeAccessHost(entry.domain) + const port = entry.server_port ?? 0 + const label = resolveAccessServiceLabel(entry, index) + const parts: string[] = [label] + + const requestHost = formatAccessFallback(entry) + if (requestHost !== label) { + parts.push(`请求 ${requestHost}`) + } else if (host) { + parts.push(port > 0 ? `${host}:${port}` : host) + } + + const binding = host && port > 0 ? index.get(bindingKey(host, port)) : undefined + if (binding?.upstream) { + parts.push(`→ ${binding.upstream}`) + } else if (entry.upstream && entry.upstream !== '-') { + parts.push(`→ ${entry.upstream}`) + } + if (entry.client_ip) parts.push(`来自 ${entry.client_ip}`) + if (entry.path) parts.push(entry.path) + return parts.join(' · ') +} diff --git a/app-src/web/src/utils/agent.spec.ts b/app-src/web/src/utils/agent.spec.ts new file mode 100644 index 0000000..f9c5395 --- /dev/null +++ b/app-src/web/src/utils/agent.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { agentVersionText } from './agent' + +describe('agentVersionText', () => { + it('有版本号时原样展示', () => { + expect(agentVersionText('0.14.1')).toBe('0.14.1') + }) + + it('旧 Agent 缺少版本字段时降级为占位符', () => { + expect(agentVersionText()).toBe('—') + expect(agentVersionText('')).toBe('—') + expect(agentVersionText(' ')).toBe('—') + }) +}) diff --git a/app-src/web/src/utils/agent.ts b/app-src/web/src/utils/agent.ts new file mode 100644 index 0000000..6981773 --- /dev/null +++ b/app-src/web/src/utils/agent.ts @@ -0,0 +1,4 @@ +/** Agent 版本展示:旧 Agent 未返回 agent_version 时降级为占位符,避免页面出现空白。 */ +export function agentVersionText(version?: string): string { + return version?.trim() || '—' +} diff --git a/app-src/web/src/utils/clipboard.ts b/app-src/web/src/utils/clipboard.ts new file mode 100644 index 0000000..a874ad2 --- /dev/null +++ b/app-src/web/src/utils/clipboard.ts @@ -0,0 +1,31 @@ +// 剪贴板复制。 +// +// Havline 常以 http://内网IP:6893 访问,属于**非安全上下文**,此时 navigator.clipboard 为 undefined, +// 直接调用会导致复制必然失败。这里统一收口:优先用异步剪贴板 API,不可用时回退到 execCommand。 +// 返回是否复制成功,由调用方决定提示文案。 +export async function copyText(text: string): Promise { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // 权限被拒或浏览器限制:继续尝试回退方案 + } + } + try { + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.top = '-1000px' + el.style.opacity = '0' + document.body.appendChild(el) + el.select() + el.setSelectionRange(0, el.value.length) + const ok = document.execCommand('copy') + document.body.removeChild(el) + return ok + } catch { + return false + } +} diff --git a/app-src/web/src/utils/diff.ts b/app-src/web/src/utils/diff.ts new file mode 100644 index 0000000..62d651f --- /dev/null +++ b/app-src/web/src/utils/diff.ts @@ -0,0 +1,73 @@ +// 极简行级差异:Nginx 配置通常只有几十行,用 LCS 就够,不为此引入第三方 diff 依赖。 + +export interface DiffLine { + kind: 'same' | 'add' | 'del' + text: string + oldLine?: number + newLine?: number +} + +// maxDiffCells 限制 LCS 表大小:超长文本(例如整份日志误粘进来)退化为「整体替换」, +// 避免 O(n*m) 的表把页面卡死。 +const maxDiffCells = 250_000 + +export function diffLines(fromText: string, toText: string): DiffLine[] { + const from = splitLines(fromText) + const to = splitLines(toText) + + if (from.length * to.length > maxDiffCells) { + return [ + ...from.map((text, index) => ({ kind: 'del' as const, text, oldLine: index + 1 })), + ...to.map((text, index) => ({ kind: 'add' as const, text, newLine: index + 1 })), + ] + } + + // dp[i][j] = from[i..] 与 to[j..] 的最长公共子序列长度 + const dp: number[][] = Array.from({ length: from.length + 1 }, () => new Array(to.length + 1).fill(0)) + for (let i = from.length - 1; i >= 0; i -= 1) { + for (let j = to.length - 1; j >= 0; j -= 1) { + dp[i][j] = from[i] === to[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + } + } + + const out: DiffLine[] = [] + let i = 0 + let j = 0 + while (i < from.length && j < to.length) { + if (from[i] === to[j]) { + out.push({ kind: 'same', text: from[i], oldLine: i + 1, newLine: j + 1 }) + i += 1 + j += 1 + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + out.push({ kind: 'del', text: from[i], oldLine: i + 1 }) + i += 1 + } else { + out.push({ kind: 'add', text: to[j], newLine: j + 1 }) + j += 1 + } + } + while (i < from.length) { + out.push({ kind: 'del', text: from[i], oldLine: i + 1 }) + i += 1 + } + while (j < to.length) { + out.push({ kind: 'add', text: to[j], newLine: j + 1 }) + j += 1 + } + return out +} + +export function diffStats(lines: DiffLine[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of lines) { + if (line.kind === 'add') added += 1 + else if (line.kind === 'del') removed += 1 + } + return { added, removed } +} + +function splitLines(text: string): string[] { + if (text === '') return [] + return text.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n') +} diff --git a/app-src/web/src/utils/format.spec.ts b/app-src/web/src/utils/format.spec.ts new file mode 100644 index 0000000..4e6023f --- /dev/null +++ b/app-src/web/src/utils/format.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, formatLogLine, formatLogTime, formatRate, formatRelativeTime, formatUptime } from './format' + +describe('formatBytes', () => { + it('按 1024 进制换算并按量级保留有效位', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1024)).toBe('1.00 KB') + expect(formatBytes(1536)).toBe('1.50 KB') + expect(formatBytes(20 * 1024)).toBe('20.0 KB') + expect(formatBytes(200 * 1024)).toBe('200 KB') + expect(formatBytes(3 * 1024 ** 3)).toBe('3.00 GB') + }) + + it('非法输入回落 0 B,不产生 NaN', () => { + expect(formatBytes(Number.NaN)).toBe('0 B') + expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('0 B') + expect(formatBytes(-1)).toBe('0 B') + }) +}) + +describe('formatRate', () => { + it('复用字节格式化并加 /s', () => { + expect(formatRate(0)).toBe('0 B/s') + expect(formatRate(2048)).toBe('2.00 KB/s') + }) +}) + +describe('formatUptime', () => { + it('小时为 0 时只显示分钟', () => { + expect(formatUptime(90)).toBe('1 分钟') + expect(formatUptime(3661)).toBe('1 小时 1 分钟') + }) +}) + +describe('formatRelativeTime', () => { + it('按分钟 / 小时 / 天分档', () => { + const ago = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString() + expect(formatRelativeTime(undefined)).toBe('-') + expect(formatRelativeTime(ago(0))).toBe('刚刚') + expect(formatRelativeTime(ago(5))).toBe('5 分钟前') + expect(formatRelativeTime(ago(3 * 60))).toBe('3 小时前') + expect(formatRelativeTime(ago(2 * 24 * 60))).toBe('2 天前') + }) + + it('无法解析的字符串原样返回,不显示 NaN', () => { + expect(formatRelativeTime('not-a-date')).toBe('not-a-date') + }) +}) + +describe('formatLogTime', () => { + it('三种日志时间格式统一成 YYYY-MM-DD HH:mm:ss', () => { + expect(formatLogTime('2026-09-24 18:30:05')).toBe('2026-09-24 18:30:05') + expect(formatLogTime('2026-09-24T18:30:05Z')).toBe('2026-09-24 18:30:05') + expect(formatLogTime('2026/09/24 18:30:05')).toBe('2026-09-24 18:30:05') + expect(formatLogTime('')).toBe('-') + }) +}) + +describe('formatLogLine', () => { + it('只改写行首时间戳,保留其余内容', () => { + expect(formatLogLine('2026/09/24 18:30:05 [error] boom')).toBe('2026-09-24 18:30:05 [error] boom') + expect(formatLogLine('2026-09-24T18:30:05Z upstream timeout')).toBe('2026-09-24 18:30:05 upstream timeout') + }) + + it('JSON 日志只替换 time 字段', () => { + expect(formatLogLine('{"time":"2026-09-24T18:30:05Z","level":"info"}')).toBe('{"time":"2026-09-24 18:30:05","level":"info"}') + }) +}) diff --git a/app-src/web/src/utils/format.ts b/app-src/web/src/utils/format.ts new file mode 100644 index 0000000..f61e73f --- /dev/null +++ b/app-src/web/src/utils/format.ts @@ -0,0 +1,103 @@ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2 + return `${value.toFixed(digits)} ${units[unit]}` +} + +export function formatRate(bytesPerSec: number): string { + if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '0 B/s' + return `${formatBytes(bytesPerSec)}/s` +} + +export function formatUptime(seconds: number): string { + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + if (h > 0) return `${h} 小时 ${m} 分钟` + return `${m} 分钟` +} + +export function formatRelativeTime(iso?: string): string { + if (!iso) return '-' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + const diff = Date.now() - date.getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return '刚刚' + if (minutes < 60) return `${minutes} 分钟前` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours} 小时前` + const days = Math.floor(hours / 24) + return `${days} 天前` +} + +export function formatMs(seconds: number): string { + const ms = Math.round(seconds * 1000) + return `${ms} ms` +} + +export function formatDate(iso?: string): string { + if (!iso) return '-' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */ +export function formatLogTime(value?: string): string { + if (!value) return '-' + + const trimmed = value.trim() + + const standard = trimmed.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/) + if (standard) return standard[1] + + const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/) + if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}:${iso[6]}` + + const nginx = trimmed.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/) + if (nginx) return `${nginx[1]}-${nginx[2]}-${nginx[3]} ${nginx[4]}:${nginx[5]}:${nginx[6]}` + + const date = new Date(trimmed) + if (!Number.isNaN(date.getTime())) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` + } + + return trimmed +} + +/** Normalize leading timestamp in a raw log line */ +export function formatLogLine(line: string): string { + const trimmed = line.trim() + if (!trimmed) return line + + const nginx = trimmed.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2})(.*)$/) + if (nginx) return formatLogTime(nginx[1]) + nginx[2] + + const iso = trimmed.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?)(.*)$/) + if (iso) return formatLogTime(iso[1]) + iso[2] + + if (trimmed.startsWith('{')) { + try { + const raw = JSON.parse(trimmed) as { time?: string } + if (raw.time) return trimmed.replace(raw.time, formatLogTime(raw.time)) + } catch { + // not JSON + } + } + + return trimmed +} diff --git a/app-src/web/src/utils/frp.spec.ts b/app-src/web/src/utils/frp.spec.ts new file mode 100644 index 0000000..b0fa238 --- /dev/null +++ b/app-src/web/src/utils/frp.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { shouldShowFrpHint } from './frp' + +describe('shouldShowFrpHint', () => { + it('配置了服务端且存在启用规则时显示提示', () => { + expect(shouldShowFrpHint({ configured: true, enabled_proxies: 1 })).toBe(true) + }) + + it('未配置服务端时不显示提示', () => { + expect(shouldShowFrpHint({ configured: false, enabled_proxies: 1 })).toBe(false) + }) + + it('配置了服务端但没有启用规则时不显示提示', () => { + expect(shouldShowFrpHint({ configured: true, enabled_proxies: 0 })).toBe(false) + }) + + it('状态为空时不显示提示', () => { + expect(shouldShowFrpHint(undefined)).toBe(false) + }) +}) diff --git a/app-src/web/src/utils/frp.ts b/app-src/web/src/utils/frp.ts new file mode 100644 index 0000000..d9c9c28 --- /dev/null +++ b/app-src/web/src/utils/frp.ts @@ -0,0 +1,6 @@ +import type { FrpStatus } from '../api/types' + +// 只有配置了 FRP 服务端且存在启用规则时,DDNS 页才提示域名应指向 VPS。 +export function shouldShowFrpHint(status: Pick | null | undefined): boolean { + return Boolean(status?.configured && status.enabled_proxies > 0) +} diff --git a/app-src/web/src/utils/logDisplay.ts b/app-src/web/src/utils/logDisplay.ts new file mode 100644 index 0000000..d8b695a --- /dev/null +++ b/app-src/web/src/utils/logDisplay.ts @@ -0,0 +1,165 @@ +import type { SystemLogEntry } from '../api/types' +import { formatLogTime } from './format' + +export type LogTagType = 'success' | 'info' | 'warning' | 'error' | 'default' + +export interface DisplaySystemLogEntry extends SystemLogEntry { + displayLevel: string + displayModule: string +} + +const SYSTEM_LEVEL_LABELS: Record = { + INFO: '信息', + WARN: '警告', + ERROR: '错误', + DEBUG: '调试', +} + +const SYSTEM_MODULE_LABELS: Record = { + SYSTEM: '系统', + NGINX: 'Nginx', + DDNS: '域名解析', + ACME: '证书', + CERT: '证书', + CHINA_CIDR: '中国 IP 段', + API: 'API', + HTTP: 'HTTP', + FRP: '内网穿透', +} + +export function systemLevelLabel(level: string): string { + const upper = level.trim().toUpperCase() + return SYSTEM_LEVEL_LABELS[upper] ?? level +} + +export function systemModuleLabel(module: string): string { + const trimmed = module.trim() + if (!trimmed) return '系统' + const upper = trimmed.toUpperCase() + return SYSTEM_MODULE_LABELS[upper] ?? trimmed +} + +export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry { + const message = entry.message.trim() + const msgLower = message.toLowerCase() + let level = entry.level.trim().toUpperCase() + let displayModule = systemModuleLabel(entry.module) + + if ( + level === 'INFO' && + (msgLower.includes('server error') || + msgLower.includes(' failed') || + msgLower.startsWith('error') || + msgLower.includes('bind:')) + ) { + level = 'ERROR' + } + + return { + ...entry, + displayLevel: systemLevelLabel(level), + displayModule, + } +} + +export interface ParsedNginxError { + time: string + level: string + message: string + raw: string +} + +export function parseNginxErrorLine(line: string): ParsedNginxError { + const raw = line.trim() + const match = raw.match(/^(\d{4}[-/]\d{2}[-/]\d{2} \d{2}:\d{2}:\d{2})\s+\[([^\]]+)\]\s+(.+)$/) + if (!match) { + return { time: '', level: 'unknown', message: raw, raw } + } + return { + time: formatLogTime(match[1]), + level: match[2].toLowerCase(), + message: match[3], + raw, + } +} + +export function nginxLevelTagType(level: string): LogTagType { + switch (level.toLowerCase()) { + case 'emerg': + case 'alert': + case 'crit': + case 'error': + return 'error' + case 'warn': + case 'warning': + return 'warning' + case 'notice': + case 'info': + return 'info' + default: + return 'default' + } +} + +export function systemLevelTagType(level: string): LogTagType { + const upper = level.trim().toUpperCase() + if (upper === 'ERROR' || level === '错误') return 'error' + if (upper === 'WARN' || level === '警告') return 'warning' + if (upper === 'INFO' || level === '信息') return 'info' + return 'default' +} + +export function systemModuleTagType(module: string): LogTagType { + const upper = module.trim().toUpperCase() + switch (upper) { + case 'NGINX': + case 'Nginx': + return 'warning' + case 'SYSTEM': + case '系统': + return 'success' + case 'CHINA_CIDR': + case '中国 IP 段': + return 'info' + case 'DDNS': + case '域名解析': + return 'info' + case 'CERT': + case 'ACME': + case '证书': + return 'warning' + default: + return 'default' + } +} + +export function latencyClass(seconds: number): string { + const ms = seconds * 1000 + if (ms >= 3000) return 'log-duration log-duration--critical' + if (ms >= 1000) return 'log-duration log-duration--slow' + return 'log-duration' +} + +export function streamLineClass(line: string): string { + const parsed = parseNginxErrorLine(line) + if (parsed.level !== 'unknown') { + const type = nginxLevelTagType(parsed.level) + if (type === 'error') return 'level-error' + if (type === 'warning') return 'level-warn' + if (type === 'info') return 'level-info' + } + + const lower = line.toLowerCase() + if (lower.includes('[error]') || lower.includes(' emerg ') || lower.includes(' alert ') || lower.includes(' crit ')) { + return 'level-error' + } + if (lower.includes('[warn]') || lower.includes(' warning ')) return 'level-warn' + + const statusMatch = line.match(/\s(\d{3})\s+\d/) + if (statusMatch) { + const code = Number(statusMatch[1]) + if (code >= 500) return 'level-error' + if (code >= 400) return 'level-warn' + } + return '' +} diff --git a/app-src/web/src/utils/status.spec.ts b/app-src/web/src/utils/status.spec.ts new file mode 100644 index 0000000..48de4fd --- /dev/null +++ b/app-src/web/src/utils/status.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { httpStatusKind, statusKind, statusLabel } from './status' + +describe('statusKind', () => { + it('把后端状态值映射成 StatusKind', () => { + expect(statusKind('ok')).toBe('success') + expect(statusKind('running')).toBe('success') + expect(statusKind('warning')).toBe('warning') + expect(statusKind('error')).toBe('error') + expect(statusKind('stopped')).toBe('disabled') + expect(statusKind('none')).toBe('disabled') + expect(statusKind(undefined)).toBe('unknown') + expect(statusKind('')).toBe('unknown') + }) + + it('回归:n-tag 的 type 值不是状态值,必须落到 unknown', () => { + // 曾把 serverStartupType() 返回的 'success'/'default' 传给 StatusBadge :value, + // 导致服务端详情弹窗「运行中」也显示灰点。 + expect(statusKind('success')).toBe('unknown') + expect(statusKind('default')).toBe('unknown') + }) +}) + +describe('statusLabel', () => { + it('已知值给中文,未知值原样返回', () => { + expect(statusLabel('ok')).toBe('正常') + expect(statusLabel('stopped')).toBe('已停止') + expect(statusLabel('weird')).toBe('weird') + expect(statusLabel()).toBe('未知') + }) +}) + +describe('httpStatusKind', () => { + it('按状态码分段', () => { + expect(httpStatusKind(200)).toBe('success') + expect(httpStatusKind(301)).toBe('unknown') + expect(httpStatusKind(404)).toBe('warning') + expect(httpStatusKind(503)).toBe('error') + }) +}) diff --git a/app-src/web/src/utils/status.ts b/app-src/web/src/utils/status.ts new file mode 100644 index 0000000..6e06148 --- /dev/null +++ b/app-src/web/src/utils/status.ts @@ -0,0 +1,42 @@ +export type StatusKind = 'success' | 'warning' | 'error' | 'disabled' | 'unknown' + +const LABELS: Record = { + ok: '正常', + running: '运行中', + warning: '部分异常', + error: '异常', + disabled: '停用', + none: '未配置', + stopped: '已停止', + unknown: '未知', +} + +export function statusLabel(value?: string): string { + if (!value) return '未知' + return LABELS[value] ?? value +} + +export function statusKind(value?: string): StatusKind { + switch (value) { + case 'ok': + case 'running': + return 'success' + case 'warning': + return 'warning' + case 'error': + return 'error' + case 'disabled': + case 'stopped': + case 'none': + return 'disabled' + default: + return 'unknown' + } +} + +export function httpStatusKind(code: number): StatusKind { + if (code >= 500) return 'error' + if (code >= 400) return 'warning' + if (code >= 300) return 'unknown' + return 'success' +} diff --git a/app-src/web/src/utils/tableActions.ts b/app-src/web/src/utils/tableActions.ts new file mode 100644 index 0000000..636843b --- /dev/null +++ b/app-src/web/src/utils/tableActions.ts @@ -0,0 +1,34 @@ +import { h, type VNode } from 'vue' +import { NButton, NSpace } from 'naive-ui' +import type { ButtonProps } from 'naive-ui' + +export interface TableRowAction { + label: string + onClick: () => void + type?: ButtonProps['type'] + /** 为 false 时隐藏该操作,默认显示 */ + show?: boolean +} + +/** 表格操作列:所有按钮平铺展示,不使用下拉菜单 */ +export function renderTableRowActions(actions: TableRowAction[]): VNode { + const buttons = actions + .filter((action) => action.show !== false) + .map((action) => + h( + NButton, + { + size: 'small', + quaternary: true, + type: action.type, + onClick: (e: MouseEvent) => { + e.stopPropagation() + action.onClick() + }, + }, + () => action.label, + ), + ) + + return h(NSpace, { size: 4, wrap: false }, () => buttons) +} diff --git a/app-src/web/src/utils/version.ts b/app-src/web/src/utils/version.ts new file mode 100644 index 0000000..a83cce6 --- /dev/null +++ b/app-src/web/src/utils/version.ts @@ -0,0 +1,56 @@ +import { GITHUB_REPO } from '../constants/app' + +export interface LatestRelease { + version: string + url: string +} + +function parseVersion(version: string): number[] { + const clean = version.replace(/^v/i, '').split('-')[0] + return clean.split('.').map((part) => Number.parseInt(part, 10) || 0) +} + +export function compareVersions(a: string, b: string): number { + const left = parseVersion(a) + const right = parseVersion(b) + const length = Math.max(left.length, right.length) + + for (let i = 0; i < length; i += 1) { + const diff = (left[i] ?? 0) - (right[i] ?? 0) + if (diff !== 0) { + return diff > 0 ? 1 : -1 + } + } + + return 0 +} + +export function isNewerVersion(latest: string, current: string): boolean { + if (!latest || !current || current === 'dev') { + return false + } + return compareVersions(latest, current) > 0 +} + +export async function fetchLatestRelease(): Promise { + try { + const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { + headers: { Accept: 'application/vnd.github+json' }, + }) + if (!response.ok) { + return null + } + + const data = (await response.json()) as { tag_name?: string; html_url?: string } + if (!data.tag_name || !data.html_url) { + return null + } + + return { + version: data.tag_name.replace(/^v/i, ''), + url: data.html_url, + } + } catch { + return null + } +} diff --git a/app-src/web/src/views/AboutView.vue b/app-src/web/src/views/AboutView.vue new file mode 100644 index 0000000..15b9214 --- /dev/null +++ b/app-src/web/src/views/AboutView.vue @@ -0,0 +1,576 @@ + + + + + diff --git a/app-src/web/src/views/AgentView.vue b/app-src/web/src/views/AgentView.vue new file mode 100644 index 0000000..9b300f2 --- /dev/null +++ b/app-src/web/src/views/AgentView.vue @@ -0,0 +1,2093 @@ + + + + + diff --git a/app-src/web/src/views/CertificatesView.vue b/app-src/web/src/views/CertificatesView.vue new file mode 100644 index 0000000..d79942d --- /dev/null +++ b/app-src/web/src/views/CertificatesView.vue @@ -0,0 +1,1881 @@ + + + + + diff --git a/app-src/web/src/views/CloudflareTunnelConfigView.vue b/app-src/web/src/views/CloudflareTunnelConfigView.vue new file mode 100644 index 0000000..db798fa --- /dev/null +++ b/app-src/web/src/views/CloudflareTunnelConfigView.vue @@ -0,0 +1,713 @@ + + + + + diff --git a/app-src/web/src/views/CloudflareView.vue b/app-src/web/src/views/CloudflareView.vue new file mode 100644 index 0000000..28f256d --- /dev/null +++ b/app-src/web/src/views/CloudflareView.vue @@ -0,0 +1,965 @@ + + + + + diff --git a/app-src/web/src/views/DashboardView.vue b/app-src/web/src/views/DashboardView.vue new file mode 100644 index 0000000..6ae834f --- /dev/null +++ b/app-src/web/src/views/DashboardView.vue @@ -0,0 +1,1815 @@ + + + + + diff --git a/app-src/web/src/views/DdnsView.vue b/app-src/web/src/views/DdnsView.vue new file mode 100644 index 0000000..03350f0 --- /dev/null +++ b/app-src/web/src/views/DdnsView.vue @@ -0,0 +1,1746 @@ + + + + + diff --git a/app-src/web/src/views/FrpView.vue b/app-src/web/src/views/FrpView.vue new file mode 100644 index 0000000..71ffdd7 --- /dev/null +++ b/app-src/web/src/views/FrpView.vue @@ -0,0 +1,1474 @@ + + + + + diff --git a/app-src/web/src/views/LoginView.vue b/app-src/web/src/views/LoginView.vue new file mode 100644 index 0000000..5a82ef5 --- /dev/null +++ b/app-src/web/src/views/LoginView.vue @@ -0,0 +1,216 @@ + + + + + + + + + + diff --git a/app-src/web/src/views/LogsView.vue b/app-src/web/src/views/LogsView.vue new file mode 100644 index 0000000..d08435b --- /dev/null +++ b/app-src/web/src/views/LogsView.vue @@ -0,0 +1,1040 @@ + + + + + diff --git a/app-src/web/src/views/ProxyRemoteView.vue b/app-src/web/src/views/ProxyRemoteView.vue new file mode 100644 index 0000000..e6d8e11 --- /dev/null +++ b/app-src/web/src/views/ProxyRemoteView.vue @@ -0,0 +1,1214 @@ + + + + + diff --git a/app-src/web/src/views/ProxyView.vue b/app-src/web/src/views/ProxyView.vue new file mode 100644 index 0000000..aaebb42 --- /dev/null +++ b/app-src/web/src/views/ProxyView.vue @@ -0,0 +1,3635 @@ + + + + + diff --git a/app-src/web/src/views/SettingsView.vue b/app-src/web/src/views/SettingsView.vue new file mode 100644 index 0000000..03d1060 --- /dev/null +++ b/app-src/web/src/views/SettingsView.vue @@ -0,0 +1,2307 @@ + + + + + diff --git a/app-src/web/src/views/StatusPageView.vue b/app-src/web/src/views/StatusPageView.vue new file mode 100644 index 0000000..72f231e --- /dev/null +++ b/app-src/web/src/views/StatusPageView.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/app-src/web/tsconfig.json b/app-src/web/tsconfig.json new file mode 100644 index 0000000..af40b2d --- /dev/null +++ b/app-src/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/app-src/web/tsconfig.node.json b/app-src/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/app-src/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/app-src/web/vite.config.ts b/app-src/web/vite.config.ts new file mode 100644 index 0000000..1b8c1b5 --- /dev/null +++ b/app-src/web/vite.config.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string } + +export default defineConfig({ + base: process.env.VITE_BASE_PATH || '/', + plugins: [vue()], + define: { + __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version), + }, + server: { + port: 5173, + allowedHosts: true, + proxy: { + '/api': { + target: 'http://localhost:6893', + configure(proxy) { + proxy.on('proxyRes', (proxyRes, req) => { + if (req.url?.includes('/stream')) { + proxyRes.headers['cache-control'] = 'no-cache' + proxyRes.headers['x-accel-buffering'] = 'no' + } + }) + }, + }, + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) diff --git a/build.ps1 b/build.ps1 index b5ec156..9317256 100644 --- a/build.ps1 +++ b/build.ps1 @@ -4,9 +4,8 @@ param( ) $ErrorActionPreference = "Stop" -$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $project = Join-Path $PSScriptRoot "havline" -$mainRepo = Join-Path $root "havline" +$mainRepo = Join-Path $PSScriptRoot "app-src" $version = (Get-Content -Raw (Join-Path $mainRepo "VERSION")).Trim() function Copy-Tree { @@ -27,12 +26,18 @@ if (-not (Test-Path -LiteralPath $project)) { } if (-not $FnpackPath) { - $localTool = Join-Path $root "fnos\fnpack.exe" + $localTool = Join-Path $PSScriptRoot "fnpack.exe" $FnpackPath = if (Test-Path -LiteralPath $localTool) { $localTool } else { "fnpack" } } Push-Location (Join-Path $mainRepo "web") try { + if (-not (Test-Path -LiteralPath (Join-Path (Get-Location) "node_modules"))) { + npm ci + if ($LASTEXITCODE -ne 0) { + throw "安装前端依赖失败,退出码:$LASTEXITCODE" + } + } $env:VITE_BASE_PATH = "/app/havline/" npm run build if ($LASTEXITCODE -ne 0) { diff --git a/fnpack.exe b/fnpack.exe new file mode 100644 index 0000000..f37679e Binary files /dev/null and b/fnpack.exe differ