commit c2498911abe94e820dc1dcd8cd189f009b16facf Author: qwer-xyz <50532012+qwer-xyz@users.noreply.github.com> Date: Sat Jun 20 14:22:31 2026 +0800 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..125e57a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# Dependencies (will be installed in container) +node_modules/ +**/node_modules/ + +# Build outputs (will be built in container) +dist/ +**/dist/ +!agent/dist/ +!agent/dist/incudal-agent-linux-amd64 +!agent/dist/incudal-agent-linux-amd64.gz +!agent/dist/incudal-agent-linux-arm64 +!agent/dist/incudal-agent-linux-arm64.gz +!agent/dist/manifest.json + +# Git +.git/ +.gitignore + +# IDE +.vscode/ +.idea/ +.cursor/ +.kiro/ + +# Environment files +.env +.env.* +server/.env + +# Certificates +server/certs/*.key +server/certs/*.crt +server/certs/*.pem + +# Docs +docs/ +*.md +!README.md + +# Tests +coverage/ +*.test.ts +*.spec.ts + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary +tmp/ +temp/ +.cache/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bdd2288 --- /dev/null +++ b/.env.example @@ -0,0 +1,185 @@ + +根据分析 `docker-compose.yml`、`.env.example` 和代码中使用的环境变量,以下是完整的环境变量清单: + +--- + +## Docker Compose 部署环境变量 + +### 必需变量(必须配置) + +| 变量名 | 说明 | 示例值 | 生成方式 | +|--------|------|--------|----------| +| `POSTGRES_PASSWORD` | PostgreSQL 数据库密码 | `BJ4hbYQTLsz97zzJUGg8RFiq` | `openssl rand -base64 24` | +| `JWT_SECRET` | JWT 签名密钥(≥32字符,至少 3 类字符) | `A1!b7e9c4d0...` | `printf 'A1!%s\n' "$(openssl rand -hex 48)" \| cut -c1-64` | +| `ENCRYPTION_KEY` | 敏感数据加密密钥(≥32字符,需稳定保存) | `XpPc3tcVSmy4/...` | `openssl rand -base64 32` | + +--- + +### 可选变量(有默认值) + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `POSTGRES_USER` | 数据库用户名 | `incudal` | +| `POSTGRES_DB` | 数据库名称 | `incudal` | +| `REDIS_PASSWORD` | Redis 密码 | 空(无密码) | +| `APP_PORT` | 外部暴露端口 | `3000` | +| `ADMIN_PASSWORD` | 首次初始化管理员密码 | `admin123` | +| `LOG_LEVEL` | 日志级别 | `info` | +| `DISABLE_REQUEST_LOG` | 禁用请求日志 | `true` | +| `COOKIE_SECRET` | Cookie 签名密钥 | 空 | +| `FRONTEND_URL` | 前端 URL(CORS 白名单 + 页面跳转) | 空 | +| `PAYMENT_CALLBACK_BASE_URL` | 支付回调使用的后端公网 URL | 空(默认回退到 `FRONTEND_URL`) | +| `PAYMENT_CALLBACK_IP_WHITELIST` | 支付回调来源 IP 白名单(逗号分隔,留空时 Heleket 默认收紧到官方 IP) | 空 | +| `PAYMENT_CALLBACK_SKIP_IP_WHITELIST` | 跳过支付回调 IP 白名单校验(仅开发调试) | `false` | +| `ALERT_WEBHOOK_URL` | 系统告警 Webhook URL | 空 | +| `DB_POOL_MAX` | PostgreSQL 连接池最大连接数 | `20` | +| `DB_POOL_MIN` | PostgreSQL 连接池最小连接数 | `5` | +| `DB_CONNECTION_TIMEOUT` | 获取数据库连接超时(毫秒) | `5000` | +| `DB_IDLE_TIMEOUT` | 数据库空闲连接超时(毫秒) | `30000` | +| `DB_STATEMENT_TIMEOUT` | PostgreSQL 语句超时(毫秒) | `30000` | +| `DB_QUERY_TIMEOUT` | PostgreSQL 查询超时(毫秒) | `30000` | +| `TRAFFIC_CONCURRENCY_PER_HOST` | 每宿主机流量采集并发数 | `10` | +| `TRAFFIC_HOST_CONCURRENCY` | 同时处理宿主机数量 | `3` | +| `DB_WORKER_BACKOFF_MS` | worker 遇到数据库异常后的退避时间(毫秒) | `15000` | + +--- + +### 最小 .env 配置示例 + +可以直接自动生成或补齐缺失项: + +```bash +bash scripts/init-env.sh +``` + +脚本只补齐缺失或空值的变量,不覆盖已有非空配置。 + +```bash +# ============ 必需配置 ============ +# 数据库密码 - 使用 openssl rand -base64 24 生成 +POSTGRES_PASSWORD=your_strong_password_here + +# JWT 密钥 - 使用下面命令生成: +# printf 'A1!%s\n' "$(openssl rand -hex 48)" | cut -c1-64 +JWT_SECRET=your_mixed_64_char_secret_here + +# 敏感数据加密密钥 - 使用 openssl rand -base64 32 生成 +# 注意:上线后必须稳定保存,不能随意更换 +ENCRYPTION_KEY=your_base64_32_byte_encryption_key_here + +# ============ 建议配置 ============ +# 管理员初始密码(首次启动后建议立即修改) +ADMIN_PASSWORD=your_admin_password + +# Redis 密码(可选但推荐) +REDIS_PASSWORD=your_redis_password +``` + +--- + +### 完整 .env 配置示例(生产环境推荐) + +```bash +# ============ 数据库配置 ============ +POSTGRES_USER=incudal +POSTGRES_PASSWORD=BJ4hbYQTLsz97zzJUGg8RFiq +POSTGRES_DB=incudal + +# ============ Redis 配置 ============ +REDIS_PASSWORD=redis_password_here + +# ============ 安全配置(必须修改!)============ +# JWT 密钥 - 生成方式: +# printf 'A1!%s\n' "$(openssl rand -hex 48)" | cut -c1-64 +JWT_SECRET=A1!b7e9c4d0f2a8e6b9d1c3f5a7b8d0e2c4f6a9b1d3e5c7a8f0b2d4e6c8 + +# Cookie 签名密钥(可选,增强安全性) +COOKIE_SECRET=another_random_string_here + +# 敏感数据加密密钥(2FA 等)- 生成方式: openssl rand -base64 32 +ENCRYPTION_KEY=XpPc3tcVSmy4/pxpLL40jVfMDJyayR+bE1ibamEWlys= + +# ============ 应用配置 ============ +APP_PORT=3000 +ADMIN_PASSWORD=QpXEZrBkDosvu7TFMcAJjbv7 +LOG_LEVEL=info +DISABLE_REQUEST_LOG=true + +# ============ CORS 配置 + 页面跳转(必配!)============ +FRONTEND_URL=https://your-domain.com + +# ============ 支付回调(前后端分域时必配)============ +# 第三方支付平台回调到后端时使用的公网地址 +# 例如前端为 https://panel.example.com,后端为 https://api.example.com +# 则这里应配置为 https://api.example.com +PAYMENT_CALLBACK_BASE_URL=https://api.your-domain.com + +# 支付回调来源 IP 白名单(逗号分隔) +# 留空时 Heleket 默认仅允许官方文档中的 webhook IP:31.133.220.8 +# PAYMENT_CALLBACK_IP_WHITELIST=31.133.220.8 + +# 仅开发调试时可临时跳过支付回调 IP 白名单校验 +# PAYMENT_CALLBACK_SKIP_IP_WHITELIST=false + +# ============ 监控告警(可选)============ +# 系统异常时发送 Webhook 告警 +# ALERT_WEBHOOK_URL=https://your-webhook-url + +# ============ 数据库稳定性调优(可选)============ +DB_POOL_MAX=30 +DB_POOL_MIN=2 +DB_CONNECTION_TIMEOUT=10000 +DB_IDLE_TIMEOUT=15000 +DB_STATEMENT_TIMEOUT=30000 +DB_QUERY_TIMEOUT=30000 +TRAFFIC_CONCURRENCY_PER_HOST=10 +TRAFFIC_HOST_CONCURRENCY=3 +DB_WORKER_BACKOFF_MS=15000 +``` + +--- + +### 部署命令 + +```bash +# 1. 创建 .env 文件 +cp .env.example .env + +# 2. 编辑 .env,配置上述变量 +nano .env + +# 3. 启动服务 +docker-compose up -d + +# 4. 查看日志 +docker-compose logs -f app +``` + +--- + +### 变量来源说明 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 环境变量流向图 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ .env 文件 │ +│ │ │ +│ ├─── POSTGRES_* ──────► db 容器 (PostgreSQL) │ +│ │ │ +│ ├─── REDIS_PASSWORD ──► redis 容器 │ +│ │ │ +│ └─── 其他变量 ────────► app 容器 (Incudal) │ +│ │ │ +│ ├── DATABASE_URL (自动组装) │ +│ ├── REDIS_URL (自动组装) │ +│ ├── JWT_SECRET │ +│ ├── ENCRYPTION_KEY │ +│ ├── ADMIN_PASSWORD │ +│ └── LOG_LEVEL 等 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**重点**:`DATABASE_URL` 和 `REDIS_URL` 由 docker-compose.yml 自动组装,无需在 .env 中配置。 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..96407ee --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Shell scripts must always use LF +*.sh text eol=lf + +# Docker files must use LF +Dockerfile text eol=lf +docker-compose*.yml text eol=lf + +# Keep Windows batch files with CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml new file mode 100644 index 0000000..2f0ac4e --- /dev/null +++ b/.github/workflows/agent-release.yml @@ -0,0 +1,190 @@ +name: Agent Build & Release + +on: + push: + branches: + - main + - dev + paths: + - 'agent/**' + - 'server/templates/agent-install.sh' + - 'server/src/routes/agent.ts' + - 'server/src/lib/agent-auth.ts' + - 'server/src/lib/host-agent-credentials.ts' + - 'server/src/services/agent-instance-report.ts' + - '.github/workflows/agent-release.yml' + pull_request: + branches: + - main + paths: + - 'agent/**' + - 'server/templates/agent-install.sh' + - 'server/src/routes/agent.ts' + - 'server/src/lib/agent-auth.ts' + - 'server/src/lib/host-agent-credentials.ts' + - 'server/src/services/agent-instance-report.ts' + - '.github/workflows/agent-release.yml' + workflow_dispatch: + inputs: + publish_release: + description: 'Publish an Agent GitHub Release using the version in agent/VERSION.' + required: false + default: false + type: boolean + +env: + GO_VERSION: '1.22.x' + +jobs: + version: + name: Resolve Agent Version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + version_changed: ${{ steps.changed.outputs.version_changed }} + should_release: ${{ steps.release.outputs.should_release }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read Agent version + id: version + shell: bash + run: | + set -euo pipefail + VERSION="$(tr -d '[:space:]' < agent/VERSION)" + + if [[ ! "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid Agent version: ${VERSION}" >&2 + echo "Expected vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-suffix" >&2 + exit 1 + fi + + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "Agent version: ${VERSION}" + + - name: Detect agent/VERSION change + id: changed + shell: bash + run: | + set -euo pipefail + + VERSION_CHANGED=false + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + BEFORE="${{ github.event.before }}" + AFTER="${{ github.sha }}" + + if [[ -z "${BEFORE}" || "${BEFORE}" =~ ^0+$ ]]; then + VERSION_CHANGED=true + elif git diff --name-only "${BEFORE}" "${AFTER}" | grep -qx 'agent/VERSION'; then + VERSION_CHANGED=true + fi + fi + + echo "version_changed=${VERSION_CHANGED}" >> "${GITHUB_OUTPUT}" + echo "agent/VERSION changed: ${VERSION_CHANGED}" + + - name: Decide release publishing + id: release + shell: bash + run: | + set -euo pipefail + + SHOULD_RELEASE=false + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${{ steps.changed.outputs.version_changed }}" == "true" ]]; then + SHOULD_RELEASE=true + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${{ inputs.publish_release }}" == "true" ]]; then + SHOULD_RELEASE=true + fi + + echo "should_release=${SHOULD_RELEASE}" >> "${GITHUB_OUTPUT}" + echo "should release: ${SHOULD_RELEASE}" + + build-agent: + name: Build Agent + runs-on: ubuntu-latest + needs: + - version + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: agent/go.mod + + - name: Test Agent + working-directory: agent + run: go test ./... + + - name: Build Agent release files + run: VERSION="${{ needs.version.outputs.version }}" bash agent/scripts/build-release.sh + + - name: Prepare release assets + shell: bash + run: | + set -euo pipefail + VERSION="${{ needs.version.outputs.version }}" + mkdir -p agent/release + + cp agent/dist/incudal-agent-linux-amd64 "agent/release/incudal-agent-x86_64-${VERSION}" + cp agent/dist/incudal-agent-linux-arm64 "agent/release/incudal-agent-aarch64-${VERSION}" + + - name: Verify release assets + shell: bash + run: | + set -euo pipefail + VERSION="${{ needs.version.outputs.version }}" + test -s "agent/release/incudal-agent-x86_64-${VERSION}" + test -s "agent/release/incudal-agent-aarch64-${VERSION}" + + - name: Upload Agent artifacts + uses: actions/upload-artifact@v4 + with: + name: incudal-agent-${{ needs.version.outputs.version }} + path: | + agent/release/incudal-agent-x86_64-${{ needs.version.outputs.version }} + agent/release/incudal-agent-aarch64-${{ needs.version.outputs.version }} + retention-days: 30 + + release-agent: + name: Publish Agent Release + runs-on: ubuntu-latest + needs: + - version + - build-agent + if: needs.version.outputs.should_release == 'true' + permissions: + contents: write + + steps: + - name: Download Agent artifacts + uses: actions/download-artifact@v4 + with: + name: incudal-agent-${{ needs.version.outputs.version }} + path: agent-release + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: agent-${{ needs.version.outputs.version }} + target_commitish: ${{ github.sha }} + name: Incudal Agent ${{ needs.version.outputs.version }} + files: | + agent-release/incudal-agent-x86_64-${{ needs.version.outputs.version }} + agent-release/incudal-agent-aarch64-${{ needs.version.outputs.version }} + generate_release_notes: true + draft: false + prerelease: ${{ contains(needs.version.outputs.version, '-') }} + fail_on_unmatched_files: true + overwrite_files: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1d0ae42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + lint-and-typecheck: + name: Lint & Type Check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Generate Prisma Client + run: pnpm --filter server exec prisma generate + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/db + + - name: Lint + run: pnpm lint + + - name: Type check (client) + run: pnpm --filter client type-check + + - name: Type check (server) + run: pnpm --filter server type-check + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + cache-dependency-path: agent/go.mod + + - name: Test Agent + working-directory: agent + run: go test ./... + + build: + name: Build + runs-on: ubuntu-latest + needs: lint-and-typecheck + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Generate Prisma Client + run: pnpm --filter server exec prisma generate + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/db + + - name: Build + run: pnpm build diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..0799b0b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,65 @@ +name: Docker Build & Push + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Image tag (e.g., latest, v1.0.0)' + required: false + default: 'latest' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build & Push Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # For tags: v1.0.0 -> 1.0.0, latest + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + # For manual trigger + type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + # Always tag latest for version tags + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c448902 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,257 @@ +# ============================================================================ +# Incudal 预构建产物包发布工作流 +# 在推送版本 tag (v*) 或手动触发时,自动构建前后端并打包为 .tar.gz 发行包 +# 支持 amd64 和 arm64 双架构 +# ============================================================================ + +name: Build & Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: '发行版本号(例如 v1.0.0),留空则使用 git SHA 作为预览版' + required: false + default: '' + +env: + NODE_VERSION: '22' + +jobs: + # ===== 构建发行包(多架构矩阵) ===== + build: + name: 构建 (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + permissions: + contents: write + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装 pnpm + uses: pnpm/action-setup@v4 + + - name: 安装 Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + # ===== 安装依赖 ===== + - name: 安装全部依赖 + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: 重新构建 esbuild(Vite 构建需要) + run: pnpm rebuild esbuild + + # ===== 生成 Prisma Client ===== + - name: 生成 Prisma Client + run: pnpm --filter server exec prisma generate + env: + DATABASE_URL: postgresql://user:pass@localhost:5432/db + + # ===== 构建前端 ===== + - name: 构建前端 (Vite) + run: pnpm --filter client build + + # ===== 构建后端 ===== + - name: 编译后端 (TypeScript) + run: pnpm --filter server build + + # ===== 准备发行目录 ===== + - name: 准备发行目录结构 + run: | + mkdir -p release/client/dist + mkdir -p release/server + + # 复制前端构建产物 + cp -r client/dist/* release/client/dist/ + + # 复制后端构建产物 + cp -r server/dist release/server/ + cp -r server/prisma release/server/ + cp server/prisma.config.ts release/server/ + cp server/package.json release/server/ + cp -r server/templates release/server/ + + # 复制根配置文件 + cp package.json release/ + cp pnpm-workspace.yaml release/ + + # ===== 安装生产依赖 ===== + - name: 安装后端生产依赖 + working-directory: release/server + run: | + # 在发行目录中只安装生产依赖 + npm install --omit=dev --ignore-scripts + + # 在当前架构的 runner 上原生生成 Prisma Client + DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate + + # ===== 创建启动辅助脚本 ===== + - name: 创建管理脚本 + run: | + cat > release/incudal.sh << 'SCRIPT' + #!/bin/bash + # Incudal 快捷管理脚本 + set -e + APP_DIR="$(cd "$(dirname "$0")" && pwd)" + + case "${1:-}" in + start) + echo "🚀 启动 Incudal..." + cd "$APP_DIR/server" + npx prisma migrate deploy + cd "$APP_DIR" + exec node server/dist/app.js + ;; + migrate) + echo "🔄 执行数据库迁移..." + cd "$APP_DIR/server" + npx prisma migrate deploy + echo "✅ 迁移完成" + ;; + version) + echo "Incudal $(node -e "console.log(require('./package.json').version)")" + ;; + *) + echo "用法: $0 {start|migrate|version}" + exit 1 + ;; + esac + SCRIPT + chmod +x release/incudal.sh + + # ===== 确定版本号 ===== + - name: 确定版本号 + id: version + run: | + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then + VERSION="${{ github.ref_name }}" + elif [[ -n "${{ github.event.inputs.tag }}" ]]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="preview-$(echo ${{ github.sha }} | head -c 7)" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "📦 版本号: $VERSION" + + # ===== 打包 ===== + - name: 打包为 tar.gz + run: | + VERSION="${{ steps.version.outputs.version }}" + cd release + tar -czf "../incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz" . + cd .. + ls -lh "incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz" + echo "📦 发行包大小: $(du -h "incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz" | cut -f1)" + + # ===== 上传构建产物 ===== + - name: 上传构建产物 + uses: actions/upload-artifact@v4 + with: + name: incudal-${{ steps.version.outputs.version }}-linux-${{ matrix.arch }} + path: incudal-${{ steps.version.outputs.version }}-linux-${{ matrix.arch }}.tar.gz + retention-days: 30 + + # ===== 发布 Release(等待所有架构构建完成) ===== + release: + name: 发布 Release + needs: + - build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: 确定版本号 + id: version + run: echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + + # 下载所有架构的构建产物 + - name: 下载 amd64 产物 + uses: actions/download-artifact@v4 + with: + name: incudal-${{ steps.version.outputs.version }}-linux-amd64 + + - name: 下载 arm64 产物 + uses: actions/download-artifact@v4 + with: + name: incudal-${{ steps.version.outputs.version }}-linux-arm64 + + - name: 列出发行文件 + run: ls -lh incudal-*.tar.gz + + # 创建 Release 并上传所有架构的产物 + - name: 创建 GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + incudal-*.tar.gz + generate_release_notes: true + draft: false + prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }} + + # ===== 构建并推送 Docker 镜像(多架构) ===== + docker: + name: 构建 Docker 镜像 + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + # 设置 QEMU(用于跨架构构建 arm64) + - name: 设置 QEMU + uses: docker/setup-qemu-action@v3 + + # 设置 Docker Buildx(多架构构建引擎) + - name: 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + # 登录 GitHub Container Registry + - name: 登录 GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # 提取版本号和元数据 + - name: 提取 Docker 元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + # 构建并推送多架构镜像 + - name: 构建并推送 Docker 镜像 + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..348dd9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,93 @@ +# ===================================================== +# Incudal 项目 .gitignore +# ===================================================== + +# ==================== 依赖 ==================== +node_modules/ +.pnpm-store/ + +# ==================== 构建产物 ==================== +dist/ +build/ +client/dist/ +server/dist/ +!agent/dist/ +!agent/dist/incudal-agent-linux-amd64 +!agent/dist/incudal-agent-linux-amd64.gz +!agent/dist/incudal-agent-linux-arm64 +!agent/dist/incudal-agent-linux-arm64.gz + +# ==================== 环境变量 ==================== +.env +.env.local +.env.*.local +server/.env + +# ==================== Prisma ==================== +server/src/generated/ +**/generated/prisma/ + +# ==================== 日志 ==================== +logs/ +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# ==================== IDE/编辑器 ==================== +.vscode/ +.idea/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project + +# ==================== 操作系统 ==================== +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# ==================== 证书 (敏感) ==================== +server/certs/*.key +server/certs/*.crt +server/certs/*.pem +!server/certs/.gitkeep + +# ==================== 数据库 ==================== +*.db +*.sqlite +*.sqlite3 + +# ==================== Docker ==================== +.docker/ + +# ==================== 临时文件 ==================== +tmp/ +temp/ +.cache/ +*.tmp +*.temp +*.pid + +# ==================== Vite ==================== +client/node_modules/.vite/ +client/node_modules/.vite-temp/ + +# ==================== TypeScript ==================== +*.tsbuildinfo + +# ==================== 测试覆盖率 ==================== +coverage/ +.nyc_output/ + +# ==================== 文档 (内部) ==================== +docs/ +SDK/ +SDKNEW/ +namecrane_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..941ccbb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,85 @@ +# ===================================================== +# Incudal 多阶段构建 Dockerfile +# ===================================================== + +# Stage 1: 依赖安装 +FROM node:22-alpine AS deps +RUN corepack enable && corepack prepare pnpm@9 --activate +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY client/package.json ./client/ +COPY server/package.json ./server/ + +RUN pnpm install --frozen-lockfile --ignore-scripts + +# esbuild 依赖安装脚本不能被跳过,否则 Vite 在构建阶段无法启动二进制 +RUN pnpm rebuild esbuild + +# Stage 2: 构建前端 +FROM node:22-alpine AS builder-client +RUN corepack enable && corepack prepare pnpm@9 --activate +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/client/node_modules ./client/node_modules +COPY client ./client +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +RUN pnpm --filter client build + +# Stage 3: 构建后端 +FROM node:22-alpine AS builder-server +RUN corepack enable && corepack prepare pnpm@9 --activate +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/server/node_modules ./server/node_modules +COPY server ./server +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +# 生成 Prisma Client 并构建 +WORKDIR /app/server +RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate +WORKDIR /app +RUN pnpm --filter server build + +# Stage 4: 生产镜像 +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +# 创建非 root 用户 +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 incudal + +# 复制依赖 (包含生成的 Prisma Client) +COPY --from=builder-server /app/node_modules ./node_modules +COPY --from=builder-server /app/server/node_modules ./server/node_modules + +# 复制构建产物 +COPY --from=builder-server /app/server/dist ./server/dist +COPY --from=builder-client /app/client/dist ./client/dist + +# 复制必要配置文件 +COPY server/package.json ./server/ +COPY server/prisma.config.ts ./server/ +COPY server/prisma ./server/prisma +COPY server/templates ./server/templates +COPY server/scripts ./server/scripts +COPY server/src ./server/src +COPY package.json pnpm-workspace.yaml ./ + +# 复制启动脚本 +COPY server/docker-entrypoint.sh ./server/ +RUN chmod +x ./server/docker-entrypoint.sh + +# 创建证书目录 +RUN mkdir -p server/certs && chown -R incudal:nodejs server/certs + +USER incudal + +EXPOSE 3000 + +ENTRYPOINT ["./server/docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68fc42b --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, qwer-xyz + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fed7b7 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +

Incudal logo Incudal

+ +

基于 Incus 的 LXC / KVM NAT VPS 销售、交付与管理面板。

+ +## 项目简介 + +Incudal 基于 Incus 的 NAT VPS 销售与管理面板。 +项目支持 LXC / KVM 实例、套餐与镜像管理、账务计费、节点托管、用户后台、管理员后台以及宿主机 Agent。 + +> 演示站:https://demo.incudal.com
+> 仅供学习与参考,本项目存在诸多不完善之处。有任何问题建议 Fork 后使用 AI 解决。
+ +## 主要功能 + +- 实例交付:基于 Incus 创建和管理 LXC / KVM 实例,支持 NAT 网络、系统镜像、套餐资源和节点绑定。 +- 平台运营:提供用户端与管理员后台,覆盖用户、套餐、镜像、节点、工单、公告、日志和系统配置等管理流程。 +- 计费与权益:支持余额、充值、消费记录、托管收益、支付渠道、积分、VIP 等级和会员福利。 +- 节点与扩展:通过宿主机 Agent 上报资源与状态,并支持托管节点、反代建站、邮箱服务等扩展能力。 + +## 目录结构 + +```text +client/ Vue 3 + Vite 前端 +server/ Fastify + Prisma 后端 +agent/ Go 宿主机 Agent +server/prisma/ 数据库 schema 与 migrations +server/templates/ 安装脚本、邮件等模板 +.github/workflows/ CI、Docker、Agent Release 工作流 +scripts/ 本地开发和检查脚本 +``` + +## 搭建教程 + +当前无论使用哪种部署方式,都需要在 `server/certs` 目录下生成或放置证书与密钥,并确保 Docker 容器具有读取权限。 + +### 方式一:Docker Compose 开发环境 + +适合本地试跑和开发预览。该方式会自动启动 Node、PostgreSQL 和 Redis,并自动安装依赖、执行数据库迁移。 + +```bash +git clone https://github.com/qwer-xyz/incudal.git +cd incudal +docker compose -f docker-compose.dev.yml up +``` + +启动后访问: + +```text +前端:http://127.0.0.1:43173 +后端:http://127.0.0.1:8888 +``` + +停止服务: + +```bash +docker compose -f docker-compose.dev.yml down +``` + +如需同时删除本地开发数据库卷: + +```bash +docker compose -f docker-compose.dev.yml down -v +``` + +### 方式二:生产镜像部署 + +生产环境建议准备独立 PostgreSQL 和 Redis,然后构建并运行镜像。 + +```bash +docker build -t incudal:local . +docker run -d --name incudal \ + -p 3000:3000 \ + --env-file .env \ + incudal:local +``` + +生产环境至少需要配置: + +```env +NODE_ENV=production +PORT=3000 +DATABASE_URL=postgresql://user:password@postgres:5432/incudal +REDIS_URL=redis://redis:6379 +JWT_SECRET=please-change-to-a-long-random-secret +FRONTEND_URL=https://your-domain.example +``` + +容器启动时会执行 `prisma migrate deploy`,然后启动后端服务。生产部署前请确保数据库可连接、`JWT_SECRET` 足够随机、反向代理和 HTTPS 已配置好。 + +Agent 正式二进制不存放在面板仓库内,面板运行时会从 GitHub Release 查询和代理下载。如部署在私有仓库或 fork,可按需配置: + +```env +INCUDAL_AGENT_RELEASE_REPOSITORY=qwer-xyz/incudal +INCUDAL_AGENT_RELEASE_TOKEN=github_pat_xxx +``` + +## 开发教程 + +### 本地依赖 + +- Node.js 20 或更高版本 +- pnpm 9.14.2 +- PostgreSQL +- Redis +- Go 1.22 或更高版本,仅开发 Agent 时需要 + +启用 pnpm: + +```bash +corepack enable +corepack prepare pnpm@9.14.2 --activate +pnpm install +``` + +配置本地环境变量后执行数据库迁移: + +```bash +pnpm --filter server exec prisma migrate deploy +``` + +启动前后端开发服务: + +```bash +pnpm dev +``` + +默认开发端口: + +```text +client: http://127.0.0.1:5173 +server: http://127.0.0.1:8888 +``` + +### 常用命令 + +```bash +pnpm --filter client type-check +pnpm --filter server type-check +pnpm build +pnpm lint +``` + +本地检查: + +```shell +# Windows +.\scripts\local-ci.ps1 +# macOS +.\scripts\local-ci-macos.sh +``` + +### 数据库开发 + +Prisma schema 位于 `server/prisma/schema.prisma`,迁移文件位于 `server/prisma/migrations/`。修改数据库结构后,应生成迁移并确认前后端类型检查通过。 + +常用命令: + +```bash +pnpm --filter server exec prisma generate +pnpm --filter server exec prisma migrate dev +pnpm --filter server exec prisma migrate deploy +``` + +### Agent 开发与发布 + +Agent 位于 `agent/`,版本号统一由 `agent/VERSION` 控制。 + +本地测试: + +```bash +cd agent +go test ./... +go run ./cmd/incudal-agent -config ./config.example.yaml -once +``` + +本地构建双架构产物: + +```bash +bash agent/scripts/build-release.sh +``` + +`agent/dist` 只是本地临时构建目录,不提交到 Git。正式发布由 GitHub Actions `Agent Build & Release` 完成:当 `agent/VERSION` 变动并推送后,会发布 GitHub Release,并生成: + +```text +incudal-agent-x86_64-v0.0.1 +incudal-agent-aarch64-v0.0.1 +``` + +## 开发约定 + +- 前端新增文案需要同步维护 `client/src/locales/` 下的多语言键。 +- 后端新增管理接口应使用登录鉴权和管理员鉴权,并补充必要的字段校验和速率限制。 +- 数据库变更需要提交 Prisma migration,不直接修改生产库结构。 +- 不提交构建产物、临时文件、密钥、数据库 dump 或本地 `.env`。 diff --git a/TransparentProxy.md b/TransparentProxy.md new file mode 100644 index 0000000..2fac5ca --- /dev/null +++ b/TransparentProxy.md @@ -0,0 +1,104 @@ +针对 Incus/LXC 容器环境的 dae 透明代理。 + +这套方案实现了:宿主机自身流量直连、接管容器出站流量走出口节点、兼容容器入站端口映射、规避节点不支持 UDP 导致的断网。 + +--- + +### 部署流程 (适用于 Ubuntu 24.04 + Incus) + +#### 第一步 + +默认情况下,Incus 容器网桥内的流量走的是二层转发,会绕过大多的网络层探针。为了让 `dae` 能抓到容器发出的 TCP 握手包,必须开启网桥的三层过滤。 + +在宿主机执行以下命令: + +```bash +# 1. 确保 Incus 网桥保持默认的 NAT 开启状态 (保证入站映射正常) +incus network set incusbr0 ipv4.nat=true + +# 2. 加载网桥过滤模块 +sudo modprobe br_netfilter + +# 3. 写入内核参数,强制网桥流量经过 iptables (进而被 dae 的 eBPF 探针捕获) +echo "net.bridge.bridge-nf-call-iptables = 1" | sudo tee /etc/sysctl.d/99-bridge-nf.conf +echo "net.bridge.bridge-nf-call-ip6tables = 1" | sudo tee -a /etc/sysctl.d/99-bridge-nf.conf +echo "net.bridge.bridge-nf-call-arptables = 1" | sudo tee -a /etc/sysctl.d/99-bridge-nf.conf + +# 4. 生效配置 +sudo sysctl -p /etc/sysctl.d/99-bridge-nf.conf +``` + +#### 第二步:安装 dae + +```bash +sudo apt update && sudo apt install curl -y +sudo bash -c "$(curl -sL https://github.com/daeuniverse/dae-installer/raw/main/installer.sh)" +``` + +#### 第三步 + +清空并编辑 `/usr/local/etc/dae/config.dae`,将以下内容粘贴进去。 + +```dae +global { + tproxy_port: 8321 + tproxy_port_protect: true + wan_interface: auto + + # 绑定 Incus 网桥 + lan_interface: incusbr0 + + auto_config_kernel_parameter: true +} + +node { + # 落地节点 + node1: '' +} + +group { + my_upstream { + filter: name(node1) + policy: fixed(0) + } +} + +dns { + upstream { + # 强制使用 tcp 协议查 DNS,完美解决代理节点不支持 UDP 导致的 DNS 解析超时黑洞 + cloudflare: 'tcp://1.1.1.1:53' + } + routing { + request { + fallback: cloudflare + } + } +} + +routing { + # 1. 内网互通与组播放行 + dip(224.0.0.0/3, 'ff00::/8') -> direct + dip(geoip:private) -> direct + + # 2. 宿主机防失联 (SSH) + dport(22, 2551) -> must_direct + sport(22, 2551) -> must_direct + + # 3. 兼容 NAT 端口映射的回程放行 + sport(80, 443, 3306) -> must_direct + + # 4. 劫持容器网段 + sip(10.10.0.0/22) -> my_upstream + + # 5. 宿主机自身流量及未匹配流量兜底直连 + fallback: direct +} +``` + +#### 第四步:启动 + +```bash +sudo systemctl daemon-reload +sudo systemctl restart dae +sudo systemctl enable dae +``` diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..bb8a5e0 --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,4 @@ +/incudal-agent +/dist/* +*.test +coverage.out diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..228c915 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,174 @@ +# Incudal Host Agent + +宿主机 Agent 客户端实现。 + +当前阶段负责读取配置、向面板上报 HMAC 签名心跳,并按面板心跳响应执行自动升级。 + +## 配置 + +默认配置路径: + +```bash +/etc/incudal-agent/config.yaml +``` + +示例: + +```yaml +panel_url: "https://idev.bitpd.com" +agent_id: "agt_xxx" +agent_secret: "ias_xxx" +heartbeat_interval_seconds: 30 +request_timeout_seconds: 10 +``` + +也可以使用环境变量覆盖: + +```bash +INCUDAL_PANEL_URL= +INCUDAL_AGENT_ID= +INCUDAL_AGENT_SECRET= +INCUDAL_HEARTBEAT_INTERVAL_SECONDS= +INCUDAL_REQUEST_TIMEOUT_SECONDS= +``` + +## 运行 + +单次心跳测试: + +```bash +go run ./cmd/incudal-agent -config ./config.example.yaml -once +``` + +循环心跳: + +```bash +go run ./cmd/incudal-agent -config /etc/incudal-agent/config.yaml +``` + +## 当前能力 + +- 读取 Agent 配置 +- 采集 CPU 数量和内存总量 +- 探测常见 Incus/LXD Unix socket +- 生成 canonical JSON body hash +- 生成 HMAC-SHA256 签名 +- 调用 `POST /api/agent/heartbeat` +- 读取心跳响应中的 `upgrade` 指令并自动升级自身 + +当前不执行实例创建、销毁、启停等下发任务。 + +## 自动升级 + +面板会在 Agent 心跳响应中返回升级指令: + +```json +{ + "upgrade": { + "available": true, + "version": "v1.0.1", + "url": "https:///api/agent/binary/incudal-agent-linux-amd64?v=v1.0.1", + "sha256": "", + "gzip": false + } +} +``` + +Agent 只接受当前 `panel_url` 同源下载地址。下载后先校验 SHA-256,再解包、写入临时文件、备份旧二进制、原子替换并执行 `systemctl restart incudal-agent`。 + +`-once` 单次心跳测试模式不会执行自动升级,避免安装前置检测阶段替换正在测试的二进制。 + +旧版本 Agent 不包含升级执行器,首次启用自动升级时仍需要通过面板安装命令或重新安装按钮部署一次新版 Agent;之后才会按心跳响应自动升级。 + +## Release 构建与发布 + +本地构建双架构二进制: + +```bash +cd .. +bash agent/scripts/build-release.sh +``` + +Agent 版本统一从 `agent/VERSION` 读取,格式固定为 `vMAJOR.MINOR.PATCH`。 +需要发布新版 Agent 时,先递增 `agent/VERSION`,再构建 release 产物。 + +产物: + +```text +agent/dist/incudal-agent-linux-amd64 +agent/dist/incudal-agent-linux-arm64 +agent/dist/manifest.json +``` + +`agent/dist` 是本地临时构建目录,不再纳入 Git。正式发布由 GitHub Actions `Agent Build & Release` 完成。 + +推送中只要 `agent/VERSION` 发生变化,Actions 会读取该版本号,构建并发布 GitHub Release: + +```text +tag: agent-v0.0.1 +assets: + incudal-agent-x86_64-v0.0.1 + incudal-agent-aarch64-v0.0.1 +``` + +面板运行时不会读取本地 `agent/dist`。它会从 GitHub Release 查询最新 Agent 版本,动态生成 `/api/agent/manifest.json`,并通过 `/api/agent/binary/*` 代理下载对应 Release 资产。 + +默认 GitHub Release 仓库为 `qwer-xyz/incudal_classic`。如果部署到 fork 或私有仓库,可设置: + +```bash +INCUDAL_AGENT_RELEASE_REPOSITORY="owner/repo" +INCUDAL_AGENT_RELEASE_TOKEN="github_pat_xxx" # 私有仓库需要 +``` + +## 安装脚本 + +面板提供通用安装脚本: + +```bash +curl -fsSL "$PANEL_URL/api/agent/install.sh" | sudo env \ + INCUDAL_PANEL_URL="$PANEL_URL" \ + INCUDAL_AGENT_INSTALL_TOKEN="$AGENT_INSTALL_TOKEN" \ + INCUDAL_AGENT_BINARY_URL="$BINARY_URL" \ + bash +``` + +`INCUDAL_AGENT_INSTALL_TOKEN` 由面板生成,30 分钟内有效且只能使用一次。 +安装脚本会调用 `/api/agent/install-config/:token` 拉取 `agent_id` 和 `agent_secret`。 +旧的 `INCUDAL_AGENT_ID` / `INCUDAL_AGENT_SECRET` 直传方式仍保留兼容。 + +安装脚本会: + +- 下载 `incudal-agent` 二进制 +- 写入 `/etc/incudal-agent/config.yaml` +- 写入 systemd service +- 执行一次心跳测试 +- 启动或重启 `incudal-agent.service` + +如果没有传入 `INCUDAL_AGENT_BINARY_URL`,默认从当前面板下载: + +```text +https:///api/agent/binary/incudal-agent-linux-amd64 +https:///api/agent/binary/incudal-agent-linux-arm64 +``` + +默认下载会先读取面板的 manifest: + +```text +https:///api/agent/manifest.json +``` + +安装脚本会按当前 OS/ARCH 取出文件名和 SHA-256,下载后先校验摘要,再解包安装。 + +如果手动传入 `INCUDAL_AGENT_BINARY_URL`,可同时传入 `INCUDAL_AGENT_BINARY_SHA256` 开启校验;未传 SHA-256 时仍保留兼容安装,但会输出 warning。 + +安装脚本会先下载到临时文件,再原子替换 `/usr/local/bin/incudal-agent`。 +重复安装或升级时,会执行 `systemctl restart incudal-agent` 确保立即切换到最新二进制。 + +dry-run 验证: + +```bash +INCUDAL_AGENT_DRY_RUN=1 \ +INCUDAL_PANEL_URL="http://127.0.0.1:8888" \ +INCUDAL_AGENT_INSTALL_TOKEN="ait_testtoken_abcdefghijklmnopqrstuvwxyz123456" \ +bash server/templates/agent-install.sh +``` diff --git a/agent/VERSION b/agent/VERSION new file mode 100644 index 0000000..45c7a58 --- /dev/null +++ b/agent/VERSION @@ -0,0 +1 @@ +v0.0.1 diff --git a/agent/cmd/incudal-agent/main.go b/agent/cmd/incudal-agent/main.go new file mode 100644 index 0000000..5c326fe --- /dev/null +++ b/agent/cmd/incudal-agent/main.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "sync/atomic" + "syscall" + "time" + + "incudal-agent/internal/config" + "incudal-agent/internal/panel" + "incudal-agent/internal/report" + "incudal-agent/internal/upgrade" +) + +var version = "dev" + +func main() { + configPath := flag.String("config", "/etc/incudal-agent/config.yaml", "agent config file") + once := flag.Bool("once", false, "send one heartbeat and exit") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + client := panel.New(cfg) + if *once { + if _, err := sendHeartbeat(ctx, client, cfg.HeartbeatIntervalSeconds); err != nil { + log.Fatalf("heartbeat: %v", err) + } + return + } + + log.Printf("incudal-agent started: panel=%s interval=%s", cfg.PanelURL, cfg.HeartbeatInterval) + upgradeRunner := upgrade.DefaultRunner(cfg) + var upgradeInProgress atomic.Bool + if result, err := sendHeartbeat(ctx, client, cfg.HeartbeatIntervalSeconds); err != nil { + log.Printf("heartbeat failed: %v", err) + } else { + scheduleAgentUpgrade(ctx, upgradeRunner, result, &upgradeInProgress) + } + + ticker := time.NewTicker(cfg.HeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + log.Printf("incudal-agent stopped") + return + case <-ticker.C: + if result, err := sendHeartbeat(ctx, client, cfg.HeartbeatIntervalSeconds); err != nil { + log.Printf("heartbeat failed: %v", err) + } else { + scheduleAgentUpgrade(ctx, upgradeRunner, result, &upgradeInProgress) + } + } + } +} + +func sendHeartbeat(ctx context.Context, client *panel.Client, heartbeatIntervalSeconds int) (panel.HeartbeatResult, error) { + result, err := client.Heartbeat(ctx, report.HeartbeatPayload(version, heartbeatIntervalSeconds)) + if err != nil { + return result, err + } + upgradeAvailable := result.Upgrade != nil && result.Upgrade.Available + log.Printf("heartbeat ok: status=%d latencyMs=%d upgrade=%t", result.StatusCode, result.LatencyMs, upgradeAvailable) + return result, nil +} + +func scheduleAgentUpgrade(ctx context.Context, runner *upgrade.Runner, result panel.HeartbeatResult, upgradeInProgress *atomic.Bool) { + if result.Upgrade == nil || !result.Upgrade.Available { + return + } + if !upgradeInProgress.CompareAndSwap(false, true) { + log.Printf("agent upgrade already scheduled: version=%s", result.Upgrade.Version) + return + } + + instruction := *result.Upgrade + log.Printf("agent upgrade scheduled: version=%s", instruction.Version) + go func() { + defer upgradeInProgress.Store(false) + + if delay := upgrade.RandomJitter(5 * time.Minute); delay > 0 { + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + + upgradeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if err := runner.Apply(upgradeCtx, instruction, version); err != nil { + log.Printf("agent upgrade failed: version=%s error=%v", instruction.Version, err) + return + } + log.Printf("agent upgrade applied: version=%s", instruction.Version) + }() +} diff --git a/agent/config.example.yaml b/agent/config.example.yaml new file mode 100644 index 0000000..8113d57 --- /dev/null +++ b/agent/config.example.yaml @@ -0,0 +1,8 @@ +# Incudal Host Agent minimal config. +# 首版仅支持简单 key: value 格式,不支持嵌套 YAML。 + +panel_url: "https://idev.bitpd.com" +agent_id: "agt_replace_me" +agent_secret: "ias_replace_me" +heartbeat_interval_seconds: 30 +request_timeout_seconds: 10 diff --git a/agent/go.mod b/agent/go.mod new file mode 100644 index 0000000..87932fb --- /dev/null +++ b/agent/go.mod @@ -0,0 +1,3 @@ +module incudal-agent + +go 1.19 diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go new file mode 100644 index 0000000..1e32d4a --- /dev/null +++ b/agent/internal/config/config.go @@ -0,0 +1,158 @@ +package config + +import ( + "bufio" + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const ( + DefaultHeartbeatIntervalSeconds = 30 + MinHeartbeatIntervalSeconds = 5 + MaxHeartbeatIntervalSeconds = 3600 +) + +type Config struct { + PanelURL string + AgentID string + AgentSecret string + HeartbeatInterval time.Duration + RequestTimeout time.Duration + HeartbeatIntervalSeconds int + RequestTimeoutSeconds int +} + +func Load(path string) (Config, error) { + values := map[string]string{} + if path != "" { + fileValues, err := readKeyValueFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return Config{}, err + } + for key, value := range fileValues { + values[key] = value + } + } + + overlayEnv(values, "panel_url", "INCUDAL_PANEL_URL") + overlayEnv(values, "agent_id", "INCUDAL_AGENT_ID") + overlayEnv(values, "agent_secret", "INCUDAL_AGENT_SECRET") + overlayEnv(values, "heartbeat_interval_seconds", "INCUDAL_HEARTBEAT_INTERVAL_SECONDS") + overlayEnv(values, "request_timeout_seconds", "INCUDAL_REQUEST_TIMEOUT_SECONDS") + + heartbeatSeconds := clampInt( + parsePositiveInt(values["heartbeat_interval_seconds"], DefaultHeartbeatIntervalSeconds), + MinHeartbeatIntervalSeconds, + MaxHeartbeatIntervalSeconds, + ) + timeoutSeconds := parsePositiveInt(values["request_timeout_seconds"], 10) + cfg := Config{ + PanelURL: strings.TrimRight(values["panel_url"], "/"), + AgentID: values["agent_id"], + AgentSecret: values["agent_secret"], + HeartbeatIntervalSeconds: heartbeatSeconds, + RequestTimeoutSeconds: timeoutSeconds, + HeartbeatInterval: time.Duration(heartbeatSeconds) * time.Second, + RequestTimeout: time.Duration(timeoutSeconds) * time.Second, + } + + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func (cfg Config) Validate() error { + if cfg.PanelURL == "" { + return errors.New("panel_url is required") + } + parsed, err := url.Parse(cfg.PanelURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("panel_url is invalid: %s", cfg.PanelURL) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("panel_url scheme must be http or https: %s", parsed.Scheme) + } + if cfg.AgentID == "" { + return errors.New("agent_id is required") + } + if cfg.AgentSecret == "" { + return errors.New("agent_secret is required") + } + if cfg.HeartbeatInterval < time.Duration(MinHeartbeatIntervalSeconds)*time.Second { + return fmt.Errorf("heartbeat interval must be at least %d seconds", MinHeartbeatIntervalSeconds) + } + if cfg.RequestTimeout < time.Second { + return errors.New("request timeout must be at least 1 second") + } + return nil +} + +func readKeyValueFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + values := map[string]string{} + scanner := bufio.NewScanner(file) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + return nil, fmt.Errorf("invalid config line %d: expected key: value", lineNumber) + } + key = strings.TrimSpace(key) + value = trimConfigValue(value) + if key == "" { + return nil, fmt.Errorf("invalid config line %d: empty key", lineNumber) + } + values[key] = value + } + if err := scanner.Err(); err != nil { + return nil, err + } + return values, nil +} + +func trimConfigValue(value string) string { + trimmed := strings.TrimSpace(value) + trimmed = strings.Trim(trimmed, `"`) + trimmed = strings.Trim(trimmed, `'`) + return trimmed +} + +func overlayEnv(values map[string]string, key string, envName string) { + if value := strings.TrimSpace(os.Getenv(envName)); value != "" { + values[key] = value + } +} + +func parsePositiveInt(value string, fallback int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func clampInt(value int, min int, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/agent/internal/config/config_test.go b/agent/internal/config/config_test.go new file mode 100644 index 0000000..53e4b08 --- /dev/null +++ b/agent/internal/config/config_test.go @@ -0,0 +1,51 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadClampsHeartbeatInterval(t *testing.T) { + tests := []struct { + name string + value string + expected int + }{ + {name: "too low", value: "1", expected: MinHeartbeatIntervalSeconds}, + {name: "too high", value: "7200", expected: MaxHeartbeatIntervalSeconds}, + {name: "valid", value: "60", expected: 60}, + {name: "invalid", value: "invalid", expected: DefaultHeartbeatIntervalSeconds}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := writeTestConfig(t, tt.value) + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.HeartbeatIntervalSeconds != tt.expected { + t.Fatalf("heartbeat seconds mismatch: got=%d want=%d", cfg.HeartbeatIntervalSeconds, tt.expected) + } + if cfg.HeartbeatInterval != time.Duration(tt.expected)*time.Second { + t.Fatalf("heartbeat interval mismatch: got=%s want=%s", cfg.HeartbeatInterval, time.Duration(tt.expected)*time.Second) + } + }) + } +} + +func writeTestConfig(t *testing.T, heartbeatInterval string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.yaml") + content := "panel_url: \"https://panel.example\"\n" + + "agent_id: \"agt_test\"\n" + + "agent_secret: \"ias_test\"\n" + + "heartbeat_interval_seconds: " + heartbeatInterval + "\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} diff --git a/agent/internal/panel/client.go b/agent/internal/panel/client.go new file mode 100644 index 0000000..99f9840 --- /dev/null +++ b/agent/internal/panel/client.go @@ -0,0 +1,119 @@ +package panel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "incudal-agent/internal/config" + "incudal-agent/internal/protocol" +) + +const heartbeatPath = "/api/agent/heartbeat" + +type Client struct { + panelURL string + agentID string + agentSecret string + httpClient *http.Client +} + +type HeartbeatResult struct { + StatusCode int + Body string + OK bool + Upgrade *UpgradeInstruction + LatencyMs int64 +} + +type UpgradeInstruction struct { + Available bool `json:"available"` + Version string `json:"version"` + URL string `json:"url"` + SHA256 string `json:"sha256"` + Gzip bool `json:"gzip"` + Size int64 `json:"size"` +} + +type heartbeatResponse struct { + Upgrade *UpgradeInstruction `json:"upgrade"` +} + +func New(cfg config.Config) *Client { + return &Client{ + panelURL: strings.TrimRight(cfg.PanelURL, "/"), + agentID: cfg.AgentID, + agentSecret: cfg.AgentSecret, + httpClient: &http.Client{ + Timeout: cfg.RequestTimeout, + }, + } +} + +func (client *Client) Heartbeat(ctx context.Context, payload map[string]any) (HeartbeatResult, error) { + body, err := protocol.CanonicalJSON(payload) + if err != nil { + return HeartbeatResult{}, err + } + + timestamp := protocol.NewTimestamp() + nonce, err := protocol.NewNonce() + if err != nil { + return HeartbeatResult{}, err + } + bodyHash := protocol.BodySHA256(body) + signingPayload := protocol.SigningPayload(http.MethodPost, heartbeatPath, timestamp, nonce, bodyHash) + signature := protocol.Signature(client.agentSecret, signingPayload) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.panelURL+heartbeatPath, bytes.NewReader(body)) + if err != nil { + return HeartbeatResult{}, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("x-incudal-agent-id", client.agentID) + request.Header.Set("x-incudal-timestamp", timestamp) + request.Header.Set("x-incudal-nonce", nonce) + request.Header.Set("x-incudal-body-sha256", bodyHash) + request.Header.Set("x-incudal-signature", signature) + + startedAt := time.Now() + response, err := client.httpClient.Do(request) + if err != nil { + return HeartbeatResult{}, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(io.LimitReader(response.Body, 1024*1024)) + if err != nil { + return HeartbeatResult{}, err + } + + result := HeartbeatResult{ + StatusCode: response.StatusCode, + Body: string(responseBody), + OK: response.StatusCode >= 200 && response.StatusCode < 300, + LatencyMs: time.Since(startedAt).Milliseconds(), + } + if !result.OK { + return result, fmt.Errorf("heartbeat failed: status=%d body=%s", response.StatusCode, result.Body) + } + + var parsedResponse heartbeatResponse + if err := json.Unmarshal(responseBody, &parsedResponse); err == nil { + result.Upgrade = parsedResponse.Upgrade + } + + var parsed map[string]any + if err := json.Unmarshal(responseBody, &parsed); err == nil { + parsed["latencyMs"] = result.LatencyMs + if compact, err := json.Marshal(parsed); err == nil { + result.Body = string(compact) + } + } + return result, nil +} diff --git a/agent/internal/protocol/signing.go b/agent/internal/protocol/signing.go new file mode 100644 index 0000000..3b85345 --- /dev/null +++ b/agent/internal/protocol/signing.go @@ -0,0 +1,52 @@ +package protocol + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" +) + +// CanonicalJSON 使用 Go 标准库的 JSON 编码。 +// map key 会按字典序输出,必须与面板端 stableStringify 规则保持一致。 +func CanonicalJSON(value any) ([]byte, error) { + return json.Marshal(value) +} + +func BodySHA256(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + +func SigningPayload(method string, path string, timestamp string, nonce string, bodyHash string) string { + return strings.Join([]string{ + strings.ToUpper(method), + path, + timestamp, + nonce, + strings.ToLower(bodyHash), + }, "\n") +} + +func Signature(secret string, payload string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func NewTimestamp() string { + return fmt.Sprintf("%d", time.Now().UnixMilli()) +} + +func NewNonce() (string, error) { + var raw [18]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw[:]), nil +} diff --git a/agent/internal/protocol/signing_test.go b/agent/internal/protocol/signing_test.go new file mode 100644 index 0000000..2f7d4f5 --- /dev/null +++ b/agent/internal/protocol/signing_test.go @@ -0,0 +1,49 @@ +package protocol + +import "testing" + +func TestCanonicalJSONIsStableForMapOrder(t *testing.T) { + bodyA := map[string]any{ + "version": "0.1.0", + "resources": map[string]any{ + "memory": 1024, + "cpu": 8, + }, + "capabilities": []any{"heartbeat", "report"}, + } + bodyB := map[string]any{ + "capabilities": []any{"heartbeat", "report"}, + "resources": map[string]any{ + "cpu": 8, + "memory": 1024, + }, + "version": "0.1.0", + } + + jsonA, err := CanonicalJSON(bodyA) + if err != nil { + t.Fatalf("canonical json A: %v", err) + } + jsonB, err := CanonicalJSON(bodyB) + if err != nil { + t.Fatalf("canonical json B: %v", err) + } + + if string(jsonA) != string(jsonB) { + t.Fatalf("canonical json mismatch:\nA=%s\nB=%s", jsonA, jsonB) + } + if BodySHA256(jsonA) != BodySHA256(jsonB) { + t.Fatalf("body hash mismatch") + } +} + +func TestSignatureChangesWithPath(t *testing.T) { + secret := "ias_test_secret" + bodyHash := BodySHA256([]byte(`{"ok":true}`)) + payloadA := SigningPayload("POST", "/api/agent/heartbeat", "1777380000000", "nonce-123456", bodyHash) + payloadB := SigningPayload("POST", "/api/agent/report", "1777380000000", "nonce-123456", bodyHash) + + if Signature(secret, payloadA) == Signature(secret, payloadB) { + t.Fatalf("signature should change when request path changes") + } +} diff --git a/agent/internal/report/incus.go b/agent/internal/report/incus.go new file mode 100644 index 0000000..79a1853 --- /dev/null +++ b/agent/internal/report/incus.go @@ -0,0 +1,362 @@ +package report + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +const ( + maxReportedIncusInstances = 1000 + incusStateConcurrency = 8 +) + +var externalGuestInterfacePattern = regexp.MustCompile(`^(eth[0-9]+|en(?:o|p|s|x)[a-z0-9]+)$`) + +type incusAPIResponse struct { + Type string `json:"type"` + Status string `json:"status"` + Error string `json:"error"` + Metadata json.RawMessage `json:"metadata"` +} + +type incusInstanceSummary struct { + Name string `json:"name"` + Status string `json:"status"` + StatusCode int `json:"status_code"` + Type string `json:"type"` +} + +type incusInstanceState struct { + Status string `json:"status"` + Network map[string]incusNetworkDevice `json:"network"` +} + +type incusNetworkDevice struct { + Addresses []incusNetworkAddress `json:"addresses"` + Hwaddr string `json:"hwaddr"` + Counters incusNetworkCounters `json:"counters"` + State string `json:"state"` +} + +type incusNetworkAddress struct { + Family string `json:"family"` + Address string `json:"address"` +} + +type incusNetworkCounters struct { + BytesReceived json.Number `json:"bytes_received"` + BytesSent json.Number `json:"bytes_sent"` +} + +type trafficCounters struct { + rx uint64 + tx uint64 +} + +func collectIncusInstanceReport() map[string]any { + reportedAt := time.Now().UTC().Format(time.RFC3339) + socketPath, ok := detectIncusSocket() + if !ok { + return map[string]any{ + "available": false, + "reportedAt": reportedAt, + "total": 0, + "items": []any{}, + } + } + + // 只通过本机 Unix socket 做只读采集,不要求宿主机开放额外 Agent 端口。 + client := newIncusUnixHTTPClient(socketPath) + instances, err := listIncusInstances(client) + if err != nil { + return map[string]any{ + "available": false, + "reportedAt": reportedAt, + "total": 0, + "items": []any{}, + "error": truncateReportError(err), + } + } + + limitedInstances := instances + if len(limitedInstances) > maxReportedIncusInstances { + limitedInstances = limitedInstances[:maxReportedIncusInstances] + } + + items := make([]map[string]any, len(limitedInstances)) + var wg sync.WaitGroup + semaphore := make(chan struct{}, incusStateConcurrency) + + for index, instance := range limitedInstances { + wg.Add(1) + go func(index int, instance incusInstanceSummary) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + + items[index] = buildIncusInstanceReportItem(client, instance) + }(index, instance) + } + wg.Wait() + + normalizedItems := make([]any, 0, len(items)) + for _, item := range items { + if item != nil { + normalizedItems = append(normalizedItems, item) + } + } + + return map[string]any{ + "available": true, + "reportedAt": reportedAt, + "total": len(instances), + "truncated": len(instances) > len(limitedInstances), + "items": normalizedItems, + } +} + +func newIncusUnixHTTPClient(socketPath string) *http.Client { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _network string, _addr string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socketPath) + }, + DisableCompression: true, + DisableKeepAlives: true, + } + + return &http.Client{ + Transport: transport, + Timeout: 8 * time.Second, + } +} + +func listIncusInstances(client *http.Client) ([]incusInstanceSummary, error) { + return incusRequest[[]incusInstanceSummary](client, "/1.0/instances?recursion=1") +} + +func getIncusInstanceState(client *http.Client, name string) (incusInstanceState, error) { + return incusRequest[incusInstanceState](client, "/1.0/instances/"+url.PathEscape(name)+"/state") +} + +func incusRequest[T any](client *http.Client, path string) (T, error) { + var zero T + request, err := http.NewRequest(http.MethodGet, "http://incus"+path, nil) + if err != nil { + return zero, err + } + + response, err := client.Do(request) + if err != nil { + return zero, err + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return zero, fmt.Errorf("incus request failed: path=%s status=%d", path, response.StatusCode) + } + + var envelope incusAPIResponse + decoder := json.NewDecoder(response.Body) + decoder.UseNumber() + if err := decoder.Decode(&envelope); err != nil { + return zero, err + } + if envelope.Type == "error" { + if envelope.Error != "" { + return zero, fmt.Errorf("incus error: %s", envelope.Error) + } + return zero, fmt.Errorf("incus error: status=%s", envelope.Status) + } + + metadataDecoder := json.NewDecoder(bytes.NewReader(envelope.Metadata)) + metadataDecoder.UseNumber() + if err := metadataDecoder.Decode(&zero); err != nil { + return zero, err + } + return zero, nil +} + +func buildIncusInstanceReportItem(client *http.Client, instance incusInstanceSummary) map[string]any { + item := map[string]any{ + "name": instance.Name, + "status": instance.Status, + "statusCode": instance.StatusCode, + "type": instance.Type, + } + + if instance.Name == "" { + return item + } + + state, err := getIncusInstanceState(client, instance.Name) + if err != nil { + item["error"] = truncateReportError(err) + return item + } + if state.Status != "" { + item["status"] = state.Status + } + + counters := getTrafficCountersFromIncusState(instance.Name, state) + item["traffic"] = map[string]any{ + "rxBytes": strconv.FormatUint(counters.rx, 10), + "txBytes": strconv.FormatUint(counters.tx, 10), + } + + if ipv4, ipv6 := firstRoutableAddresses(state.Network); ipv4 != "" || ipv6 != "" { + network := map[string]any{} + if ipv4 != "" { + network["ipv4"] = ipv4 + } + if ipv6 != "" { + network["ipv6"] = ipv6 + } + item["network"] = network + } + + return item +} + +func getTrafficCountersFromIncusState(instanceName string, state incusInstanceState) trafficCounters { + billableVmMacs := generateBillableVmMacs(instanceName) + totals := trafficCounters{} + fallbackInterfaces := make([]incusNetworkDevice, 0) + hasStrictBillableInterface := false + + // 与面板旧采集口径保持一致,避免 guest 内部 bridge/veth 被重复计费。 + for ifName, ifData := range state.Network { + if isBillableNetworkInterface(ifName, ifData, billableVmMacs) { + hasStrictBillableInterface = true + addNetworkCounters(&totals, ifData.Counters) + continue + } + + if isLikelyExternalGuestInterface(ifName) { + fallbackInterfaces = append(fallbackInterfaces, ifData) + } + } + + if !hasStrictBillableInterface { + for _, ifData := range fallbackInterfaces { + addNetworkCounters(&totals, ifData.Counters) + } + } + + return totals +} + +func isBillableNetworkInterface(ifName string, ifData incusNetworkDevice, billableVmMacs map[string]struct{}) bool { + if ifName == "lo" { + return false + } + if ifName == "eth0" || ifName == "eth1" { + return true + } + + hwaddr := strings.ToLower(strings.TrimSpace(ifData.Hwaddr)) + if hwaddr == "" { + return false + } + _, ok := billableVmMacs[hwaddr] + return ok +} + +func isLikelyExternalGuestInterface(ifName string) bool { + return externalGuestInterfacePattern.MatchString(strings.ToLower(ifName)) +} + +func addNetworkCounters(totals *trafficCounters, counters incusNetworkCounters) { + totals.rx += jsonNumberToUint64(counters.BytesReceived) + totals.tx += jsonNumberToUint64(counters.BytesSent) +} + +func jsonNumberToUint64(value json.Number) uint64 { + raw := strings.TrimSpace(value.String()) + if raw == "" { + return 0 + } + parsed, err := strconv.ParseUint(raw, 10, 64) + if err == nil { + return parsed + } + return 0 +} + +func generateBillableVmMacs(seed string) map[string]struct{} { + return map[string]struct{}{ + generateVmNicMac(seed, "eth0"): {}, + generateVmNicMac(seed, "eth1"): {}, + } +} + +func generateVmNicMac(seed string, nicLabel string) string { + hash := sha256.Sum256([]byte("incudal-vm-nic:" + seed + ":" + nicLabel)) + bytes := []byte{0x02, hash[0], hash[1], hash[2], hash[3], hash[4]} + encoded := hex.EncodeToString(bytes) + return strings.Join([]string{ + encoded[0:2], + encoded[2:4], + encoded[4:6], + encoded[6:8], + encoded[8:10], + encoded[10:12], + }, ":") +} + +func firstRoutableAddresses(network map[string]incusNetworkDevice) (string, string) { + var ipv4 string + var ipv6 string + + for _, ifData := range network { + for _, address := range ifData.Addresses { + if address.Address == "" { + continue + } + ip := net.ParseIP(address.Address) + if ip == nil || !isRoutableGuestIP(ip) { + continue + } + if ipv4 == "" && ip.To4() != nil && strings.EqualFold(address.Family, "inet") { + ipv4 = address.Address + continue + } + if ipv6 == "" && ip.To4() == nil && strings.EqualFold(address.Family, "inet6") { + ipv6 = address.Address + } + } + } + + return ipv4, ipv6 +} + +func isRoutableGuestIP(ip net.IP) bool { + return !ip.IsLoopback() && + !ip.IsUnspecified() && + !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && + !ip.IsMulticast() +} + +func truncateReportError(err error) string { + if err == nil { + return "" + } + message := err.Error() + if len(message) > 200 { + return message[:200] + } + return message +} diff --git a/agent/internal/report/report.go b/agent/internal/report/report.go new file mode 100644 index 0000000..d99d735 --- /dev/null +++ b/agent/internal/report/report.go @@ -0,0 +1,298 @@ +package report + +import ( + "os" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +var incusSocketCandidates = []string{ + "/var/lib/incus/unix.socket", + "/var/snap/incus/common/lxd/unix.socket", + "/var/lib/lxd/unix.socket", +} + +func HeartbeatPayload(version string, heartbeatIntervalSeconds int) map[string]any { + return map[string]any{ + "version": version, + "capabilities": []any{"heartbeat", "report", "host-metrics", "instance-status", "traffic-counters"}, + "runtime": map[string]any{ + "goos": runtime.GOOS, + "goarch": runtime.GOARCH, + }, + "incus": detectIncus(), + "instances": collectIncusInstanceReport(), + "resources": collectResources(), + "metrics": collectMetrics(heartbeatIntervalSeconds), + } +} + +func collectResources() map[string]any { + resources := map[string]any{ + "cpuTotal": runtime.NumCPU(), + } + if cpuUsagePercent := readCPUUsagePercent(); cpuUsagePercent >= 0 { + resources["cpuUsagePercent"] = cpuUsagePercent + } + for key, value := range readMemoryStats() { + resources[key] = value + } + for key, value := range readDiskStats("/") { + resources[key] = value + } + if processCount := readProcessCount(); processCount >= 0 { + resources["processCount"] = processCount + } + return resources +} + +func collectMetrics(heartbeatIntervalSeconds int) map[string]any { + switch { + case heartbeatIntervalSeconds <= 0: + heartbeatIntervalSeconds = 30 + case heartbeatIntervalSeconds < 5: + heartbeatIntervalSeconds = 5 + case heartbeatIntervalSeconds > 3600: + heartbeatIntervalSeconds = 3600 + } + + metrics := map[string]any{ + "reportedAt": time.Now().UTC().Format(time.RFC3339), + "heartbeatIntervalSeconds": heartbeatIntervalSeconds, + } + if uptimeSeconds := readUptimeSeconds(); uptimeSeconds > 0 { + metrics["uptimeSeconds"] = uptimeSeconds + } + for key, value := range readLoadAverage() { + metrics[key] = value + } + return metrics +} + +func detectIncus() map[string]any { + socketPath, ok := detectIncusSocket() + if ok { + return map[string]any{ + "available": true, + "socket": socketPath, + } + } + return map[string]any{ + "available": false, + "socket": "", + } +} + +func detectIncusSocket() (string, bool) { + for _, socketPath := range incusSocketCandidates { + if info, err := os.Stat(socketPath); err == nil && !info.IsDir() { + return socketPath, true + } + } + return "", false +} + +type cpuStat struct { + idle uint64 + total uint64 +} + +func readCPUUsagePercent() float64 { + before, ok := readCPUStat() + if !ok { + return -1 + } + time.Sleep(200 * time.Millisecond) + after, ok := readCPUStat() + if !ok { + return -1 + } + + totalDelta := after.total - before.total + idleDelta := after.idle - before.idle + if totalDelta == 0 || idleDelta > totalDelta { + return -1 + } + + return roundPercent(float64(totalDelta-idleDelta) / float64(totalDelta) * 100) +} + +func readCPUStat() (cpuStat, bool) { + content, err := os.ReadFile("/proc/stat") + if err != nil { + return cpuStat{}, false + } + for _, line := range strings.Split(string(content), "\n") { + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line) + if len(fields) < 5 { + return cpuStat{}, false + } + + var values []uint64 + for _, field := range fields[1:] { + value, err := strconv.ParseUint(field, 10, 64) + if err != nil { + return cpuStat{}, false + } + values = append(values, value) + } + + var total uint64 + for _, value := range values { + total += value + } + idle := values[3] + if len(values) > 4 { + idle += values[4] + } + return cpuStat{idle: idle, total: total}, true + } + return cpuStat{}, false +} + +func readMemoryStats() map[string]any { + meminfo := readMeminfoKB() + stats := map[string]any{} + memTotal := meminfo["MemTotal"] + memAvailable := meminfo["MemAvailable"] + if memTotal > 0 { + memUsed := memTotal - memAvailable + if memUsed < 0 { + memUsed = 0 + } + stats["memoryTotalMb"] = memTotal / 1024 + stats["memoryAvailableMb"] = memAvailable / 1024 + stats["memoryUsedMb"] = memUsed / 1024 + stats["memoryUsagePercent"] = roundPercent(float64(memUsed) / float64(memTotal) * 100) + } + + swapTotal := meminfo["SwapTotal"] + swapFree := meminfo["SwapFree"] + if swapTotal > 0 { + swapUsed := swapTotal - swapFree + if swapUsed < 0 { + swapUsed = 0 + } + stats["swapTotalMb"] = swapTotal / 1024 + stats["swapUsedMb"] = swapUsed / 1024 + stats["swapUsagePercent"] = roundPercent(float64(swapUsed) / float64(swapTotal) * 100) + } else { + stats["swapTotalMb"] = int64(0) + stats["swapUsedMb"] = int64(0) + stats["swapUsagePercent"] = float64(0) + } + + return stats +} + +func readMeminfoKB() map[string]int64 { + content, err := os.ReadFile("/proc/meminfo") + if err != nil { + return map[string]int64{} + } + + values := map[string]int64{} + for _, line := range strings.Split(string(content), "\n") { + key, rest, ok := strings.Cut(line, ":") + if !ok { + continue + } + fields := strings.Fields(rest) + if len(fields) == 0 { + continue + } + kb, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + continue + } + values[key] = kb + } + return values +} + +func readDiskStats(path string) map[string]any { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return map[string]any{} + } + + blockSize := uint64(stat.Bsize) + total := stat.Blocks * blockSize + free := stat.Bfree * blockSize + if total == 0 || free > total { + return map[string]any{} + } + + used := total - free + return map[string]any{ + "diskMountpoint": path, + "diskTotalBytes": total, + "diskUsedBytes": used, + "diskAvailableBytes": stat.Bavail * blockSize, + "diskUsagePercent": roundPercent(float64(used) / float64(total) * 100), + } +} + +func readLoadAverage() map[string]any { + content, err := os.ReadFile("/proc/loadavg") + if err != nil { + return map[string]any{} + } + fields := strings.Fields(string(content)) + if len(fields) < 3 { + return map[string]any{} + } + + loads := map[string]any{} + keys := []string{"load1", "load5", "load15"} + for index, key := range keys { + value, err := strconv.ParseFloat(fields[index], 64) + if err == nil { + loads[key] = value + } + } + return loads +} + +func readProcessCount() int { + entries, err := os.ReadDir("/proc") + if err != nil { + return -1 + } + count := 0 + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, err := strconv.Atoi(entry.Name()); err == nil { + count++ + } + } + return count +} + +func readUptimeSeconds() int64 { + content, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + fields := strings.Fields(string(content)) + if len(fields) == 0 { + return 0 + } + value, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return 0 + } + return int64(value) +} + +func roundPercent(value float64) float64 { + return float64(int(value*10+0.5)) / 10 +} diff --git a/agent/internal/report/report_test.go b/agent/internal/report/report_test.go new file mode 100644 index 0000000..ce02d2e --- /dev/null +++ b/agent/internal/report/report_test.go @@ -0,0 +1,166 @@ +package report + +import "testing" + +func TestHeartbeatPayloadIncludesHostMetrics(t *testing.T) { + payload := HeartbeatPayload("test-version", 30) + + capabilities, ok := payload["capabilities"].([]any) + if !ok { + t.Fatalf("capabilities missing or invalid: %#v", payload["capabilities"]) + } + if !containsCapability(capabilities, "host-metrics") { + t.Fatalf("host-metrics capability missing: %#v", capabilities) + } + if !containsCapability(capabilities, "instance-status") { + t.Fatalf("instance-status capability missing: %#v", capabilities) + } + if !containsCapability(capabilities, "traffic-counters") { + t.Fatalf("traffic-counters capability missing: %#v", capabilities) + } + + instances, ok := payload["instances"].(map[string]any) + if !ok { + t.Fatalf("instances report missing or invalid: %#v", payload["instances"]) + } + for _, key := range []string{"available", "reportedAt", "total", "items"} { + if _, ok := instances[key]; !ok { + t.Fatalf("instances key %s missing: %#v", key, instances) + } + } + + runtimeInfo, ok := payload["runtime"].(map[string]any) + if !ok { + t.Fatalf("runtime missing or invalid: %#v", payload["runtime"]) + } + for _, key := range []string{"goos", "goarch"} { + if _, ok := runtimeInfo[key]; !ok { + t.Fatalf("runtime key %s missing: %#v", key, runtimeInfo) + } + } + + resources, ok := payload["resources"].(map[string]any) + if !ok { + t.Fatalf("resources missing or invalid: %#v", payload["resources"]) + } + for _, key := range []string{ + "cpuTotal", + "cpuUsagePercent", + "memoryTotalMb", + "memoryUsedMb", + "memoryUsagePercent", + "swapTotalMb", + "swapUsedMb", + "swapUsagePercent", + "diskTotalBytes", + "diskUsedBytes", + "diskUsagePercent", + "processCount", + } { + if _, ok := resources[key]; !ok { + t.Fatalf("resource key %s missing: %#v", key, resources) + } + } + + metrics, ok := payload["metrics"].(map[string]any) + if !ok { + t.Fatalf("metrics missing or invalid: %#v", payload["metrics"]) + } + for _, key := range []string{"reportedAt", "heartbeatIntervalSeconds", "uptimeSeconds", "load1", "load5", "load15"} { + if _, ok := metrics[key]; !ok { + t.Fatalf("metric key %s missing: %#v", key, metrics) + } + } + if metrics["heartbeatIntervalSeconds"] != 30 { + t.Fatalf("heartbeat interval mismatch: %#v", metrics["heartbeatIntervalSeconds"]) + } +} + +func TestHeartbeatPayloadClampsHeartbeatInterval(t *testing.T) { + tests := []struct { + name string + input int + expected int + }{ + {name: "zero falls back", input: 0, expected: 30}, + {name: "too low clamps to min", input: 1, expected: 5}, + {name: "too high clamps to max", input: 7200, expected: 3600}, + {name: "valid stays unchanged", input: 60, expected: 60}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := HeartbeatPayload("test-version", tt.input) + metrics, ok := payload["metrics"].(map[string]any) + if !ok { + t.Fatalf("metrics missing or invalid: %#v", payload["metrics"]) + } + if metrics["heartbeatIntervalSeconds"] != tt.expected { + t.Fatalf("heartbeat interval mismatch: got=%#v want=%d", metrics["heartbeatIntervalSeconds"], tt.expected) + } + }) + } +} + +func TestTrafficCountersFromIncusStateUsesBillableInterfaces(t *testing.T) { + state := incusInstanceState{ + Network: map[string]incusNetworkDevice{ + "lo": { + Counters: incusNetworkCounters{ + BytesReceived: "999", + BytesSent: "999", + }, + }, + "eth0": { + Counters: incusNetworkCounters{ + BytesReceived: "100", + BytesSent: "200", + }, + }, + "docker0": { + Counters: incusNetworkCounters{ + BytesReceived: "300", + BytesSent: "400", + }, + }, + }, + } + + counters := getTrafficCountersFromIncusState("vm-test", state) + if counters.rx != 100 || counters.tx != 200 { + t.Fatalf("traffic counters mismatch: got=%+v", counters) + } +} + +func TestTrafficCountersFromIncusStateFallsBackToExternalInterfaces(t *testing.T) { + state := incusInstanceState{ + Network: map[string]incusNetworkDevice{ + "lo": { + Counters: incusNetworkCounters{ + BytesReceived: "999", + BytesSent: "999", + }, + }, + "enp5s0": { + Counters: incusNetworkCounters{ + BytesReceived: "123", + BytesSent: "456", + }, + }, + }, + } + + counters := getTrafficCountersFromIncusState("vm-test", state) + if counters.rx != 123 || counters.tx != 456 { + t.Fatalf("traffic counters mismatch: got=%+v", counters) + } +} + +func containsCapability(capabilities []any, expected string) bool { + for _, capability := range capabilities { + if capability == expected { + return true + } + } + return false +} diff --git a/agent/internal/upgrade/upgrade.go b/agent/internal/upgrade/upgrade.go new file mode 100644 index 0000000..1a091ba --- /dev/null +++ b/agent/internal/upgrade/upgrade.go @@ -0,0 +1,400 @@ +package upgrade + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "incudal-agent/internal/config" + "incudal-agent/internal/panel" +) + +const ( + defaultServiceName = "incudal-agent" + defaultMaxDownloadBytes = 64 * 1024 * 1024 +) + +var ErrUpgradeInProgress = errors.New("agent upgrade already in progress") +var systemdServiceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@-]+$`) + +type RestartFunc func(ctx context.Context, serviceName string) error + +type Runner struct { + BinaryPath string + BackupPath string + LockPath string + ServiceName string + AllowedBaseURL string + HTTPClient *http.Client + Restart RestartFunc + MaxDownloadBytes int64 +} + +func DefaultRunner(cfg config.Config) *Runner { + binaryPath, err := os.Executable() + if err != nil || binaryPath == "" { + binaryPath = "/usr/local/bin/incudal-agent" + } + + return &Runner{ + BinaryPath: binaryPath, + BackupPath: binaryPath + ".bak", + LockPath: defaultLockPath(), + ServiceName: defaultServiceName, + AllowedBaseURL: cfg.PanelURL, + HTTPClient: &http.Client{Timeout: cfg.RequestTimeout}, + Restart: restartSystemdService, + MaxDownloadBytes: defaultMaxDownloadBytes, + } +} + +func RandomJitter(max time.Duration) time.Duration { + if max <= 0 { + return 0 + } + limit := big.NewInt(int64(max)) + value, err := rand.Int(rand.Reader, limit) + if err != nil { + return 0 + } + return time.Duration(value.Int64()) +} + +func (runner *Runner) Apply(ctx context.Context, instruction panel.UpgradeInstruction, currentVersion string) error { + if !instruction.Available { + return nil + } + if instruction.Version == "" { + return errors.New("upgrade version is required") + } + if instruction.Version == currentVersion { + return nil + } + if instruction.URL == "" { + return errors.New("upgrade URL is required") + } + if instruction.SHA256 == "" { + return errors.New("upgrade sha256 is required") + } + if err := runner.validateUpgradeURL(instruction.URL); err != nil { + return err + } + + unlock, err := acquireLock(runner.lockPath()) + if err != nil { + return err + } + defer unlock() + + packageBytes, err := runner.download(ctx, instruction.URL) + if err != nil { + return err + } + if err := verifySHA256(packageBytes, instruction.SHA256); err != nil { + return err + } + + binaryBytes := packageBytes + if instruction.Gzip { + binaryBytes, err = gunzip(packageBytes) + if err != nil { + return err + } + } + + tempPath, err := runner.writeTempBinary(binaryBytes) + if err != nil { + return err + } + if err := runner.replaceBinary(tempPath); err != nil { + _ = os.Remove(tempPath) + return err + } + + if err := runner.restart(ctx); err != nil { + return fmt.Errorf("restart agent after upgrade: %w", err) + } + return nil +} + +func (runner *Runner) validateUpgradeURL(rawURL string) error { + upgradeURL, err := url.Parse(rawURL) + if err != nil || upgradeURL.Scheme == "" || upgradeURL.Host == "" { + return fmt.Errorf("upgrade URL is invalid: %s", rawURL) + } + if upgradeURL.Scheme != "http" && upgradeURL.Scheme != "https" { + return fmt.Errorf("upgrade URL scheme is not allowed: %s", upgradeURL.Scheme) + } + + baseURL, err := url.Parse(strings.TrimRight(runner.AllowedBaseURL, "/")) + if err != nil || baseURL.Scheme == "" || baseURL.Host == "" { + return fmt.Errorf("panel URL is invalid: %s", runner.AllowedBaseURL) + } + if !strings.EqualFold(upgradeURL.Scheme, baseURL.Scheme) || !strings.EqualFold(upgradeURL.Host, baseURL.Host) { + return errors.New("upgrade URL is outside panel origin") + } + return nil +} + +func (runner *Runner) download(ctx context.Context, rawURL string) ([]byte, error) { + client := runner.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return nil, fmt.Errorf("download upgrade failed: status=%d body=%s", response.StatusCode, string(body)) + } + + limit := runner.MaxDownloadBytes + if limit <= 0 { + limit = defaultMaxDownloadBytes + } + body, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > limit { + return nil, fmt.Errorf("upgrade package exceeds %d bytes", limit) + } + return body, nil +} + +func (runner *Runner) writeTempBinary(binaryBytes []byte) (string, error) { + binaryPath := runner.binaryPath() + tempFile, err := os.CreateTemp(filepath.Dir(binaryPath), ".incudal-agent-upgrade-*") + if err != nil { + return "", err + } + tempPath := tempFile.Name() + defer tempFile.Close() + + if _, err := tempFile.Write(binaryBytes); err != nil { + _ = os.Remove(tempPath) + return "", err + } + if err := tempFile.Chmod(0755); err != nil { + _ = os.Remove(tempPath) + return "", err + } + return tempPath, nil +} + +func (runner *Runner) replaceBinary(tempPath string) error { + binaryPath := runner.binaryPath() + backupPath := runner.backupPath() + + if _, err := os.Stat(binaryPath); err == nil { + _ = os.Remove(backupPath) + if err := copyFile(binaryPath, backupPath); err != nil { + return fmt.Errorf("backup current agent: %w", err) + } + } + + if err := os.Rename(tempPath, binaryPath); err != nil { + return fmt.Errorf("replace agent binary: %w", err) + } + return nil +} + +func (runner *Runner) rollback() error { + backupPath := runner.backupPath() + if _, err := os.Stat(backupPath); err != nil { + return err + } + return os.Rename(backupPath, runner.binaryPath()) +} + +func (runner *Runner) restart(ctx context.Context) error { + if runner.Restart == nil { + return nil + } + serviceName := runner.ServiceName + if serviceName == "" { + serviceName = defaultServiceName + } + return runner.Restart(ctx, serviceName) +} + +func (runner *Runner) binaryPath() string { + if runner.BinaryPath != "" { + return runner.BinaryPath + } + return "/usr/local/bin/incudal-agent" +} + +func (runner *Runner) backupPath() string { + if runner.BackupPath != "" { + return runner.BackupPath + } + return runner.binaryPath() + ".bak" +} + +func (runner *Runner) lockPath() string { + if runner.LockPath != "" { + return runner.LockPath + } + return defaultLockPath() +} + +func defaultLockPath() string { + if info, err := os.Stat("/run"); err == nil && info.IsDir() { + return "/run/incudal-agent-upgrade.lock" + } + return filepath.Join(os.TempDir(), "incudal-agent-upgrade.lock") +} + +func acquireLock(lockPath string) (func(), error) { + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil, ErrUpgradeInProgress + } + return nil, err + } + _, _ = fmt.Fprintf(file, "%d\n", os.Getpid()) + _ = file.Close() + + return func() { + _ = os.Remove(lockPath) + }, nil +} + +func verifySHA256(payload []byte, expected string) error { + sum := sha256.Sum256(payload) + actual := hex.EncodeToString(sum[:]) + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("upgrade sha256 mismatch: expected=%s actual=%s", expected, actual) + } + return nil +} + +func gunzip(payload []byte) ([]byte, error) { + reader, err := gzip.NewReader(bytes.NewReader(payload)) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) +} + +func copyFile(source string, target string) error { + sourceFile, err := os.Open(source) + if err != nil { + return err + } + defer sourceFile.Close() + + info, err := sourceFile.Stat() + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode == 0 { + mode = 0755 + } + + targetFile, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + defer targetFile.Close() + + if _, err := io.Copy(targetFile, sourceFile); err != nil { + return err + } + return targetFile.Chmod(mode) +} + +func restartSystemdService(ctx context.Context, serviceName string) error { + if !systemdServiceNamePattern.MatchString(serviceName) { + return fmt.Errorf("invalid systemd service name: %s", serviceName) + } + + if err := scheduleSystemdRestart(ctx, serviceName); err == nil { + return nil + } + + systemctlPath, err := exec.LookPath("systemctl") + if err != nil { + return err + } + + // 不等待 systemctl 完成。Agent 正在重启自身,等待子进程会在服务停止时 + // 收到 SIGTERM,旧逻辑会误判失败并回滚已替换的新二进制。 + command := exec.CommandContext(ctx, systemctlPath, "restart", serviceName) + return command.Start() +} + +func scheduleSystemdRestart(ctx context.Context, serviceName string) error { + systemdRunPath, err := exec.LookPath("systemd-run") + if err != nil { + return err + } + systemctlPath, err := exec.LookPath("systemctl") + if err != nil { + return err + } + + unitName := fmt.Sprintf("incudal-agent-restart-%d", os.Getpid()) + args := []string{ + "--unit", unitName, + "--description", "Restart Incudal Agent after self-upgrade", + "--on-active=2s", + "--collect", + systemctlPath, "restart", serviceName, + } + + command := exec.CommandContext(ctx, systemdRunPath, args...) + output, err := command.CombinedOutput() + if err == nil { + return nil + } + + // 老版本 systemd 可能不支持 --collect,降级重试一次。 + if strings.Contains(string(output), "unrecognized option '--collect'") || + strings.Contains(string(output), "Unknown option --collect") { + args = []string{ + "--unit", unitName, + "--description", "Restart Incudal Agent after self-upgrade", + "--on-active=2s", + systemctlPath, "restart", serviceName, + } + command = exec.CommandContext(ctx, systemdRunPath, args...) + output, err = command.CombinedOutput() + } + if err != nil { + trimmedOutput := strings.TrimSpace(string(output)) + if trimmedOutput == "" { + return err + } + return fmt.Errorf("%w: %s", err, trimmedOutput) + } + return nil +} diff --git a/agent/internal/upgrade/upgrade_test.go b/agent/internal/upgrade/upgrade_test.go new file mode 100644 index 0000000..f889111 --- /dev/null +++ b/agent/internal/upgrade/upgrade_test.go @@ -0,0 +1,230 @@ +package upgrade + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "incudal-agent/internal/panel" +) + +func TestApplyUpgradeReplacesBinaryAndRestarts(t *testing.T) { + tempDir := t.TempDir() + binaryPath := filepath.Join(tempDir, "incudal-agent") + if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil { + t.Fatalf("write current binary: %v", err) + } + + nextBinary := []byte("new-binary") + packageBytes := gzipBytes(t, nextBinary) + sha := sha256Hex(packageBytes) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusOK) + _, _ = response.Write(packageBytes) + })) + defer server.Close() + + restarted := false + runner := Runner{ + BinaryPath: binaryPath, + BackupPath: binaryPath + ".bak", + LockPath: filepath.Join(tempDir, "upgrade.lock"), + ServiceName: "incudal-agent", + AllowedBaseURL: server.URL, + HTTPClient: server.Client(), + MaxDownloadBytes: 1024 * 1024, + Restart: func(_ context.Context, serviceName string) error { + if serviceName != "incudal-agent" { + t.Fatalf("unexpected service name: %s", serviceName) + } + restarted = true + return nil + }, + } + + err := runner.Apply(context.Background(), panel.UpgradeInstruction{ + Available: true, + Version: "v2", + URL: server.URL + "/incudal-agent-linux-amd64.gz", + SHA256: sha, + Gzip: true, + }, "v1") + if err != nil { + t.Fatalf("apply upgrade: %v", err) + } + if !restarted { + t.Fatalf("restart was not called") + } + + actual, err := os.ReadFile(binaryPath) + if err != nil { + t.Fatalf("read replaced binary: %v", err) + } + if string(actual) != string(nextBinary) { + t.Fatalf("binary mismatch: %q", string(actual)) + } + + backup, err := os.ReadFile(binaryPath + ".bak") + if err != nil { + t.Fatalf("read backup binary: %v", err) + } + if string(backup) != "old-binary" { + t.Fatalf("backup mismatch: %q", string(backup)) + } +} + +func TestApplyUpgradeRejectsBadSHA(t *testing.T) { + tempDir := t.TempDir() + binaryPath := filepath.Join(tempDir, "incudal-agent") + if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil { + t.Fatalf("write current binary: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte("payload")) + })) + defer server.Close() + + restarted := false + runner := Runner{ + BinaryPath: binaryPath, + BackupPath: binaryPath + ".bak", + LockPath: filepath.Join(tempDir, "upgrade.lock"), + AllowedBaseURL: server.URL, + HTTPClient: server.Client(), + MaxDownloadBytes: 1024 * 1024, + Restart: func(context.Context, string) error { + restarted = true + return nil + }, + } + + err := runner.Apply(context.Background(), panel.UpgradeInstruction{ + Available: true, + Version: "v2", + URL: server.URL + "/incudal-agent-linux-amd64.gz", + SHA256: "0000000000000000000000000000000000000000000000000000000000000000", + Gzip: true, + }, "v1") + if err == nil { + t.Fatalf("expected sha mismatch") + } + if restarted { + t.Fatalf("restart should not be called") + } + + current, err := os.ReadFile(binaryPath) + if err != nil { + t.Fatalf("read current binary: %v", err) + } + if string(current) != "old-binary" { + t.Fatalf("current binary should stay unchanged: %q", string(current)) + } +} + +func TestApplyUpgradeDoesNotRollbackWhenSelfRestartIsInterrupted(t *testing.T) { + tempDir := t.TempDir() + binaryPath := filepath.Join(tempDir, "incudal-agent") + if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil { + t.Fatalf("write current binary: %v", err) + } + + nextBinary := []byte("new-binary") + packageBytes := gzipBytes(t, nextBinary) + sha := sha256Hex(packageBytes) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusOK) + _, _ = response.Write(packageBytes) + })) + defer server.Close() + + runner := Runner{ + BinaryPath: binaryPath, + BackupPath: binaryPath + ".bak", + LockPath: filepath.Join(tempDir, "upgrade.lock"), + ServiceName: "incudal-agent", + AllowedBaseURL: server.URL, + HTTPClient: server.Client(), + MaxDownloadBytes: 1024 * 1024, + Restart: func(context.Context, string) error { + return errors.New("signal: terminated") + }, + } + + err := runner.Apply(context.Background(), panel.UpgradeInstruction{ + Available: true, + Version: "v2", + URL: server.URL + "/incudal-agent-linux-amd64.gz", + SHA256: sha, + Gzip: true, + }, "v1") + if err == nil { + t.Fatalf("expected restart error") + } + + actual, err := os.ReadFile(binaryPath) + if err != nil { + t.Fatalf("read replaced binary: %v", err) + } + if string(actual) != string(nextBinary) { + t.Fatalf("binary should stay replaced after restart interruption: %q", string(actual)) + } +} + +func TestApplyUpgradeRejectsDifferentOrigin(t *testing.T) { + tempDir := t.TempDir() + binaryPath := filepath.Join(tempDir, "incudal-agent") + if err := os.WriteFile(binaryPath, []byte("old-binary"), 0755); err != nil { + t.Fatalf("write current binary: %v", err) + } + + runner := Runner{ + BinaryPath: binaryPath, + BackupPath: binaryPath + ".bak", + LockPath: filepath.Join(tempDir, "upgrade.lock"), + AllowedBaseURL: "https://panel.example", + Restart: func(context.Context, string) error { + t.Fatalf("restart should not be called") + return nil + }, + } + + err := runner.Apply(context.Background(), panel.UpgradeInstruction{ + Available: true, + Version: "v2", + URL: "https://evil.example/incudal-agent-linux-amd64.gz", + SHA256: "0000000000000000000000000000000000000000000000000000000000000000", + Gzip: true, + }, "v1") + if err == nil { + t.Fatalf("expected origin validation error") + } +} + +func gzipBytes(t *testing.T, payload []byte) []byte { + t.Helper() + + var buffer bytes.Buffer + writer := gzip.NewWriter(&buffer) + if _, err := writer.Write(payload); err != nil { + t.Fatalf("gzip write: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buffer.Bytes() +} + +func sha256Hex(payload []byte) string { + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} diff --git a/agent/scripts/build-release.sh b/agent/scripts/build-release.sh new file mode 100644 index 0000000..36f4dc5 --- /dev/null +++ b/agent/scripts/build-release.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="${DIST_DIR:-${ROOT_DIR}/dist}" +VERSION_FILE="${VERSION_FILE:-${ROOT_DIR}/VERSION}" +VERSION_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + +default_version() { + if [ ! -f "${VERSION_FILE}" ]; then + echo "Agent version file not found: ${VERSION_FILE}" >&2 + exit 1 + fi + + tr -d '[:space:]' < "${VERSION_FILE}" +} + +VERSION="${VERSION:-$(default_version)}" +if [[ ! "${VERSION}" =~ ${VERSION_PATTERN} ]]; then + echo "Invalid Agent version: ${VERSION}" >&2 + echo "Expected format: vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-suffix" >&2 + exit 1 +fi + +mkdir -p "${DIST_DIR}" + +build_one() { + local goarch="$1" + local output="${DIST_DIR}/incudal-agent-linux-${goarch}" + + echo "Building ${output} (version=${VERSION})" + ( + cd "${ROOT_DIR}" + CGO_ENABLED=0 GOOS=linux GOARCH="${goarch}" \ + go build \ + -trimpath \ + -buildvcs=false \ + -gcflags "all=-l" \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o "${output}" \ + ./cmd/incudal-agent + ) + chmod +x "${output}" + gzip -9 -c "${output}" > "${output}.gz" +} + +build_one amd64 +build_one arm64 + +sha256_file() { + sha256sum "$1" | awk '{print $1}' +} + +size_file() { + wc -c < "$1" | tr -d ' ' +} + +GENERATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +AMD64_GZ="${DIST_DIR}/incudal-agent-linux-amd64.gz" +ARM64_GZ="${DIST_DIR}/incudal-agent-linux-arm64.gz" +cat > "${DIST_DIR}/manifest.json" < + } + }, + plugins: { + '@typescript-eslint': tseslint + }, + rules: { + // Vue 特定规则 + 'vue/multi-word-component-names': 'off', + 'vue/no-v-html': 'off', // 项目中使用了 markdown 渲染,需要 v-html + 'vue/require-default-prop': 'off', + 'vue/require-explicit-emits': 'warn', + 'vue/html-self-closing': 'off', + 'vue/max-attributes-per-line': 'off', + 'vue/singleline-html-element-content-newline': 'off', + + // TypeScript 规则(放宽限制) + '@typescript-eslint/no-unused-vars': ['warn', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + }], + '@typescript-eslint/no-explicit-any': 'off', // 允许使用 any(API 响应等场景需要) + + // 通用规则 + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', + 'no-undef': 'off', // TypeScript 会处理 + 'no-unused-vars': 'off', // 使用 TypeScript 版本 + 'no-useless-escape': 'error' // 保留这个错误检查 + } + }, + + // JavaScript 文件配置 + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + }, + rules: { + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn' + } + } +] + diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..fca0a8d --- /dev/null +++ b/client/index.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + 基于 Incus 的低价 NAT VPS 平台 + + + + + + + + +
+ + + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..186826d --- /dev/null +++ b/client/package.json @@ -0,0 +1,54 @@ +{ + "name": "client", + "version": "1.0.0", + "description": "Incudal 前端客户端", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --fix", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "@vueuse/core": "^12.0.0", + "@xterm/addon-clipboard": "^0.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-image": "^0.9.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "axios": "^1.7.9", + "flag-icons": "^7.5.0", + "marked": "^17.0.1", + "pinia": "^2.2.8", + "simple-icons": "^16.1.0", + "vue": "^3.5.13", + "vue-i18n": "^11.2.2", + "vue-router": "^4.5.0", + "vue-turnstile": "^1.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.16.0", + "@tailwindcss/forms": "^0.5.9", + "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^8.48.1", + "@typescript-eslint/parser": "^8.48.1", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.16.0", + "eslint-plugin-vue": "^9.32.0", + "javascript-obfuscator": "^5.0.1", + "postcss": "^8.4.49", + "rollup-plugin-obfuscator": "^1.1.0", + "tailwindcss": "^3.4.17", + "terser": "^5.44.1", + "typescript": "^5.9.3", + "vite": "^6.0.3", + "vue-eslint-parser": "^9.4.2", + "vue-tsc": "^3.1.6" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..75af576 --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} + diff --git a/client/public/badges/dark/abyss.svg b/client/public/badges/dark/abyss.svg new file mode 100644 index 0000000..d1b74d7 --- /dev/null +++ b/client/public/badges/dark/abyss.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/aether.svg b/client/public/badges/dark/aether.svg new file mode 100644 index 0000000..7d48d84 --- /dev/null +++ b/client/public/badges/dark/aether.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/apex.svg b/client/public/badges/dark/apex.svg new file mode 100644 index 0000000..8c589b6 --- /dev/null +++ b/client/public/badges/dark/apex.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/aquarius.svg b/client/public/badges/dark/aquarius.svg new file mode 100644 index 0000000..abb30f5 --- /dev/null +++ b/client/public/badges/dark/aquarius.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/ares.svg b/client/public/badges/dark/ares.svg new file mode 100644 index 0000000..f5764d0 --- /dev/null +++ b/client/public/badges/dark/ares.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/aries.svg b/client/public/badges/dark/aries.svg new file mode 100644 index 0000000..dc633c6 --- /dev/null +++ b/client/public/badges/dark/aries.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/astral.svg b/client/public/badges/dark/astral.svg new file mode 100644 index 0000000..e1f1492 --- /dev/null +++ b/client/public/badges/dark/astral.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/barrage.svg b/client/public/badges/dark/barrage.svg new file mode 100644 index 0000000..4d439ae --- /dev/null +++ b/client/public/badges/dark/barrage.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/barrier.svg b/client/public/badges/dark/barrier.svg new file mode 100644 index 0000000..35ce681 --- /dev/null +++ b/client/public/badges/dark/barrier.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/bastion.svg b/client/public/badges/dark/bastion.svg new file mode 100644 index 0000000..28afc4b --- /dev/null +++ b/client/public/badges/dark/bastion.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/blackhole.svg b/client/public/badges/dark/blackhole.svg new file mode 100644 index 0000000..05f2d91 --- /dev/null +++ b/client/public/badges/dark/blackhole.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/cancer.svg b/client/public/badges/dark/cancer.svg new file mode 100644 index 0000000..ccd67d8 --- /dev/null +++ b/client/public/badges/dark/cancer.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/capricorn.svg b/client/public/badges/dark/capricorn.svg new file mode 100644 index 0000000..9f10d6c --- /dev/null +++ b/client/public/badges/dark/capricorn.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/citadel.svg b/client/public/badges/dark/citadel.svg new file mode 100644 index 0000000..6411f37 --- /dev/null +++ b/client/public/badges/dark/citadel.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/core.svg b/client/public/badges/dark/core.svg new file mode 100644 index 0000000..05d7b6f --- /dev/null +++ b/client/public/badges/dark/core.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/cronus.svg b/client/public/badges/dark/cronus.svg new file mode 100644 index 0000000..e4ba305 --- /dev/null +++ b/client/public/badges/dark/cronus.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/crosshair.svg b/client/public/badges/dark/crosshair.svg new file mode 100644 index 0000000..8db88be --- /dev/null +++ b/client/public/badges/dark/crosshair.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/defender.svg b/client/public/badges/dark/defender.svg new file mode 100644 index 0000000..ac48dfc --- /dev/null +++ b/client/public/badges/dark/defender.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/dog.svg b/client/public/badges/dark/dog.svg new file mode 100644 index 0000000..08c1975 --- /dev/null +++ b/client/public/badges/dark/dog.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/dragon.svg b/client/public/badges/dark/dragon.svg new file mode 100644 index 0000000..bff6ee8 --- /dev/null +++ b/client/public/badges/dark/dragon.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/dynamo.svg b/client/public/badges/dark/dynamo.svg new file mode 100644 index 0000000..9aa9e45 --- /dev/null +++ b/client/public/badges/dark/dynamo.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/earth.svg b/client/public/badges/dark/earth.svg new file mode 100644 index 0000000..986d93f --- /dev/null +++ b/client/public/badges/dark/earth.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/elite.svg b/client/public/badges/dark/elite.svg new file mode 100644 index 0000000..62821ff --- /dev/null +++ b/client/public/badges/dark/elite.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/enigma.svg b/client/public/badges/dark/enigma.svg new file mode 100644 index 0000000..75b5725 --- /dev/null +++ b/client/public/badges/dark/enigma.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/fission.svg b/client/public/badges/dark/fission.svg new file mode 100644 index 0000000..318bcea --- /dev/null +++ b/client/public/badges/dark/fission.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/fortress.svg b/client/public/badges/dark/fortress.svg new file mode 100644 index 0000000..f8d8d51 --- /dev/null +++ b/client/public/badges/dark/fortress.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/fusion.svg b/client/public/badges/dark/fusion.svg new file mode 100644 index 0000000..611dc52 --- /dev/null +++ b/client/public/badges/dark/fusion.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/galaxy.svg b/client/public/badges/dark/galaxy.svg new file mode 100644 index 0000000..c4f3b05 --- /dev/null +++ b/client/public/badges/dark/galaxy.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/gemini.svg b/client/public/badges/dark/gemini.svg new file mode 100644 index 0000000..df0193b --- /dev/null +++ b/client/public/badges/dark/gemini.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/genesis.svg b/client/public/badges/dark/genesis.svg new file mode 100644 index 0000000..44626be --- /dev/null +++ b/client/public/badges/dark/genesis.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/glyph.svg b/client/public/badges/dark/glyph.svg new file mode 100644 index 0000000..44eed37 --- /dev/null +++ b/client/public/badges/dark/glyph.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/goat.svg b/client/public/badges/dark/goat.svg new file mode 100644 index 0000000..952ad8a --- /dev/null +++ b/client/public/badges/dark/goat.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/guardian.svg b/client/public/badges/dark/guardian.svg new file mode 100644 index 0000000..b0a0a81 --- /dev/null +++ b/client/public/badges/dark/guardian.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/horse.svg b/client/public/badges/dark/horse.svg new file mode 100644 index 0000000..cfd18da --- /dev/null +++ b/client/public/badges/dark/horse.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/ignition.svg b/client/public/badges/dark/ignition.svg new file mode 100644 index 0000000..572c66f --- /dev/null +++ b/client/public/badges/dark/ignition.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/intercept.svg b/client/public/badges/dark/intercept.svg new file mode 100644 index 0000000..4e384ee --- /dev/null +++ b/client/public/badges/dark/intercept.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/jupiter.svg b/client/public/badges/dark/jupiter.svg new file mode 100644 index 0000000..f4c69bc --- /dev/null +++ b/client/public/badges/dark/jupiter.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/leo.svg b/client/public/badges/dark/leo.svg new file mode 100644 index 0000000..2bd9877 --- /dev/null +++ b/client/public/badges/dark/leo.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/libra.svg b/client/public/badges/dark/libra.svg new file mode 100644 index 0000000..f00cc32 --- /dev/null +++ b/client/public/badges/dark/libra.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/lockon.svg b/client/public/badges/dark/lockon.svg new file mode 100644 index 0000000..2665868 --- /dev/null +++ b/client/public/badges/dark/lockon.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/matrix.svg b/client/public/badges/dark/matrix.svg new file mode 100644 index 0000000..eed6082 --- /dev/null +++ b/client/public/badges/dark/matrix.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/mercury.svg b/client/public/badges/dark/mercury.svg new file mode 100644 index 0000000..39ff9f1 --- /dev/null +++ b/client/public/badges/dark/mercury.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/monkey.svg b/client/public/badges/dark/monkey.svg new file mode 100644 index 0000000..0faa226 --- /dev/null +++ b/client/public/badges/dark/monkey.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/moon.svg b/client/public/badges/dark/moon.svg new file mode 100644 index 0000000..4e0d1a6 --- /dev/null +++ b/client/public/badges/dark/moon.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/mystic.svg b/client/public/badges/dark/mystic.svg new file mode 100644 index 0000000..4d67beb --- /dev/null +++ b/client/public/badges/dark/mystic.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/nebula.svg b/client/public/badges/dark/nebula.svg new file mode 100644 index 0000000..382f634 --- /dev/null +++ b/client/public/badges/dark/nebula.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/neptune.svg b/client/public/badges/dark/neptune.svg new file mode 100644 index 0000000..d3e5e8f --- /dev/null +++ b/client/public/badges/dark/neptune.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/nexus.svg b/client/public/badges/dark/nexus.svg new file mode 100644 index 0000000..23c4ac3 --- /dev/null +++ b/client/public/badges/dark/nexus.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/omega.svg b/client/public/badges/dark/omega.svg new file mode 100644 index 0000000..54a64d2 --- /dev/null +++ b/client/public/badges/dark/omega.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/oracle.svg b/client/public/badges/dark/oracle.svg new file mode 100644 index 0000000..7675e81 --- /dev/null +++ b/client/public/badges/dark/oracle.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/overload.svg b/client/public/badges/dark/overload.svg new file mode 100644 index 0000000..5fa0f3e --- /dev/null +++ b/client/public/badges/dark/overload.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/overwatch.svg b/client/public/badges/dark/overwatch.svg new file mode 100644 index 0000000..b959dab --- /dev/null +++ b/client/public/badges/dark/overwatch.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/ox.svg b/client/public/badges/dark/ox.svg new file mode 100644 index 0000000..5e7422e --- /dev/null +++ b/client/public/badges/dark/ox.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/paladin.svg b/client/public/badges/dark/paladin.svg new file mode 100644 index 0000000..4b7fe7c --- /dev/null +++ b/client/public/badges/dark/paladin.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/paradox.svg b/client/public/badges/dark/paradox.svg new file mode 100644 index 0000000..823dc10 --- /dev/null +++ b/client/public/badges/dark/paradox.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/phalanx.svg b/client/public/badges/dark/phalanx.svg new file mode 100644 index 0000000..0ff6485 --- /dev/null +++ b/client/public/badges/dark/phalanx.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/pig.svg b/client/public/badges/dark/pig.svg new file mode 100644 index 0000000..189e268 --- /dev/null +++ b/client/public/badges/dark/pig.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/pisces.svg b/client/public/badges/dark/pisces.svg new file mode 100644 index 0000000..fdc8ee9 --- /dev/null +++ b/client/public/badges/dark/pisces.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/plasma.svg b/client/public/badges/dark/plasma.svg new file mode 100644 index 0000000..9408ead --- /dev/null +++ b/client/public/badges/dark/plasma.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/pluto.svg b/client/public/badges/dark/pluto.svg new file mode 100644 index 0000000..3765e29 --- /dev/null +++ b/client/public/badges/dark/pluto.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/pulsar.svg b/client/public/badges/dark/pulsar.svg new file mode 100644 index 0000000..3fbd696 --- /dev/null +++ b/client/public/badges/dark/pulsar.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/pulse.svg b/client/public/badges/dark/pulse.svg new file mode 100644 index 0000000..6a3b3e7 --- /dev/null +++ b/client/public/badges/dark/pulse.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/quantum.svg b/client/public/badges/dark/quantum.svg new file mode 100644 index 0000000..d567b27 --- /dev/null +++ b/client/public/badges/dark/quantum.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/rabbit.svg b/client/public/badges/dark/rabbit.svg new file mode 100644 index 0000000..3316794 --- /dev/null +++ b/client/public/badges/dark/rabbit.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/radar.svg b/client/public/badges/dark/radar.svg new file mode 100644 index 0000000..1d0096a --- /dev/null +++ b/client/public/badges/dark/radar.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/radiance.svg b/client/public/badges/dark/radiance.svg new file mode 100644 index 0000000..1df6f76 --- /dev/null +++ b/client/public/badges/dark/radiance.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/rat.svg b/client/public/badges/dark/rat.svg new file mode 100644 index 0000000..2b0581c --- /dev/null +++ b/client/public/badges/dark/rat.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/recon.svg b/client/public/badges/dark/recon.svg new file mode 100644 index 0000000..87a3c96 --- /dev/null +++ b/client/public/badges/dark/recon.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/rooster.svg b/client/public/badges/dark/rooster.svg new file mode 100644 index 0000000..64d4d96 --- /dev/null +++ b/client/public/badges/dark/rooster.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/rune.svg b/client/public/badges/dark/rune.svg new file mode 100644 index 0000000..12f1bd2 --- /dev/null +++ b/client/public/badges/dark/rune.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/sagittarius.svg b/client/public/badges/dark/sagittarius.svg new file mode 100644 index 0000000..6da6ff2 --- /dev/null +++ b/client/public/badges/dark/sagittarius.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/scorpio.svg b/client/public/badges/dark/scorpio.svg new file mode 100644 index 0000000..a653484 --- /dev/null +++ b/client/public/badges/dark/scorpio.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/sentinel.svg b/client/public/badges/dark/sentinel.svg new file mode 100644 index 0000000..292f4c3 --- /dev/null +++ b/client/public/badges/dark/sentinel.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/sigil.svg b/client/public/badges/dark/sigil.svg new file mode 100644 index 0000000..f9fe656 --- /dev/null +++ b/client/public/badges/dark/sigil.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/snake.svg b/client/public/badges/dark/snake.svg new file mode 100644 index 0000000..1b21ec3 --- /dev/null +++ b/client/public/badges/dark/snake.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/sniper.svg b/client/public/badges/dark/sniper.svg new file mode 100644 index 0000000..1468881 --- /dev/null +++ b/client/public/badges/dark/sniper.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/solaris.svg b/client/public/badges/dark/solaris.svg new file mode 100644 index 0000000..4ff31ed --- /dev/null +++ b/client/public/badges/dark/solaris.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/strike.svg b/client/public/badges/dark/strike.svg new file mode 100644 index 0000000..e8c863e --- /dev/null +++ b/client/public/badges/dark/strike.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/taurus.svg b/client/public/badges/dark/taurus.svg new file mode 100644 index 0000000..37d35b4 --- /dev/null +++ b/client/public/badges/dark/taurus.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/tiger.svg b/client/public/badges/dark/tiger.svg new file mode 100644 index 0000000..6d904ab --- /dev/null +++ b/client/public/badges/dark/tiger.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/titan.svg b/client/public/badges/dark/titan.svg new file mode 100644 index 0000000..9a88787 --- /dev/null +++ b/client/public/badges/dark/titan.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/tracer.svg b/client/public/badges/dark/tracer.svg new file mode 100644 index 0000000..612e886 --- /dev/null +++ b/client/public/badges/dark/tracer.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/ultra.svg b/client/public/badges/dark/ultra.svg new file mode 100644 index 0000000..4c32809 --- /dev/null +++ b/client/public/badges/dark/ultra.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/uranus.svg b/client/public/badges/dark/uranus.svg new file mode 100644 index 0000000..6d2ce12 --- /dev/null +++ b/client/public/badges/dark/uranus.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/vanguard.svg b/client/public/badges/dark/vanguard.svg new file mode 100644 index 0000000..2ea4dd9 --- /dev/null +++ b/client/public/badges/dark/vanguard.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/virgo.svg b/client/public/badges/dark/virgo.svg new file mode 100644 index 0000000..a30a8c3 --- /dev/null +++ b/client/public/badges/dark/virgo.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/vortex.svg b/client/public/badges/dark/vortex.svg new file mode 100644 index 0000000..0c83859 --- /dev/null +++ b/client/public/badges/dark/vortex.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/ward.svg b/client/public/badges/dark/ward.svg new file mode 100644 index 0000000..f3c8a3a --- /dev/null +++ b/client/public/badges/dark/ward.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/dark/wormhole.svg b/client/public/badges/dark/wormhole.svg new file mode 100644 index 0000000..ab3a053 --- /dev/null +++ b/client/public/badges/dark/wormhole.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/abyss.svg b/client/public/badges/light/abyss.svg new file mode 100644 index 0000000..660bac3 --- /dev/null +++ b/client/public/badges/light/abyss.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/aether.svg b/client/public/badges/light/aether.svg new file mode 100644 index 0000000..8b262db --- /dev/null +++ b/client/public/badges/light/aether.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/apex.svg b/client/public/badges/light/apex.svg new file mode 100644 index 0000000..2077836 --- /dev/null +++ b/client/public/badges/light/apex.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/aquarius.svg b/client/public/badges/light/aquarius.svg new file mode 100644 index 0000000..e0d17d6 --- /dev/null +++ b/client/public/badges/light/aquarius.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/ares.svg b/client/public/badges/light/ares.svg new file mode 100644 index 0000000..6293dc7 --- /dev/null +++ b/client/public/badges/light/ares.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/aries.svg b/client/public/badges/light/aries.svg new file mode 100644 index 0000000..13df493 --- /dev/null +++ b/client/public/badges/light/aries.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/astral.svg b/client/public/badges/light/astral.svg new file mode 100644 index 0000000..411abf6 --- /dev/null +++ b/client/public/badges/light/astral.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/barrage.svg b/client/public/badges/light/barrage.svg new file mode 100644 index 0000000..d2d8381 --- /dev/null +++ b/client/public/badges/light/barrage.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/barrier.svg b/client/public/badges/light/barrier.svg new file mode 100644 index 0000000..39bb419 --- /dev/null +++ b/client/public/badges/light/barrier.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/bastion.svg b/client/public/badges/light/bastion.svg new file mode 100644 index 0000000..2641e0e --- /dev/null +++ b/client/public/badges/light/bastion.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/blackhole.svg b/client/public/badges/light/blackhole.svg new file mode 100644 index 0000000..7235bd8 --- /dev/null +++ b/client/public/badges/light/blackhole.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/cancer.svg b/client/public/badges/light/cancer.svg new file mode 100644 index 0000000..ac70a73 --- /dev/null +++ b/client/public/badges/light/cancer.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/capricorn.svg b/client/public/badges/light/capricorn.svg new file mode 100644 index 0000000..c60ecad --- /dev/null +++ b/client/public/badges/light/capricorn.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/citadel.svg b/client/public/badges/light/citadel.svg new file mode 100644 index 0000000..e4884cf --- /dev/null +++ b/client/public/badges/light/citadel.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/core.svg b/client/public/badges/light/core.svg new file mode 100644 index 0000000..12bd06c --- /dev/null +++ b/client/public/badges/light/core.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/cronus.svg b/client/public/badges/light/cronus.svg new file mode 100644 index 0000000..7534ae8 --- /dev/null +++ b/client/public/badges/light/cronus.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/crosshair.svg b/client/public/badges/light/crosshair.svg new file mode 100644 index 0000000..7c0743a --- /dev/null +++ b/client/public/badges/light/crosshair.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/defender.svg b/client/public/badges/light/defender.svg new file mode 100644 index 0000000..4592fd9 --- /dev/null +++ b/client/public/badges/light/defender.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/dog.svg b/client/public/badges/light/dog.svg new file mode 100644 index 0000000..9472ff7 --- /dev/null +++ b/client/public/badges/light/dog.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/dragon.svg b/client/public/badges/light/dragon.svg new file mode 100644 index 0000000..f2e934b --- /dev/null +++ b/client/public/badges/light/dragon.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/dynamo.svg b/client/public/badges/light/dynamo.svg new file mode 100644 index 0000000..7457980 --- /dev/null +++ b/client/public/badges/light/dynamo.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/earth.svg b/client/public/badges/light/earth.svg new file mode 100644 index 0000000..4f38d8f --- /dev/null +++ b/client/public/badges/light/earth.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/elite.svg b/client/public/badges/light/elite.svg new file mode 100644 index 0000000..97d39b6 --- /dev/null +++ b/client/public/badges/light/elite.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/enigma.svg b/client/public/badges/light/enigma.svg new file mode 100644 index 0000000..f28d58a --- /dev/null +++ b/client/public/badges/light/enigma.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/fission.svg b/client/public/badges/light/fission.svg new file mode 100644 index 0000000..75e491c --- /dev/null +++ b/client/public/badges/light/fission.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/fortress.svg b/client/public/badges/light/fortress.svg new file mode 100644 index 0000000..369f406 --- /dev/null +++ b/client/public/badges/light/fortress.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/fusion.svg b/client/public/badges/light/fusion.svg new file mode 100644 index 0000000..73ee2f6 --- /dev/null +++ b/client/public/badges/light/fusion.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/galaxy.svg b/client/public/badges/light/galaxy.svg new file mode 100644 index 0000000..95c55c5 --- /dev/null +++ b/client/public/badges/light/galaxy.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/gemini.svg b/client/public/badges/light/gemini.svg new file mode 100644 index 0000000..01ecd9e --- /dev/null +++ b/client/public/badges/light/gemini.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/genesis.svg b/client/public/badges/light/genesis.svg new file mode 100644 index 0000000..d9325ff --- /dev/null +++ b/client/public/badges/light/genesis.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/glyph.svg b/client/public/badges/light/glyph.svg new file mode 100644 index 0000000..997e2bd --- /dev/null +++ b/client/public/badges/light/glyph.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/goat.svg b/client/public/badges/light/goat.svg new file mode 100644 index 0000000..12a185d --- /dev/null +++ b/client/public/badges/light/goat.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/guardian.svg b/client/public/badges/light/guardian.svg new file mode 100644 index 0000000..811a92c --- /dev/null +++ b/client/public/badges/light/guardian.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/horse.svg b/client/public/badges/light/horse.svg new file mode 100644 index 0000000..a5bb4af --- /dev/null +++ b/client/public/badges/light/horse.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/ignition.svg b/client/public/badges/light/ignition.svg new file mode 100644 index 0000000..334d575 --- /dev/null +++ b/client/public/badges/light/ignition.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/intercept.svg b/client/public/badges/light/intercept.svg new file mode 100644 index 0000000..f48d3a9 --- /dev/null +++ b/client/public/badges/light/intercept.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/jupiter.svg b/client/public/badges/light/jupiter.svg new file mode 100644 index 0000000..dece832 --- /dev/null +++ b/client/public/badges/light/jupiter.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/leo.svg b/client/public/badges/light/leo.svg new file mode 100644 index 0000000..7a02ecc --- /dev/null +++ b/client/public/badges/light/leo.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/libra.svg b/client/public/badges/light/libra.svg new file mode 100644 index 0000000..7a2dc0a --- /dev/null +++ b/client/public/badges/light/libra.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/lockon.svg b/client/public/badges/light/lockon.svg new file mode 100644 index 0000000..9b77107 --- /dev/null +++ b/client/public/badges/light/lockon.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/matrix.svg b/client/public/badges/light/matrix.svg new file mode 100644 index 0000000..7e47fdf --- /dev/null +++ b/client/public/badges/light/matrix.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/mercury.svg b/client/public/badges/light/mercury.svg new file mode 100644 index 0000000..9ff324b --- /dev/null +++ b/client/public/badges/light/mercury.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/monkey.svg b/client/public/badges/light/monkey.svg new file mode 100644 index 0000000..234dcc7 --- /dev/null +++ b/client/public/badges/light/monkey.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/moon.svg b/client/public/badges/light/moon.svg new file mode 100644 index 0000000..59e96bf --- /dev/null +++ b/client/public/badges/light/moon.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/mystic.svg b/client/public/badges/light/mystic.svg new file mode 100644 index 0000000..1f6d1c6 --- /dev/null +++ b/client/public/badges/light/mystic.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/nebula.svg b/client/public/badges/light/nebula.svg new file mode 100644 index 0000000..a4856b7 --- /dev/null +++ b/client/public/badges/light/nebula.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/neptune.svg b/client/public/badges/light/neptune.svg new file mode 100644 index 0000000..049eacb --- /dev/null +++ b/client/public/badges/light/neptune.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/nexus.svg b/client/public/badges/light/nexus.svg new file mode 100644 index 0000000..d8e9ccc --- /dev/null +++ b/client/public/badges/light/nexus.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/omega.svg b/client/public/badges/light/omega.svg new file mode 100644 index 0000000..482a8f1 --- /dev/null +++ b/client/public/badges/light/omega.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/oracle.svg b/client/public/badges/light/oracle.svg new file mode 100644 index 0000000..1a929d6 --- /dev/null +++ b/client/public/badges/light/oracle.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/overload.svg b/client/public/badges/light/overload.svg new file mode 100644 index 0000000..d75d239 --- /dev/null +++ b/client/public/badges/light/overload.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/overwatch.svg b/client/public/badges/light/overwatch.svg new file mode 100644 index 0000000..99f2e91 --- /dev/null +++ b/client/public/badges/light/overwatch.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/ox.svg b/client/public/badges/light/ox.svg new file mode 100644 index 0000000..e186d81 --- /dev/null +++ b/client/public/badges/light/ox.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/paladin.svg b/client/public/badges/light/paladin.svg new file mode 100644 index 0000000..a85b0db --- /dev/null +++ b/client/public/badges/light/paladin.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/paradox.svg b/client/public/badges/light/paradox.svg new file mode 100644 index 0000000..5812165 --- /dev/null +++ b/client/public/badges/light/paradox.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/phalanx.svg b/client/public/badges/light/phalanx.svg new file mode 100644 index 0000000..652fe4b --- /dev/null +++ b/client/public/badges/light/phalanx.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/pig.svg b/client/public/badges/light/pig.svg new file mode 100644 index 0000000..40ba238 --- /dev/null +++ b/client/public/badges/light/pig.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/pisces.svg b/client/public/badges/light/pisces.svg new file mode 100644 index 0000000..f75290b --- /dev/null +++ b/client/public/badges/light/pisces.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/plasma.svg b/client/public/badges/light/plasma.svg new file mode 100644 index 0000000..c9e67db --- /dev/null +++ b/client/public/badges/light/plasma.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/pluto.svg b/client/public/badges/light/pluto.svg new file mode 100644 index 0000000..1ac80f3 --- /dev/null +++ b/client/public/badges/light/pluto.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/pulsar.svg b/client/public/badges/light/pulsar.svg new file mode 100644 index 0000000..1a3a30a --- /dev/null +++ b/client/public/badges/light/pulsar.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/pulse.svg b/client/public/badges/light/pulse.svg new file mode 100644 index 0000000..0913e30 --- /dev/null +++ b/client/public/badges/light/pulse.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/quantum.svg b/client/public/badges/light/quantum.svg new file mode 100644 index 0000000..3832d1e --- /dev/null +++ b/client/public/badges/light/quantum.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/rabbit.svg b/client/public/badges/light/rabbit.svg new file mode 100644 index 0000000..9a77916 --- /dev/null +++ b/client/public/badges/light/rabbit.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/radar.svg b/client/public/badges/light/radar.svg new file mode 100644 index 0000000..4ea319f --- /dev/null +++ b/client/public/badges/light/radar.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/radiance.svg b/client/public/badges/light/radiance.svg new file mode 100644 index 0000000..176787f --- /dev/null +++ b/client/public/badges/light/radiance.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/rat.svg b/client/public/badges/light/rat.svg new file mode 100644 index 0000000..1ba7b70 --- /dev/null +++ b/client/public/badges/light/rat.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/recon.svg b/client/public/badges/light/recon.svg new file mode 100644 index 0000000..af9f57b --- /dev/null +++ b/client/public/badges/light/recon.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/rooster.svg b/client/public/badges/light/rooster.svg new file mode 100644 index 0000000..a186e56 --- /dev/null +++ b/client/public/badges/light/rooster.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/rune.svg b/client/public/badges/light/rune.svg new file mode 100644 index 0000000..d7f99dd --- /dev/null +++ b/client/public/badges/light/rune.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/sagittarius.svg b/client/public/badges/light/sagittarius.svg new file mode 100644 index 0000000..cb09c78 --- /dev/null +++ b/client/public/badges/light/sagittarius.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/scorpio.svg b/client/public/badges/light/scorpio.svg new file mode 100644 index 0000000..ff94d62 --- /dev/null +++ b/client/public/badges/light/scorpio.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/sentinel.svg b/client/public/badges/light/sentinel.svg new file mode 100644 index 0000000..3aeab18 --- /dev/null +++ b/client/public/badges/light/sentinel.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/sigil.svg b/client/public/badges/light/sigil.svg new file mode 100644 index 0000000..1aaadb3 --- /dev/null +++ b/client/public/badges/light/sigil.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/snake.svg b/client/public/badges/light/snake.svg new file mode 100644 index 0000000..2c1437e --- /dev/null +++ b/client/public/badges/light/snake.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/sniper.svg b/client/public/badges/light/sniper.svg new file mode 100644 index 0000000..a2ecc99 --- /dev/null +++ b/client/public/badges/light/sniper.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/solaris.svg b/client/public/badges/light/solaris.svg new file mode 100644 index 0000000..bfaab05 --- /dev/null +++ b/client/public/badges/light/solaris.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/strike.svg b/client/public/badges/light/strike.svg new file mode 100644 index 0000000..a22afc7 --- /dev/null +++ b/client/public/badges/light/strike.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/taurus.svg b/client/public/badges/light/taurus.svg new file mode 100644 index 0000000..a6d5699 --- /dev/null +++ b/client/public/badges/light/taurus.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/tiger.svg b/client/public/badges/light/tiger.svg new file mode 100644 index 0000000..ff22606 --- /dev/null +++ b/client/public/badges/light/tiger.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/titan.svg b/client/public/badges/light/titan.svg new file mode 100644 index 0000000..0e961da --- /dev/null +++ b/client/public/badges/light/titan.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/tracer.svg b/client/public/badges/light/tracer.svg new file mode 100644 index 0000000..ec1c7d2 --- /dev/null +++ b/client/public/badges/light/tracer.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/ultra.svg b/client/public/badges/light/ultra.svg new file mode 100644 index 0000000..122c4bd --- /dev/null +++ b/client/public/badges/light/ultra.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/uranus.svg b/client/public/badges/light/uranus.svg new file mode 100644 index 0000000..3f00d15 --- /dev/null +++ b/client/public/badges/light/uranus.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/vanguard.svg b/client/public/badges/light/vanguard.svg new file mode 100644 index 0000000..ff3727c --- /dev/null +++ b/client/public/badges/light/vanguard.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/virgo.svg b/client/public/badges/light/virgo.svg new file mode 100644 index 0000000..e19e9f8 --- /dev/null +++ b/client/public/badges/light/virgo.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/vortex.svg b/client/public/badges/light/vortex.svg new file mode 100644 index 0000000..d38b3c8 --- /dev/null +++ b/client/public/badges/light/vortex.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/ward.svg b/client/public/badges/light/ward.svg new file mode 100644 index 0000000..b0eccaa --- /dev/null +++ b/client/public/badges/light/ward.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/badges/light/wormhole.svg b/client/public/badges/light/wormhole.svg new file mode 100644 index 0000000..90b5574 --- /dev/null +++ b/client/public/badges/light/wormhole.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/icons/peer.svg b/client/public/icons/peer.svg new file mode 100644 index 0000000..824e52b --- /dev/null +++ b/client/public/icons/peer.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/prime.svg b/client/public/icons/prime.svg new file mode 100644 index 0000000..8e0ecca --- /dev/null +++ b/client/public/icons/prime.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KVM + + + + + diff --git a/client/public/icons/pro.svg b/client/public/icons/pro.svg new file mode 100644 index 0000000..50fb10e --- /dev/null +++ b/client/public/icons/pro.svg @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LXC + + + + + diff --git a/client/public/incudal_logo.webp b/client/public/incudal_logo.webp new file mode 100644 index 0000000..1bd5fc3 Binary files /dev/null and b/client/public/incudal_logo.webp differ diff --git a/client/public/sw.js b/client/public/sw.js new file mode 100644 index 0000000..803c104 --- /dev/null +++ b/client/public/sw.js @@ -0,0 +1,138 @@ +/** + * Service Worker - 静态资源缓存 + * 提升二次加载速度,支持离线访问静态资源 + */ + +const CACHE_NAME = 'incudal-cache-v2' + +// 需要缓存的静态资源类型 +const CACHEABLE_EXTENSIONS = ['.js', '.css', '.woff', '.woff2', '.ttf', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico'] + +// 需要始终从网络获取的路径(不缓存) +const NETWORK_ONLY_PATTERNS = [ + '/api/', // API 请求 + '/auth/', // 认证请求 + '/oauth/', // OAuth 请求 + '/__vite_ping', // Vite HMR + '/sw.js' // Service Worker 自身 +] + +// 安装事件 +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...') + // 跳过等待,立即激活 + event.waitUntil(self.skipWaiting()) +}) + +// 激活事件 +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...') + event.waitUntil( + Promise.all([ + // 清理旧版本缓存 + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames + .filter(cacheName => cacheName !== CACHE_NAME) + .map(cacheName => { + console.log('[SW] Deleting old cache:', cacheName) + return caches.delete(cacheName) + }) + ) + }), + // 立即接管所有客户端 + self.clients.claim() + ]) + ) +}) + +// 判断是否应该缓存该请求 +function shouldCache(url) { + // 检查是否是网络优先的路径 + for (const pattern of NETWORK_ONLY_PATTERNS) { + if (url.pathname.includes(pattern)) { + return false + } + } + + // 只缓存同源请求 + if (url.origin !== self.location.origin) { + return false + } + + // 检查文件扩展名 + const pathname = url.pathname.toLowerCase() + return CACHEABLE_EXTENSIONS.some(ext => pathname.endsWith(ext)) +} + +// 拦截请求 +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + + // 只处理 GET 请求 + if (event.request.method !== 'GET') { + return + } + + // 页面导航请求不做离线接管: + // 当前策略不缓存 HTML,如果这里兜底到缓存首页,会把瞬时网络抖动放大成“离线状态”。 + if (event.request.mode === 'navigate') { + return + } + + // 可缓存的静态资源 - 缓存优先策略(Stale While Revalidate) + if (shouldCache(url)) { + event.respondWith( + caches.match(event.request).then(cachedResponse => { + if (cachedResponse) { + // 返回缓存,同时在后台更新 + event.waitUntil( + fetch(event.request) + .then(networkResponse => { + if (networkResponse && networkResponse.status === 200) { + return caches.open(CACHE_NAME).then(cache => { + cache.put(event.request, networkResponse.clone()) + }) + } + }) + .catch(() => { + // 后台更新失败,忽略 + }) + ) + return cachedResponse + } + + // 没有缓存,从网络获取并缓存 + return fetch(event.request).then(networkResponse => { + if (networkResponse && networkResponse.status === 200) { + const responseToCache = networkResponse.clone() + event.waitUntil( + caches.open(CACHE_NAME).then(cache => { + cache.put(event.request, responseToCache) + }) + ) + } + return networkResponse + }) + }) + ) + return + } + + // 其他请求直接走网络 +}) + +// 监听消息(用于手动清除缓存等操作) +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.delete(CACHE_NAME).then(() => { + console.log('[SW] Cache cleared') + // 通知客户端 + if (event.source && event.source.postMessage) { + event.source.postMessage({ type: 'CACHE_CLEARED' }) + } + }) + ) + } +}) diff --git a/client/public/tos/en.md b/client/public/tos/en.md new file mode 100644 index 0000000..9d178e7 --- /dev/null +++ b/client/public/tos/en.md @@ -0,0 +1,3 @@ +# Terms of Service + +By using this platform, you agree to all terms of this agreement. diff --git a/client/public/tos/zh.md b/client/public/tos/zh.md new file mode 100644 index 0000000..584e3e0 --- /dev/null +++ b/client/public/tos/zh.md @@ -0,0 +1,3 @@ +# 服务协议 + +使用本平台即表示您同意本协议全部条款。 diff --git a/client/src/App.vue b/client/src/App.vue new file mode 100644 index 0000000..c64e1d7 --- /dev/null +++ b/client/src/App.vue @@ -0,0 +1,187 @@ + + + diff --git a/client/src/api/index.ts b/client/src/api/index.ts new file mode 100644 index 0000000..1da60da --- /dev/null +++ b/client/src/api/index.ts @@ -0,0 +1,5250 @@ +import axios, { type AxiosInstance } from 'axios' +import { useAuthStore } from '@/stores/auth' +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + UpdateUserResponse, + GenerateInviteRequest, + InviteListResponse, + User, + BadgeOverview, + BadgeMultiDrawResponse, + BadgeOwnership, + BadgeCatalogItem, + BadgeSeriesItem, + UserQuota, + UpdateUserRequest, + Instance, + InstanceWithDetails, + InstanceStats, + CreateInstanceRequest, + UpdateInstanceRequest, + ChangeHostOptionsResponse, + PortMapping, + CreatePortMappingRequest, + IpAddress, + Ipv6Subnet, + HostAgentStatusResponse, + HostAgentInstallCommandResponse, + HostAgentUpgradeRequestResponse, + Snapshot, + Backup, + CreateSnapshotRequest, + CreateBackupRequest, + SnapshotPolicy, + BackupPolicy, + UpdateSnapshotPolicyRequest, + UpdateBackupPolicyRequest, + Host, + HostWithDetails, + AvailableHost, + CreateHostRequest, + UpdateHostRequest, + Package, + CreatePackageRequest, + UpdatePackageRequest, + SshKey, + CreateSshKeyRequest, + NotificationChannel, + CreateNotificationChannelRequest, + UpdateNotificationChannelRequest, + OAuthConfig, + UserOAuthBinding, + UpdateOAuthConfigRequest, + HelpArticle, + CreateHelpArticleRequest, + UpdateHelpArticleRequest, + HostImagePolicy, + SystemImage, + CreateSystemImageRequest, + UpdateSystemImageRequest, + Log, + PaginatedResponse, + Ticket, + TicketMessage, + TicketStatus, + CreateTicketRequest, + PaginatedTickets, + PaginatedTicketMessages, + TerminalSavedCommand, + CreateTerminalSavedCommandRequest, + UpdateTerminalSavedCommandRequest, + TelegramBindingStatus, + TelegramBindTokenResponse, + TelegramAdminBindingsResponse, + TelegramWebhookDeleteResponse, + TelegramWebhookInfoResponse, + TelegramWebhookSetupResponse, + UserInvite, + UserInviteSummary +} from '@/types/api.js' + +export type VipRuleType = 'user' | 'hosting' +export type VipConditionMode = 'any' | 'all' +export type UserVipMetric = 'totalRecharge' | 'totalConsume' + +export interface VipBadgeStyle { + backgroundColor: string + textColor: string +} + +export interface VipBenefitHallConfig { + balance?: { + enabled?: boolean + amount?: number + } + points?: { + enabled?: boolean + amount?: number + } + instance?: { + enabled?: boolean + packageId?: number | null + packageName?: string | null + planId?: number | null + planName?: string | null + days?: number | null + quantity?: number | null + } +} + +export interface VipLevelRule { + id?: number + type: VipRuleType + level: number + enabled: boolean + conditionMode: VipConditionMode + userMetric?: UserVipMetric + minRecharge: number | null + minConsume: number | null + minHostingIncome: number | null + minHostingInstances: number | null + benefits?: Record & { + badgeStyle?: VipBadgeStyle + benefitHall?: VipBenefitHallConfig + } + badgeStyle?: VipBadgeStyle +} + +export interface VipLevelRulesResponse { + type: VipRuleType + maxLevel: number + userMetric?: UserVipMetric + rules: VipLevelRule[] +} + +export type VipProgressMetric = 'totalRecharge' | 'totalConsume' | 'totalHostingIncome' | 'instanceCount' + +export interface VipProgressCondition { + metric: VipProgressMetric + current: number + target: number + remaining: number + matched: boolean + progress: number +} + +export interface VipLevelProgress { + currentLevel: number + nextLevel: number | null + conditionMode: VipConditionMode | null + userMetric?: UserVipMetric | null + progress: number + isMaxLevel: boolean + conditions: VipProgressCondition[] +} + +export interface VipOverviewResponse { + userVipLevel: number + hostingVipLevel: number + userVipBadgeStyle?: VipBadgeStyle | null + hostingVipBadgeStyle?: VipBadgeStyle | null + userVipMetric?: UserVipMetric + userStats: { + totalRecharge: number + totalConsume: number + } + hostingStats: { + totalHostingIncome: number + instanceCount: number + } + userVipProgress?: VipLevelProgress + hostingVipProgress?: VipLevelProgress +} + +export type VipBenefitRewardType = 'balance' | 'points' | 'instance' +export type VipBenefitClaimStatus = 'delivered' | 'pending' +export type VipBenefitRewardState = 'claimable' | 'claimed' | 'locked' | 'blocked' + +export interface VipBenefitRewardConfig { + amount?: number + packageId?: number | null + packageName?: string | null + planId?: number | null + planName?: string | null + days?: number + quantity?: number +} + +export interface VipBenefitClaim { + id: number + rewardId: number + level: number + status: VipBenefitClaimStatus + claimNo: number + snapshot: Record + deliveredAt: string | null + createdAt: string +} + +export interface VipBenefitReward { + id: number + level: number + type: VipBenefitRewardType + title: string + description: string | null + claimLimit: number + sortOrder: number + enabled: boolean + config: VipBenefitRewardConfig + createdAt?: string + updatedAt?: string + claimedCount?: number + remainingClaims?: number + state?: VipBenefitRewardState + blockedByLevel?: number | null + claims?: VipBenefitClaim[] +} + +export interface VipBenefitRewardInput { + level: number + type: VipBenefitRewardType + title: string + description?: string | null + claimLimit?: number + sortOrder?: number + enabled?: boolean + config: VipBenefitRewardConfig +} + +export interface VipBenefitAmountSummary { + balanceAmount: number + pointsAmount: number + instanceQuantity: number +} + +export interface VipBenefitOverviewResponse { + currentLevel: number + userVipMetric: UserVipMetric + userStats: { + totalRecharge: number + totalConsume: number + } + userVipBadgeStyle?: VipBadgeStyle | null + rewards: VipBenefitReward[] + summary: { + totalRewards: number + unlockedRewards: number + claimableRewards: number + claimedRewards: number + lockedRewards: number + blockedRewards: number + pendingRewards: number + entitlement: VipBenefitAmountSummary + remaining: VipBenefitAmountSummary + } +} + +// API 超时配置(毫秒) +const TIMEOUT = { + DEFAULT: 30000, // 30秒 - 普通请求 + MEDIUM: 60000, // 60秒 - 中等耗时操作 + LONG: 120000, // 120秒 - 较长操作(启动/停止/重启,需等待IP获取) + SNAPSHOT: 180000, // 3分钟 - 快照/备份操作 + REBUILD: 300000, // 5分钟 - 重装系统 + CLONE: 600000, // 10分钟 - 复制实例 + BATCH: 900000, // 15分钟 - 批量操作 +} + +function buildTicketFormData( + data: CreateTicketRequest | { content: string; attachments?: File[] } +): FormData { + const formData = new FormData() + + if ('instanceId' in data && data.instanceId !== undefined && data.instanceId !== null) { + formData.append('instanceId', String(data.instanceId)) + } + if ('subject' in data) { + formData.append('subject', data.subject) + } + if ('category' in data && data.category) { + formData.append('category', data.category) + } + if ('priority' in data && data.priority) { + formData.append('priority', data.priority) + } + + formData.append('content', data.content ?? '') + + for (const file of data.attachments || []) { + formData.append('images', file) + } + + return formData +} + +// 创建 axios 实例 +const http: AxiosInstance = axios.create({ + baseURL: '/api', + timeout: TIMEOUT.DEFAULT, + headers: { + 'Content-Type': 'application/json' + } +}) + +// 刷新 token 的锁,防止多个请求同时触发刷新 +let isRefreshing = false +let failedQueue: Array<{ + resolve: (value?: unknown) => void + reject: (reason?: unknown) => void +}> = [] +// 记录最后一次刷新尝试的时间,避免过于频繁的刷新 +let lastRefreshAttempt = 0 +const MIN_REFRESH_INTERVAL = 5000 // 5秒内最多刷新一次 +// 记录认证失败的时间,防止在短时间内重复清除状态 +let lastAuthFailureTime = 0 +const AUTH_FAILURE_COOLDOWN = 10000 // 10秒内最多清除一次认证状态 + +// 处理队列中的请求 +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach(prom => { + if (error) { + prom.reject(error) + } else { + prom.resolve(token) + } + }) + failedQueue = [] +} + +/** + * 解析 JWT token,获取过期时间 + */ +function parseJWT(token: string): { exp?: number; iat?: number } | null { + try { + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ) + return JSON.parse(jsonPayload) + } catch { + return null + } +} + +/** + * 检查 token 是否即将过期(简化版:剩余时间少于 1 天) + */ +function isTokenExpiringSoon(token: string): boolean { + const decoded = parseJWT(token) + if (!decoded || !decoded.exp) { + return false + } + const exp = decoded.exp * 1000 // 转换为毫秒 + const now = Date.now() + const timeUntilExpiry = exp - now + // 简化版:如果剩余时间少于 1 天,则认为即将过期 + return timeUntilExpiry < 24 * 60 * 60 * 1000 +} + +/** + * 主动刷新 token(在 token 即将过期前) + */ +async function proactiveRefreshToken(): Promise { + if (isRefreshing) { + // 如果正在刷新,等待刷新完成 + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }).then(token => token as string | null).catch(() => null) + } + + // 检查刷新频率,避免过于频繁 + const now = Date.now() + if (now - lastRefreshAttempt < MIN_REFRESH_INTERVAL) { + // 最近刚刷新过,直接返回 null,使用当前 token + return null + } + + isRefreshing = true + lastRefreshAttempt = now + + try { + const refreshResponse = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', // 重要:发送 Cookie(包含 refreshToken) + headers: { + 'Content-Type': 'application/json' + } + }) + + if (!refreshResponse.ok) { + // 如果是 401 或 400,说明 refreshToken 也失效了,需要重新登录 + // 400 通常表示 refresh token 无效、过期或格式错误 + if (refreshResponse.status === 401 || refreshResponse.status === 400) { + throw new Error('REFRESH_TOKEN_INVALID') + } + throw new Error(`Refresh token failed: ${refreshResponse.status}`) + } + + const refreshData = await refreshResponse.json() + const newToken = refreshData?.token + if (newToken) { + localStorage.setItem('token', newToken) + // 同步更新 auth store 中的 token + try { + const authStore = useAuthStore() + authStore.syncToken() + } catch { + // 如果 store 未初始化,忽略 + } + processQueue(null, newToken) + return newToken + } else { + throw new Error('No token in refresh response') + } + } catch (refreshError: any) { + processQueue(refreshError, null) + // 如果是 refreshToken 失效,需要清除并跳转登录 + if (refreshError?.message === 'REFRESH_TOKEN_INVALID') { + localStorage.removeItem('token') + if (!window.location.pathname.startsWith('/login') && !window.location.pathname.startsWith('/register')) { + window.location.href = '/login' + } + return null + } + // 其他错误(如网络错误)不立即跳转登录页,让响应拦截器处理 + // 这样可以避免因为临时网络问题导致用户被退出 + console.warn('Proactive token refresh failed:', refreshError) + return null + } finally { + isRefreshing = false + } +} + +// 请求拦截器 - 添加 token 并检查是否需要刷新 +http.interceptors.request.use( + async (config) => { + let token = localStorage.getItem('token') + if (token) { + // 检查 token 是否即将过期,如果是则先刷新 + if (isTokenExpiringSoon(token)) { + const newToken = await proactiveRefreshToken() + if (newToken) { + token = newToken + } + // 如果刷新失败,继续使用旧 token,让响应拦截器处理 401 + } + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +// 响应拦截器 - 处理错误和自动刷新 token +http.interceptors.response.use( + (response) => response.data, + async (error) => { + const responseData = error.response?.data + const requestUrl = error.config?.url || '' + const originalRequest = error.config + + // 401 未授权,尝试刷新 token + if (error.response?.status === 401) { + // 排除登录、注册、刷新和check-2fa接口本身 + const isAuthEndpoint = requestUrl.startsWith('/auth/login') || + requestUrl.startsWith('/auth/register') || + requestUrl.startsWith('/auth/refresh') || + requestUrl.startsWith('/auth/check-2fa') + + // 如果是认证相关接口的 401,不尝试刷新,直接返回错误 + if (isAuthEndpoint) { + // 创建带有错误码的错误对象 + const apiError = { + message: responseData?.error || 'Unauthorized', + code: responseData?.code || null, + details: responseData?.details || null + } + return Promise.reject(apiError) + } + + // 如果已经重试过,说明刷新也失败了,清除所有认证状态并跳转登录页 + if (originalRequest?._retry) { + const now = Date.now() + // 防止在短时间内重复清除状态和跳转 + if (now - lastAuthFailureTime > AUTH_FAILURE_COOLDOWN) { + lastAuthFailureTime = now + // 清除 auth store 状态 + try { + const authStore = useAuthStore() + authStore.clearLocalAuth() + } catch { + // 如果 store 未初始化,只清除 localStorage + localStorage.removeItem('token') + } + if (!window.location.pathname.startsWith('/login') && !window.location.pathname.startsWith('/register')) { + window.location.href = '/login' + } + } + return Promise.reject(error) + } + + // 如果正在刷新,将请求加入队列 + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }).then(token => { + if (token) { + originalRequest.headers.Authorization = `Bearer ${token}` + } + return http(originalRequest) + }).catch(err => { + return Promise.reject(err) + }) + } + + // 检查刷新频率,避免过于频繁 + const now = Date.now() + if (now - lastRefreshAttempt < MIN_REFRESH_INTERVAL && lastRefreshAttempt > 0) { + // 最近刚刷新过,可能是网络问题,等待一下再重试原请求 + await new Promise(resolve => setTimeout(resolve, 1000)) + if (originalRequest) { + return http(originalRequest) + } + return Promise.reject(error) + } + + // 标记正在刷新,防止重复刷新 + if (originalRequest) { + originalRequest._retry = true + } + isRefreshing = true + lastRefreshAttempt = now + + try { + // 尝试刷新 token(使用 fetch API,避免触发拦截器循环) + const refreshResponse = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', // 重要:发送 Cookie(包含 refreshToken) + headers: { + 'Content-Type': 'application/json' + } + }) + + if (!refreshResponse.ok) { + // 如果是 401 或 400,说明 refreshToken 也失效了,需要重新登录 + // 400 通常表示 refresh token 无效、过期或格式错误 + if (refreshResponse.status === 401 || refreshResponse.status === 400) { + throw new Error('REFRESH_TOKEN_INVALID') + } + throw new Error(`Refresh token failed: ${refreshResponse.status}`) + } + + const refreshData = await refreshResponse.json() + const newToken = refreshData?.token + if (newToken) { + // 更新 localStorage 中的 token + localStorage.setItem('token', newToken) + // 同步更新 auth store 中的 token + try { + const authStore = useAuthStore() + authStore.syncToken() + } catch { + // 如果 store 未初始化,忽略 + } + // 处理队列中的请求 + processQueue(null, newToken) + // 更新请求头并重试原请求 + if (originalRequest) { + originalRequest.headers.Authorization = `Bearer ${newToken}` + return http(originalRequest) + } + return Promise.reject(error) + } else { + throw new Error('No token in refresh response') + } + } catch (refreshError: any) { + // 刷新失败,根据错误类型决定是否跳转登录 + processQueue(refreshError, null) + + // 如果是 refreshToken 失效(401 或 400),清除所有认证状态并跳转到登录页 + // 400 通常表示 refresh token 无效、过期或格式错误 + if (refreshError?.message === 'REFRESH_TOKEN_INVALID' || + (refreshError?.response?.status === 401) || + (refreshError?.response?.status === 400)) { + const now = Date.now() + // 防止在短时间内重复清除状态和跳转 + if (now - lastAuthFailureTime > AUTH_FAILURE_COOLDOWN) { + lastAuthFailureTime = now + // 清除 auth store 状态 + try { + const authStore = useAuthStore() + authStore.clearLocalAuth() + } catch { + // 如果 store 未初始化,只清除 localStorage + localStorage.removeItem('token') + } + if (!window.location.pathname.startsWith('/login') && !window.location.pathname.startsWith('/register')) { + window.location.href = '/login' + } + } + } else { + // 其他错误(如网络错误),不立即跳转,记录日志 + console.warn('Token refresh failed, but not invalidating session:', refreshError) + } + return Promise.reject(refreshError) + } finally { + isRefreshing = false + } + } + + // 创建带有错误码的错误对象,用于前端翻译 + // 保留原始响应数据中的额外字段(如 conflicts, availableCount 等) + const apiError = { + message: responseData?.error || responseData?.message || 'Request failed', + code: responseData?.code || null, + details: responseData?.details || null, + ...responseData // 保留原始响应中的所有字段 + } + + return Promise.reject(apiError) + } +) + +// API 模块 +const api = { + // 认证 + auth: { + check2FA: (username: string): Promise<{ requires2FA: boolean }> => + http.post('/auth/check-2fa', { username }), + login: (username: string, password: string, totpCode?: string, recoveryCode?: string, turnstileToken?: string): Promise => + http.post('/auth/login', { username, password, totpCode, recoveryCode, turnstileToken } as LoginRequest & { totpCode?: string; recoveryCode?: string; turnstileToken?: string }), + register: (data: RegisterRequest & { emailCode?: string }): Promise => + http.post('/auth/register', data), + sendVerificationCode: (email: string, turnstileToken?: string): Promise<{ message: string; expiresAt: string }> => + http.post('/auth/send-verification-code', { email, turnstileToken }), + sendForgotPasswordCode: (email: string, turnstileToken?: string): Promise<{ message: string; expiresAt: string }> => + http.post('/auth/forgot-password/send-code', { email, turnstileToken }), + resetPassword: (email: string, code: string, turnstileToken?: string): Promise<{ message: string; twoFactorDisabled: boolean }> => + http.post('/auth/forgot-password/reset', { email, code, turnstileToken }), + me: (): Promise<{ user: User }> => http.get('/auth/me'), + logout: (): Promise => http.post('/auth/logout'), + generateInvite: (data: GenerateInviteRequest = {}): Promise<{ code: string; codes?: string[]; count?: number; expiresAt?: string; url?: string }> => + http.post('/auth/invite', data), + getInvites: (params: { page?: number; pageSize?: number; status?: 'used' | 'unused' } = {}): Promise => + http.get('/auth/invites', { params }), + deleteInvite: (id: number): Promise => http.delete(`/auth/invites/${id}`), + // 双因素认证 + get2FAStatus: (): Promise<{ enabled: boolean }> => http.get('/auth/2fa/status'), + setup2FA: (): Promise<{ secret: string; qrCode: string; recoveryCodes: string[] }> => + http.post('/auth/2fa/setup'), + enable2FA: (code: string): Promise<{ message: string }> => + http.post('/auth/2fa/enable', { code }), + disable2FA: (password: string, code: string): Promise<{ message: string }> => + http.post('/auth/2fa/disable', { password, code }), + getRecoveryCodes: (): Promise<{ total: number; remaining: number; used: number }> => + http.get('/auth/2fa/recovery-codes'), + regenerateRecoveryCodes: (password: string, code: string): Promise<{ recoveryCodes: string[] }> => + http.post('/auth/2fa/regenerate-recovery-codes', { password, code }), + // 登录历史 + getLoginHistory: (params?: { page?: number; pageSize?: number }): Promise<{ + records: Array<{ + id: number + ip: string + country: string | null + region: string | null + city: string | null + isp: string | null + timezone: string | null + userAgent: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get('/auth/login-history', { params }) + }, + + userInvites: { + summary: (): Promise => http.get('/user-invites/summary'), + list: (params: { page?: number; pageSize?: number } = {}): Promise<{ + invites: UserInvite[] + total: number + page: number + pageSize: number + totalPages: number + }> => http.get('/user-invites', { params }), + generate: (data: { costResource: string; count?: number }): Promise<{ + invites: UserInvite[] + count: number + }> => http.post('/user-invites', data) + }, + + // 用户管理 + users: { + list: (params: Record = {}): Promise> => + http.get('/users', { params }), + get: (id: number): Promise => http.get(`/users/${id}`), + update: (id: number, data: UpdateUserRequest): Promise => + http.patch(`/users/${id}`, data), + sendChangeEmailCode: (id: number, email: string): Promise<{ message: string; expiresAt: string }> => + http.post(`/users/${id}/change-email/send-code`, { email }), + increaseQuota: (quota: { hostLimit?: number; friendLimit?: number }): Promise<{ message: string }> => + http.post('/users/me/quota/increase', quota), + updateRole: (id: number, role: 'admin' | 'user'): Promise<{ message: string; role: 'admin' | 'user'; revokedSessions?: number }> => + http.patch(`/users/${id}/role`, { role }), + updateStatus: (id: number, status: 'active' | 'banned', reason?: string): Promise => + http.patch(`/users/${id}/status`, { status, reason }), + recalculateQuota: (id: number): Promise => + http.post(`/users/${id}/quota/recalculate`), + delete: (id: number): Promise => http.delete(`/users/${id}`), + // 管理员重置用户密码(自动生成) + resetPassword: (id: number): Promise<{ message: string; newPassword: string; username: string }> => + http.post(`/users/${id}/reset-password`), + // 管理员取消用户 2FA + disable2FA: (id: number): Promise<{ message: string }> => + http.post(`/users/${id}/disable-2fa`), + // 管理员解绑用户 OAuth + unbindOAuth: (userId: number, provider: 'github' | 'google'): Promise<{ message: string }> => + http.delete(`/users/${userId}/oauth/${provider}`), + // 管理员获取用户登录记录 + getLoginRecords: (userId: number, params: { page?: number; pageSize?: number } = {}): Promise<{ + records: Array<{ + id: number + ip: string + country: string | null + region: string | null + city: string | null + isp: string | null + timezone: string | null + userAgent: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get(`/users/${userId}/login-records`, { params }), + // 管理员检测关联账号 + detectLinkedAccounts: (days: number = 90): Promise<{ + detectedAt: string + durationMs: number + days: number + summary: { + ipGroups: number + emailGroups: number + usernameGroups: number + } + ipGroups: Array<{ + ip: string + userCount: number + totalLogins: number + users: Array<{ + id: number + username: string + email: string | null + status: string + loginCount: number + lastLogin: string + }> + }> + emailGroups: Array<{ + pattern: string + userCount: number + users: Array<{ + id: number + username: string + email: string + status: string + createdAt: string + }> + }> + usernameGroups: Array<{ + pattern: string + userCount: number + users: Array<{ + id: number + username: string + email: string | null + status: string + createdAt: string + }> + }> + }> => http.get('/users/detect-linked-accounts', { params: { days } }) + }, + + // 终端快捷命令 + terminalSavedCommands: { + list: (): Promise<{ commands: TerminalSavedCommand[] }> => + http.get('/terminal-saved-commands'), + create: (data: CreateTerminalSavedCommandRequest): Promise<{ message: string; command: TerminalSavedCommand }> => + http.post('/terminal-saved-commands', data), + update: (id: number, data: UpdateTerminalSavedCommandRequest): Promise<{ message: string; command: TerminalSavedCommand }> => + http.put(`/terminal-saved-commands/${id}`, data), + delete: (id: number): Promise<{ message: string }> => + http.delete(`/terminal-saved-commands/${id}`) + }, + + // 实例管理 + instances: { + list: (params: Record = {}): Promise & { availableCountries?: string[] }> => + http.get('/instances', { params }), + get: (id: number): Promise => http.get(`/instances/${id}`), + getPassword: (id: number): Promise<{ rootPassword: string | null }> => http.get(`/instances/${id}/password`), + getStats: (id: number): Promise => http.get(`/instances/${id}/stats`), + create: (data: CreateInstanceRequest): Promise => http.post('/instances', data), + getAvailableHosts: (params: Record = {}): Promise => + http.get('/instances/available-hosts', { params }), + getChangeHostOptions: (id: number): Promise => + http.get(`/instances/${id}/change-host-options`), + changeHost: (id: number, data: { targetHostId: number; sshKeyId: number }): Promise<{ message: string; taskId: number; status: string }> => + http.post(`/instances/${id}/change-host`, data), + createTerminalTicket: (id: number): Promise<{ ticket: string; expiresIn: number }> => + http.post(`/ws/instances/${id}/terminal-ticket`, {}), + updateOrder: (id: number, action: 'top' | 'up' | 'down' | 'bottom'): Promise<{ message: string; updated: number }> => + http.patch(`/instances/${id}/order`, { action }), + delete: (id: number, reason?: string): Promise<{ message: string; refundAmount?: number }> => + http.delete(`/instances/${id}`, { data: reason ? { reason } : {}, timeout: TIMEOUT.LONG }), + // 实例操作任务(异步模式) + start: (id: number): Promise<{ message: string; taskId: number; status: string }> => + http.post(`/instances/${id}/start`, {}), + stop: (id: number): Promise<{ message: string; taskId: number; status: string }> => + http.post(`/instances/${id}/stop`, {}), + restart: (id: number): Promise<{ message: string; taskId: number; status: string }> => + http.post(`/instances/${id}/restart`, {}), + // 获取实例的活跃任务 + getActiveTask: (id: number): Promise<{ + task: { + id: number + taskType: string + status: string + progress?: string | null + error?: string | null + queuePosition: number + createdAt: string + startedAt?: string | null + finishedAt?: string | null + } | null + }> => http.get(`/instances/${id}/task`), + // 获取任务详情 + getTaskById: (taskId: number): Promise<{ + task: { + id: number + instanceId: number + instanceName?: string | null + taskType: string + status: string + progress?: string | null + error?: string | null + queuePosition: number + createdAt: string + startedAt?: string | null + finishedAt?: string | null + newInstanceId?: number | null + } + }> => http.get(`/instances/tasks/${taskId}`), + rebuild: (id: number, data: { image: string; sshKeyId?: number; customInitCommandIds?: number[] }): Promise<{ message: string; taskId: number; status: string }> => + http.post(`/instances/${id}/rebuild`, data), + recreate: (id: number, data: { image: string; sshKeyId?: number; customInitCommandIds?: number[] }): Promise<{ message: string; taskId: number; status: string }> => + http.post(`/instances/${id}/recreate`, data), + addPort: (id: number, data: CreatePortMappingRequest): Promise => + http.post(`/instances/${id}/ports`, data), + addPortBatch: (id: number, data: { + protocol: 'tcp' | 'udp' | 'both' + privatePortStart: number + privatePortEnd: number + publicPortStart?: number + publicPortEnd?: number + remark?: string + portMappings?: Array<{ privatePort: number; publicPort: number }> + }): Promise<{ + message: string + mappings: Array<{ id: number; protocol: string; publicPort: number; privatePort: number }> + count: number + } | { + error: string + message: string + conflicts: Array<{ publicPort: number; suggestedPort: number | null }> + availableCount: number + }> => http.post(`/instances/${id}/ports/batch`, data), + deletePort: (id: number, portId: number): Promise => + http.delete(`/instances/${id}/ports/${portId}`), + // 快照 + getSnapshots: (id: number): Promise => http.get(`/instances/${id}/snapshots`), + createSnapshot: (id: number, data: CreateSnapshotRequest): Promise => + http.post(`/instances/${id}/snapshots`, data, { timeout: TIMEOUT.SNAPSHOT }), + deleteSnapshot: (id: number, snapshotId: number): Promise => + http.delete(`/instances/${id}/snapshots/${snapshotId}`, { timeout: TIMEOUT.MEDIUM }), + restoreSnapshot: (id: number, snapshotId: number): Promise => + http.post(`/instances/${id}/snapshots/${snapshotId}/restore`, {}, { timeout: TIMEOUT.SNAPSHOT }), + getSnapshotPolicy: (id: number): Promise => + http.get(`/instances/${id}/snapshot-policy`), + updateSnapshotPolicy: (id: number, data: UpdateSnapshotPolicyRequest): Promise => + http.put(`/instances/${id}/snapshot-policy`, data), + // 备份 + getBackups: (id: number): Promise => http.get(`/instances/${id}/backups`), + createBackup: (id: number, data: CreateBackupRequest): Promise => + http.post(`/instances/${id}/backups`, data, { timeout: TIMEOUT.SNAPSHOT }), + deleteBackup: (id: number, backupId: number): Promise => + http.delete(`/instances/${id}/backups/${backupId}`, { timeout: TIMEOUT.MEDIUM }), + getBackupPolicy: (id: number): Promise => + http.get(`/instances/${id}/backup-policy`), + updateBackupPolicy: (id: number, data: UpdateBackupPolicyRequest): Promise => + http.put(`/instances/${id}/backup-policy`, data), + // 备份导出 + exportBackup: (id: number, backupId: number): Promise<{ taskId: string; status: string; expiresAt: string; downloadUrl: string }> => + http.post(`/instances/${id}/backups/${backupId}/export`, {}), + getExportStatus: (id: number, taskId: string): Promise<{ taskId: string; status: string; error?: string; expiresAt: string }> => + http.get(`/instances/${id}/backups/export/${taskId}/status`), + // 获取一次性下载 token(安全改进:使用短期 token 替代 JWT URL 参数) + getDownloadToken: (id: number, taskId: string): Promise<{ downloadUrl: string; expiresIn: number }> => + http.post(`/instances/${id}/backups/export/${taskId}/download-token`, {}), + // 已废弃:直接获取下载 URL(不安全,保留用于向后兼容) + // @deprecated 使用 getDownloadToken 替代 + getExportDownloadUrl: (id: number, taskId: string): string => + `/api/instances/${id}/backups/export/${taskId}/download`, + // 备份恢复 + restoreBackup: (id: number, backupId: number): Promise<{ taskId: string; status: string; message: string }> => + http.post(`/instances/${id}/restore/${backupId}`, {}, { timeout: TIMEOUT.SNAPSHOT }), + getRestoreStatus: (id: number, taskId: string): Promise<{ + taskId: string + status: string + error?: string + queuePosition: number + duration: number | null + createdAt: string + startedAt: string | null + finishedAt: string | null + }> => + http.get(`/instances/${id}/restore/${taskId}`), + rollbackRestore: (id: number, taskId: string): Promise<{ success: boolean; message: string }> => + http.post(`/instances/${id}/restore/${taskId}/rollback`, {}, { timeout: TIMEOUT.SNAPSHOT }), + // 备份上传到远程存储 + uploadBackupRemote: (id: number, backupId: number, storageConfigId?: number): Promise<{ + taskId: number + status: string + queuePosition: number + storageName: string + message: string + }> => http.post(`/instances/${id}/backups/${backupId}/upload-remote`, { storageConfigId }), + getUploadTaskStatus: (id: number, taskId: number): Promise<{ + taskId: number + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' + error?: string + remoteFileName?: string + fileSize?: string + storageName?: string + storageType?: string + queuePosition: number + duration: number | null + createdAt: string + startedAt: string | null + finishedAt: string | null + }> => http.get(`/instances/${id}/upload-tasks/${taskId}`), + cancelUploadTask: (id: number, taskId: number): Promise<{ success: boolean; message: string }> => + http.delete(`/instances/${id}/upload-tasks/${taskId}`), + getActiveUploadTask: (id: number): Promise<{ + task: { + taskId: number + backupId: number + status: 'PENDING' | 'PROCESSING' + storageName?: string + storageType?: string + queuePosition: number + duration: number | null + createdAt: string + startedAt: string | null + finishedAt: string | null + } | null + }> => http.get(`/instances/${id}/upload-tasks/active`), + // 配额 + updateQuota: (id: number, data: UpdateInstanceRequest): Promise => + http.patch(`/instances/${id}/quota`, data), + // 重命名 + rename: (id: number, name: string): Promise<{ message: string; name: string }> => + http.patch(`/instances/${id}/rename`, { name }), + // 更新配置(CPU、内存、磁盘) + updateConfig: (id: number, data: { cpu?: number; memory?: number; disk?: number; monthlyTrafficLimit?: string | null }): Promise<{ + message: string + instance: { id: number; cpu: number; memory: number; disk: number } + }> => http.patch(`/instances/${id}/config`, data), + // 复制实例 + clone: (id: number): Promise<{ message: string; taskId: number; status: string }> => + http.post(`/instances/${id}/clone`, {}), + // 实例配置 + getConfig: (id: number): Promise<{ + config: { + limits_read: string + limits_write: string + limits_read_iops: number + limits_write_iops: number + limits_ingress: string + limits_egress: string + limits_processes: number + limits_cpu_priority: number + boot_autostart: boolean + boot_autostart_priority: number + boot_autostart_delay: number + boot_host_shutdown_timeout: number + } + overrides: Record + packageDefaults: { + limits_read: string + limits_write: string + limits_read_iops: number + limits_write_iops: number + limits_ingress: string + limits_egress: string + limits_processes: number + limits_cpu_priority: number + boot_autostart: boolean + boot_autostart_priority: number + boot_autostart_delay: number + boot_host_shutdown_timeout: number + } + ioLimitMode: 'throughput' | 'iops' + swap: { + available: boolean + enabled: boolean + sizeMb: number + kind: 'container' | 'vm' + requiresRunning: boolean + } + }> => http.get(`/instances/${id}/config`), + enableSwap: (id: number): Promise<{ message: string; swapEnabled: boolean; swapSize: number }> => + http.post(`/instances/${id}/swap/enable`, {}), + disableSwap: (id: number): Promise<{ message: string; swapEnabled: boolean; swapSize: number }> => + http.post(`/instances/${id}/swap/disable`, {}), + updateInstanceConfig: (id: number, data: { + limitsRead?: string | null + limitsWrite?: string | null + limitsReadIops?: number | null + limitsWriteIops?: number | null + limitsIngress?: string | null + limitsEgress?: string | null + limitsProcesses?: number | null + limitsCpuPriority?: number | null + bootAutostart?: boolean | null + bootAutostartPriority?: number | null + bootAutostartDelay?: number | null + bootHostShutdownTimeout?: number | null + }): Promise<{ message: string }> => http.patch(`/instances/${id}/advanced-config`, data), + // 提升进程数限制 + boostProcesses: (id: number): Promise<{ message: string; newLimit: number }> => + http.post(`/instances/${id}/boost-processes`, {}), + // IP 地址管理 + getIpAddresses: (id: number): Promise<{ ipAddresses: IpAddress[] }> => + http.get(`/instances/${id}/ips`), + addIpAddress: (id: number, customAddress?: string): Promise<{ success: boolean; ipAddress: IpAddress }> => + http.post(`/instances/${id}/ips`, customAddress ? { customAddress } : {}, { timeout: TIMEOUT.MEDIUM }), + deleteIpAddress: (id: number, ipId: number): Promise<{ success: boolean }> => + http.delete(`/instances/${id}/ips/${ipId}`, { timeout: TIMEOUT.MEDIUM }), + // IPv6 地址管理 (新双网卡架构) + setCustomIpv6: (id: number, ipId: number, address: string): Promise<{ success: boolean; ipAddress: IpAddress }> => + http.put(`/instances/${id}/ips/${ipId}/custom`, { address }, { timeout: TIMEOUT.MEDIUM }), + // IPv6 网段管理 + getIpv6Subnets: (id: number): Promise<{ subnets: Ipv6Subnet[] }> => + http.get(`/instances/${id}/subnets`), + allocateIpv6Subnet: (id: number, prefix: 112 | 120 | 124): Promise<{ success: boolean; subnet: Ipv6Subnet }> => + http.post(`/instances/${id}/subnet`, { prefix }, { timeout: TIMEOUT.MEDIUM }), + deleteIpv6Subnet: (id: number, subnetId: number): Promise<{ success: boolean }> => + http.delete(`/instances/${id}/subnet/${subnetId}`, { timeout: TIMEOUT.MEDIUM }), + // 站点管理 + getSites: (id: number): Promise<{ + sites: Array<{ + id: number + domain: string + targetPort: number + httpsEnabled: boolean + remark: string | null + status: 'pending' | 'active' | 'error' + enabled: boolean + error: string | null + createdAt: string + }> + caddyEnabled: boolean + dnsRecordType: 'A' | 'AAAA' | 'CNAME' | null + dnsRecordValue: string | null + siteQuota: { + used: number + limit: number // 0 = 不限制 + } + canManageSites?: boolean + }> => http.get(`/instances/${id}/sites`), + addSite: (id: number, data: { domain: string; targetPort: number; httpsEnabled?: boolean; remark?: string }): Promise<{ + success: boolean + site: { id: number; domain: string; targetPort: number; httpsEnabled: boolean; status: string } + dnsHint: { + type: 'A' | 'AAAA' | 'CNAME' + host: string + value: string + } + }> => http.post(`/instances/${id}/sites`, data), + deleteSite: (id: number, siteId: number): Promise<{ success: boolean }> => + http.delete(`/instances/${id}/sites/${siteId}`), + updateSite: (id: number, siteId: number, data: { targetPort?: number; httpsEnabled?: boolean; remark?: string }): Promise<{ + message: string + site: { id: number; domain: string; targetPort: number; httpsEnabled: boolean; remark: string | null; status: string } + }> => http.patch(`/instances/${id}/sites/${siteId}`, data), + refreshSite: (id: number, siteId: number): Promise<{ success: boolean; status: string }> => + http.post(`/instances/${id}/sites/${siteId}/refresh`), + checkDns: (id: number, siteId: number): Promise<{ + dnsResolved: boolean + ipMatches?: boolean + expectedIp: string + resolvedIps: string[] + activated?: boolean + status: string + error?: string + message?: string + }> => http.post(`/instances/${id}/sites/${siteId}/check-dns`), + toggleSite: (id: number, siteId: number): Promise<{ message: string; enabled: boolean; status: string }> => + http.post(`/instances/${id}/sites/${siteId}/toggle`), + getCertificateStatus: (id: number, siteId: number): Promise<{ + httpsEnabled: boolean + status: 'disabled' | 'valid' | 'pending' | 'dns_error' | 'connection_refused' | 'timeout' | 'cert_pending' | 'error' + message?: string + error?: string + hint?: string + certificate?: { + valid: boolean + issuer: string + subject: string + validFrom: string + validTo: string + daysRemaining: number + } + }> => http.get(`/instances/${id}/sites/${siteId}/certificate`), + // Cloud-init 状态检查 + checkCloudInitStatus: (id: number): Promise => + http.get(`/instances/${id}/cloud-init-status`), + manualCompleteCloudInit: (id: number): Promise => + http.post(`/instances/${id}/cloud-init-status/manual-complete`, {}), + // 封停/解封实例(仅宿主机所有者和管理员) + suspend: (id: number, reason?: string): Promise<{ message: string }> => + http.post(`/instances/${id}/suspend`, { reason }), + unsuspend: (id: number): Promise<{ message: string }> => + http.post(`/instances/${id}/unsuspend`, {}), + // 同步实例状态(从 Incus 获取实际状态并更新数据库) + syncStatus: (id: number): Promise<{ + success: boolean + statusChanged: boolean + from?: string + to?: string + currentStatus: string + ipv4Changed?: boolean + oldIpv4?: string + newIpv4?: string + proxySitesUpdated?: number + }> => http.post(`/instances/${id}/sync-status`, {}), + // 重新分配 IPv6 地址(仅 nat_ipv6 模式) + reassignIpv6: (id: number): Promise<{ message: string; oldIpv6: string | null; newIpv6: string }> => + http.post(`/instances/${id}/reassign-ipv6`, {}) + }, + + // SSH 密钥 + sshKeys: { + list: (): Promise<{ keys: SshKey[] }> => http.get('/ssh-keys'), + create: (data: CreateSshKeyRequest): Promise<{ message: string; key: SshKey }> => http.post('/ssh-keys', data), + delete: (id: number): Promise<{ message: string }> => http.delete(`/ssh-keys/${id}`), + generate: (): Promise<{ message: string; privateKey: string; key: { id: number; name: string; fingerprint: string; publicKeyPreview: string } }> => + http.post('/ssh-keys/generate') + }, + + // 通知管理 + notifications: { + list: (): Promise<{ channels: NotificationChannel[] }> => http.get('/notifications'), + get: (id: number): Promise => http.get(`/notifications/${id}`), + create: (data: CreateNotificationChannelRequest): Promise => + http.post('/notifications', data), + update: (id: number, data: UpdateNotificationChannelRequest): Promise => + http.patch(`/notifications/${id}`, data), + delete: (id: number): Promise => http.delete(`/notifications/${id}`), + test: (id: number): Promise => http.post(`/notifications/${id}/test`), + toggle: (id: number): Promise => http.post(`/notifications/${id}/toggle`), + // 通知历史 + getLogs: (params: { page?: number; pageSize?: number; status?: 'pending' | 'sent' | 'failed' } = {}): Promise<{ + logs: Array<{ + id: number + channelId: number + channelName: string + channelType: string + eventType: string + message: string + status: string + error: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/notifications/logs', { params }), + getStats: (): Promise<{ + stats: { + total: number + sent: number + failed: number + pending: number + } + }> => http.get('/notifications/stats'), + // 获取管理员创建的全局通知渠道(供托管用户绑定套餐使用) + getGlobalChannels: (): Promise<{ + channels: Array<{ id: number; name: string; type: string; configPreview: string }> + }> => http.get('/notifications/global-channels') + }, + + // Telegram 专用机器人绑定 + telegram: { + getBinding: (): Promise => http.get('/telegram/binding'), + createBindToken: (): Promise => http.post('/telegram/bind-token'), + unlink: (): Promise<{ message: string }> => http.delete('/telegram/binding'), + getWebhookInfo: (): Promise => http.get('/telegram/admin/webhook/info'), + setupWebhook: (data: { baseUrl?: string } = {}): Promise => + http.post('/telegram/admin/webhook/setup', data), + deleteWebhook: (): Promise => + http.post('/telegram/admin/webhook/delete', {}), + listBindings: (params?: { page?: number; pageSize?: number; search?: string }): Promise => + http.get('/telegram/admin/bindings', { params }), + unlinkAdminBinding: (id: number): Promise<{ message: string }> => + http.delete(`/telegram/admin/bindings/${id}`) + }, + + // 管理员全局通知渠道管理 + adminNotificationChannels: { + list: (): Promise<{ + channels: Array<{ id: number; name: string; type: string; enabled: boolean; boundPackages: number; configPreview: string; createdAt: string }> + }> => http.get('/admin/notification-channels'), + get: (id: number): Promise<{ + channel: { id: number; name: string; type: string; enabled: boolean; config: Record; createdAt: string } + }> => http.get(`/admin/notification-channels/${id}`), + create: (data: { name: string; botToken: string; chatId: string; enabled?: boolean }): Promise<{ message: string; channel: { id: number; name: string } }> => + http.post('/admin/notification-channels', data), + update: (id: number, data: { name?: string; botToken?: string; chatId?: string; enabled?: boolean }): Promise<{ message: string }> => + http.patch(`/admin/notification-channels/${id}`, data), + delete: (id: number): Promise<{ message: string }> => + http.delete(`/admin/notification-channels/${id}`), + test: (id: number): Promise<{ message: string }> => + http.post(`/admin/notification-channels/${id}/test`) + }, + + // 主机管理 + hosts: { + list: (params: Record = {}): Promise<{ hosts: HostWithDetails[]; total: number; page: number; pageSize: number; totalPages: number }> => + http.get('/hosts', { params }), + // 管理员专用:获取托管节点列表 + listHosted: (params: { userId?: number; page?: number; pageSize?: number; search?: string } = {}): Promise<{ hosts: HostWithDetails[]; total: number; page: number; pageSize: number; totalPages: number }> => + http.get('/hosts', { params: { ...params, scope: 'hosted' } }), + get: (id: number): Promise => http.get(`/hosts/${id}`), + lookupGiftTargetUser: (hostId: number, username: string): Promise<{ + user: { + id: number + username: string + status: string + hasSshKey: boolean + } + }> => http.get(`/hosts/${hostId}/users/lookup`, { params: { username } }), + getImagePolicy: (id: number): Promise => http.get(`/hosts/${id}/images`), + updateImagePolicy: (id: number, data: { useDefault: boolean; imageIds?: number[] }): Promise => + http.put(`/hosts/${id}/images`, data), + create: (data: CreateHostRequest): Promise => http.post('/hosts', data), + update: (id: number, data: UpdateHostRequest): Promise => + http.patch(`/hosts/${id}`, data), + delete: (id: number): Promise => http.delete(`/hosts/${id}`), + test: (id: number): Promise<{ success: boolean; message?: string }> => + http.post(`/hosts/${id}/test`, {}, { timeout: TIMEOUT.MEDIUM }), + takeoverOfficial: (id: number): Promise<{ + success: boolean + summary: { + hostId: number + previousOwnerId: number + previousOwnerUsername: string + instanceCount: number + transferredPackageCount: number + detachedPackageCount: number + transferredPackageNames: string[] + detachedPackageNames: string[] + hostRenamed: boolean + oldHostName: string + newHostName: string + } + }> => http.post(`/hosts/${id}/takeover-official`, {}), + verify: (id: number): Promise<{ success: boolean; message?: string; resources?: { cpuTotal: number; memoryTotalGB: number; diskTotalGB: number } }> => + http.post(`/hosts/${id}/verify`, {}, { timeout: TIMEOUT.MEDIUM }), + regenerateInstall: (id: number): Promise<{ success: boolean; installCommand: string; installToken?: string; message: string }> => + http.post(`/hosts/${id}/regenerate-install`, {}), + getAgentStatus: (id: number): Promise => + http.get(`/agent/hosts/${id}/status`), + generateAgentInstallCommand: (id: number): Promise => + http.post(`/agent/hosts/${id}/install-command`, {}), + requestAgentUpgrade: (id: number): Promise => + http.post(`/agent/hosts/${id}/upgrade`, {}), + sync: (id: number): Promise => http.post(`/hosts/${id}/sync`, {}, { timeout: TIMEOUT.LONG }), + setMaintenance: (id: number, enabled: boolean): Promise => + http.post(`/hosts/${id}/maintenance`, { enabled }), + // 存储池管理 + getStoragePools: (id: number): Promise<{ pools: Array<{ name: string; driver: string; description: string; status: string; purpose?: 'instance_data' | 'instance_storage' | null; config: Record; usedBy: number; space?: { used: number; total: number } | null }> }> => + http.get(`/hosts/${id}/storage-pools`), + createStoragePool: (id: number, data: { + name: string + driver?: 'zfs' | 'lvm' | 'btrfs' | 'dir' // 使用已有池时可不传 + source?: string + size?: string + zfsPoolName?: string + lvmVgName?: string + lvmUseThinpool?: boolean + description?: string + purpose?: 'instance_data' | 'instance_storage' + useExisting?: boolean // 使用/导入已有存储池模式 + existingSource?: string // 底层已存在的存储源名称(如 ZFS 池名、LVM VG 名) + }): Promise<{ success: boolean; message: string; imported?: boolean }> => + http.post(`/hosts/${id}/storage-pools`, data), + deleteStoragePool: (id: number, poolName: string): Promise<{ success: boolean; message: string }> => + http.delete(`/hosts/${id}/storage-pools/${poolName}`), + updateStoragePool: (id: number, poolName: string, data: { + size?: string + description?: string + purpose?: 'instance_data' | 'instance_storage' + }): Promise<{ success: boolean; message: string }> => + http.patch(`/hosts/${id}/storage-pools/${poolName}`, data), + // Caddy 管理 + getCaddy: (id: number): Promise<{ + enabled: boolean + username: string | null + port: number + hasPassword: boolean + natPublicIp: string | null + sitesCount: number + }> => http.get(`/hosts/${id}/caddy`), + generateCaddyCommand: (id: number): Promise<{ + installCommand: string + username: string + password: string + port: number + isNewCredentials: boolean + }> => http.post(`/hosts/${id}/caddy/generate`), + resetCaddyCredentials: (id: number): Promise<{ + installCommand: string + username: string + password: string + port: number + }> => http.post(`/hosts/${id}/caddy/reset`), + confirmCaddyInstalled: (id: number): Promise<{ message: string }> => + http.post(`/hosts/${id}/caddy/confirm`), + testCaddyConnection: (id: number): Promise<{ + connected: boolean + sitesCount: number + dnsRecordType: 'A' | 'AAAA' | 'CNAME' + dnsRecordValue: string + }> => http.post(`/hosts/${id}/caddy/test`, {}, { timeout: TIMEOUT.MEDIUM }), + getCaddySites: (id: number, params: { page?: number; pageSize?: number } = {}): Promise<{ + sites: Array<{ + id: number + domain: string + targetPort: number + httpsEnabled: boolean + status: 'pending' | 'active' | 'error' + enabled: boolean + error: string | null + createdAt: string + instance: { + id: number + name: string + ipv4: string | null + status: string + } | null + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get(`/hosts/${id}/caddy/sites`, { params }), + // 批量删除实例预览(计算退款信息) + batchDeleteInstancesPreview: (hostId: number, instanceIds: number[]): Promise<{ + instances: Array<{ + id: number + name: string + username: string + userId: number + isOwnInstance: boolean + isPaid: boolean + remainingDays: number + refundAmount: number + }> + totalRefundAmount: number + }> => http.post(`/hosts/${hostId}/instances/batch-delete-preview`, { instanceIds }), + // 批量删除实例 + batchDeleteInstances: (hostId: number, instanceIds: number[], reason?: string, databaseOnly?: boolean): Promise<{ + message: string + results: Array<{ id: number; name: string; success: boolean; error?: string; refundAmount?: number }> + successCount: number + failedCount: number + totalRefundAmount: number + }> => http.delete(`/hosts/${hostId}/instances/batch`, { data: { instanceIds, reason, databaseOnly }, timeout: TIMEOUT.BATCH }), + // 批量赠送时长 + giftDays: (hostId: number, instanceIds: number[], days: number): Promise<{ + message: string + results: Array<{ instanceId: number; instanceName: string; success: boolean; error?: string; newExpiresAt?: string }> + successCount: number + failedCount: number + skippedCount: number + }> => http.post(`/hosts/${hostId}/instances/gift-days`, { instanceIds, days }), + createInstanceForUser: (hostId: number, data: { + username: string + name: string + packageId: number + image: string + cpu?: number + memory?: number + disk?: number + planId?: number + giftDays?: number + }): Promise<{ + message: string + instance: { + id: number + name: string + incusId: string + host: string + status: string + user: { id: number; username: string } + isPaid: boolean + planName: string | null + charged: boolean + amount: number + expiresAt: string | null + giftDays?: number | null + } + }> => http.post(`/hosts/${hostId}/instances/create-for-user`, data), + // 修改付费实例的续费价格(托管节点所有者专用) + updateInstanceRenewalPrice: (hostId: number, instanceId: number, newPrice: number): Promise<{ + message: string + instanceId: number + instanceName: string + oldPrice: number + newPrice: number + }> => http.patch(`/hosts/${hostId}/instances/${instanceId}/renewal-price`, { newPrice }), + // 批量同步实例状态 + batchSyncInstanceStatus: (hostId: number, instanceIds: number[]): Promise<{ + message: string + results: Array<{ id: number; name: string; success: boolean; from?: string; to?: string; ipv4Changed?: boolean; oldIpv4?: string; newIpv4?: string; proxySitesUpdated?: number; error?: string }> + syncedCount: number + changedCount: number + ipv4ChangedCount: number + failedCount: number + }> => http.post(`/hosts/${hostId}/instances/sync-status`, { instanceIds }, { timeout: TIMEOUT.MEDIUM }), + // 资源校对:重新计算宿主机资源使用量,并将配额对齐到已用配额 + recalculateResources: (hostId: number): Promise<{ + message: string + hasChanges: boolean + before: { cpuUsed: number; memoryUsed: number; diskUsed: number; natPortsUsedCount: number; cpuAllowanceMax: number; memoryMax: number } + after: { cpuUsed: number; memoryUsed: number; diskUsed: number; natPortsUsedCount: number; cpuAllowanceMax: number; memoryMax: number } + diff: { cpuUsed: number; memoryUsed: number; diskUsed: number; natPortsUsedCount: number; cpuAllowanceMax: number; memoryMax: number } + }> => http.post(`/hosts/${hostId}/recalculate-resources`), + // 批量封停实例 + batchSuspendInstances: (hostId: number, instanceIds: number[], reason?: string): Promise<{ + message: string + results: Array<{ id: number; name: string; success: boolean; error?: string }> + successCount: number + failedCount: number + }> => http.post(`/hosts/${hostId}/instances/suspend`, { instanceIds, reason }, { timeout: TIMEOUT.BATCH }), + // 批量解封实例 + batchUnsuspendInstances: (hostId: number, instanceIds: number[]): Promise<{ + message: string + results: Array<{ id: number; name: string; success: boolean; error?: string }> + successCount: number + failedCount: number + }> => http.post(`/hosts/${hostId}/instances/unsuspend`, { instanceIds }, { timeout: TIMEOUT.BATCH }), + // 批量为节点下所有付费实例免费延期(仅管理员) + batchExtendAll: (hostId: number, days: number): Promise<{ + success: boolean + message: string + extendedCount: number + }> => http.post(`/hosts/${hostId}/extend-all`, { days }), + // 批量迁移实例到其他节点(仅管理员) + migrateInstances: (hostId: number, instanceIds: number[], targetHostId: number, targetImage: string, targetPlanId?: number): Promise<{ + message: string + results: Array<{ id: number; name: string; success: boolean; error?: string; newInstanceId?: number }> + successCount: number + failedCount: number + }> => http.post(`/hosts/${hostId}/instances/migrate`, { instanceIds, targetHostId, targetImage, targetPlanId }, { timeout: TIMEOUT.BATCH }), + // 获取节点绑定的套餐方案(用于改节点) + getHostPlans: (hostId: number): Promise<{ + plans: Array<{ + id: number + name: string + packageId: number + packageName: string + price: number + billingCycle: number + }> + }> => http.get(`/hosts/${hostId}/plans`), + // 批量修改实例配置 + batchUpdateConfig: (hostId: number, data: { + instanceIds: number[] + config: Record + }): Promise<{ + success: boolean + totalCount: number + successCount: number + failedCount: number + failedItems: Array<{ + instanceId: number + incusId: string + name: string + error?: string + }> + retryPayload?: { + instanceIds: number[] + config: Record + } + }> => http.post(`/hosts/${hostId}/instances/batch-config`, data, { timeout: TIMEOUT.BATCH }), + // 兑换码管理 + getRedeemCodes: (hostId: number, params: { limit?: number; offset?: number; enabled?: boolean } = {}): Promise<{ + codes: Array<{ + id: number + code: string + codeType: 'c' | 'r' | 'd' | 't' + codeValue: number + maxUses: number + usedCount: number + expiresAt: string | null + enabled: boolean + remark: string | null + batchId: string | null + createdAt: string + }> + total: number + }> => http.get(`/hosts/${hostId}/redeem-codes`, { params }), + createRedeemCode: (hostId: number, data: { + codeType: 'c' | 'r' | 'd' | 't' + codeValue: number + maxUses?: number + expiresAt?: string | null + remark?: string + batchCount?: number + }): Promise<{ message: string; code?: string; id?: number; codes?: string[]; batchId?: string; count?: number }> => + http.post(`/hosts/${hostId}/redeem-codes`, data), + updateRedeemCode: (hostId: number, codeId: number, data: { + enabled?: boolean + remark?: string + maxUses?: number + expiresAt?: string | null + }): Promise<{ message: string }> => + http.patch(`/hosts/${hostId}/redeem-codes/${codeId}`, data), + deleteRedeemCode: (hostId: number, codeId: number): Promise<{ message: string }> => + http.delete(`/hosts/${hostId}/redeem-codes/${codeId}`), + batchDeleteRedeemCodes: (hostId: number, ids: number[]): Promise<{ message: string; count: number }> => + http.post(`/hosts/${hostId}/redeem-codes/batch-delete`, { ids }), + getRedeemCodeUsages: (hostId: number, codeId: number, params: { limit?: number; offset?: number } = {}): Promise<{ + usages: Array<{ + id: number + user: { id: number; username: string } + instance: { id: number; name: string } + usedAt: string + }> + total: number + }> => http.get(`/hosts/${hostId}/redeem-codes/${codeId}/usages`, { params }), + getRedeemCodeOptions: (): Promise<{ + types: Array<{ value: string; label: string; unit: string }> + ranges: Record + }> => http.get('/redeem-code-options'), + opsDiscover: (hostId: number): Promise<{ + managed: Array<{ incusName: string; incusType: string; incusStatus: string; dbId: number; dbStatus: string; userId: number }> + orphaned: Array<{ incusName: string; incusType: string; incusStatus: string }> + missing: Array<{ dbId: number; dbName: string; incusId: string; dbStatus: string }> + summary: { + totalIncus: number + totalDb: number + managedCount: number + orphanedCount: number + missingCount: number + } + }> => http.post(`/hosts/${hostId}/ops/discover`), + opsBaselineSync: (hostId: number): Promise<{ + message: string + resources: { + cpuUsed: number + memoryUsed: number + diskUsed: number + } + instanceSync: { + total: number + synced: number + ipChanged: number + } + }> => http.post(`/hosts/${hostId}/ops/baseline-sync`), + opsNetworkRepair: (hostId: number): Promise<{ + message: string + results: Array<{ + id: number + name: string + success: boolean + statusChanged?: boolean + oldStatus?: string + newStatus?: string + ipv4Changed?: boolean + oldIpv4?: string | null + newIpv4?: string | null + ipv6Changed?: boolean + oldIpv6?: string | null + newIpv6?: string | null + error?: string + }> + summary: { + total: number + success: number + failed: number + changed: number + } + }> => http.post(`/hosts/${hostId}/ops/network-repair`), + opsInstanceSshKeys: (hostId: number, instanceId: number): Promise<{ + keys: SshKey[] + }> => http.get(`/hosts/${hostId}/ops/instances/${instanceId}/ssh-keys`), + opsInstanceInitCommands: (hostId: number, instanceId: number, distro: string): Promise<{ + commands: Array<{ + id: number + name: string + commandLineCount: number + distros: string[] + description: string | null + }> + }> => http.get(`/hosts/${hostId}/ops/instances/${instanceId}/init-commands`, { params: { distro } }), + opsInstancePreview: (hostId: number, instanceId: number): Promise<{ + instanceId: number + instanceName: string + incusId: string + instanceStatus: string + hostName: string + hostId: number + imageAlias?: string | null + canSync: boolean + canRestart: boolean + canForceRestart: boolean + canRebuild: boolean + canRecreate: boolean + activeTask?: { + id: number + taskType: string + status: string + } | null + risk: { + status: string + isStopped: boolean + hasActiveTask: boolean + suggestedAction: 'rebuild' | 'recreate' | 'sync' | 'restart' | 'none' + notes: string[] + } + }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/preview`), + opsInstanceSync: (hostId: number, instanceId: number): Promise<{ + success: boolean + message: string + statusChanged?: boolean + from?: string + to?: string + currentStatus?: string + ipv4Changed?: boolean + oldIpv4?: string | null + newIpv4?: string | null + ipv6Changed?: boolean + oldIpv6?: string | null + newIpv6?: string | null + }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/sync`), + opsInstanceRestart: (hostId: number, instanceId: number, force: boolean): Promise<{ + success: boolean + message: string + }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/restart`, { force }), + opsInstanceDangerousAction: (hostId: number, instanceId: number, data: { + action: 'rebuild' | 'recreate' + imageAlias: string + sshKeyId?: number + customInitCommandIds?: number[] + confirmationText: string + riskConfirmed: boolean + }): Promise<{ + success: boolean + message: string + taskId: number + status: string + }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/dangerous-action`, data), + opsInstanceAuditScan: (hostId: number, instanceId: number): Promise<{ + success: boolean + scanId: number + scannedAt: string + capability: string + instance: { + id: number + name: string + incusId: string + type: string + status: string + } + summary: { + riskLevel: 'info' | 'low' | 'medium' | 'high' + processCount: number + connectionCount: number + listeningCount: number + startupItemCount: number + findingCount: number + } + ignoredCount?: number + rules?: Array<{ + id: string + source: 'builtin' | 'custom' + name: string + category: string + severity: 'info' | 'low' | 'medium' | 'high' + targetTypes: Array<'process' | 'network' | 'startup'> + matchType: 'contains' | 'regex' | 'exact' + pattern: string + caseSensitive: boolean + recommendation?: string | null + enabled: boolean + }> + findings: Array<{ + id: string + severity: 'info' | 'low' | 'medium' | 'high' + category: string + title: string + detail: string + targetType: 'process' | 'network' | 'startup' | 'capability' + ruleId?: string + ruleName?: string + ruleSource?: 'builtin' | 'custom' + matchedText?: string + recommendation?: string | null + pid?: number + evidence: string + ignored?: boolean + ignoreReason?: string | null + }> + processes: Array<{ + pid: number + ppid: number | null + user: string + stat: string + cpuPercent: number | null + memoryPercent: number | null + elapsed: string + command: string + args: string + raw: string + findings: string[] + }> + connections: Array<{ + protocol: string + state: string + local: string + peer: string + process: string | null + pid: number | null + raw: string + }> + startupItems: Array<{ + source: string + command: string + raw: string + findings: string[] + }> + stderr?: string[] + }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/audit/scan`), + opsInstanceAuditKillProcess: (hostId: number, instanceId: number, data: { + pid: number + signal?: 'TERM' | 'KILL' + reason: string + confirmationText: string + scanId?: number + expectedCommand?: string + }): Promise<{ + success: boolean + message: string + pid: number + signal: 'TERM' | 'KILL' + stdout?: string + stderr?: string + }> => http.post(`/hosts/${hostId}/ops/instances/${instanceId}/audit/kill-process`, data), + opsAuditRules: (hostId: number): Promise<{ + builtin: Array + custom: Array + canCreateGlobal: boolean + }> => http.get(`/hosts/${hostId}/ops/audit/rules`), + opsAuditCreateRule: (hostId: number, data: Record): Promise => + http.post(`/hosts/${hostId}/ops/audit/rules`, data), + opsAuditUpdateRule: (hostId: number, ruleId: number, data: Record): Promise => + http.patch(`/hosts/${hostId}/ops/audit/rules/${ruleId}`, data), + opsAuditDeleteRule: (hostId: number, ruleId: number): Promise<{ success: boolean }> => + http.delete(`/hosts/${hostId}/ops/audit/rules/${ruleId}`), + opsAuditUpdateBuiltinRule: (hostId: number, ruleId: string, data: Record): Promise => + http.patch(`/hosts/${hostId}/ops/audit/builtin-rules/${encodeURIComponent(ruleId)}`, data), + opsAuditResetBuiltinRule: (hostId: number, ruleId: string): Promise<{ success: boolean }> => + http.delete(`/hosts/${hostId}/ops/audit/builtin-rules/${encodeURIComponent(ruleId)}`), + opsAuditIgnores: (hostId: number, params?: { instanceId?: number }): Promise<{ ignores: Array }> => + http.get(`/hosts/${hostId}/ops/audit/ignores`, { params }), + opsAuditCreateIgnore: (hostId: number, data: Record): Promise => + http.post(`/hosts/${hostId}/ops/audit/ignores`, data), + opsAuditDeleteIgnore: (hostId: number, ignoreId: number): Promise<{ success: boolean }> => + http.delete(`/hosts/${hostId}/ops/audit/ignores/${ignoreId}`), + opsAuditHistory: (hostId: number, params?: { instanceId?: number; pageSize?: number }): Promise<{ + scans: Array + actions: Array + }> => http.get(`/hosts/${hostId}/ops/audit/history`, { params }) + }, + + // 套餐管理 + packages: { + // 公开 API(无需登录) + listPublic: (options?: { source?: 'official' | 'market' }): Promise<{ packages: Package[]; total: number }> => { + const params = new URLSearchParams() + if (options?.source) params.append('source', options.source) + const queryString = params.toString() + return http.get(queryString ? `/packages/public?${queryString}` : '/packages/public') + }, + getPublicRegions: (options?: { source?: 'official' | 'market' }): Promise<{ + regions: Array<{ + code: string + name: string + packageIds: number[] + hostCount: number + }> + }> => { + const params = new URLSearchParams() + if (options?.source) params.append('source', options.source) + const queryString = params.toString() + return http.get(queryString ? `/packages/public/regions?${queryString}` : '/packages/public/regions') + }, + // 需要登录的 API + list: (options?: { all?: boolean; source?: 'official' | 'market' | 'friends' | 'zone'; zoneId?: number; scope?: 'mine' | 'official' | 'hosted' }): Promise<{ packages: Package[]; total: number }> => { + const params = new URLSearchParams() + if (options?.all) params.append('all', 'true') + if (options?.source) params.append('source', options.source) + if (options?.zoneId) params.append('zoneId', String(options.zoneId)) + if (options?.scope) params.append('scope', options.scope) + const queryString = params.toString() + return http.get(queryString ? `/packages?${queryString}` : '/packages') + }, + getHostingZones: (): Promise<{ + zones: Array<{ + id: number + name: string + ownerId: number + ownerUsername: string + logoUrl: string + sortOrder: number + }> + }> => http.get('/packages/hosting-zones'), + // 管理员专用:获取托管套餐列表 + listHosted: (params: { userId?: number } = {}): Promise<{ packages: Package[] }> => + http.get('/packages', { params: { ...params, scope: 'hosted' } }), + get: (id: number): Promise => http.get(`/packages/${id}`), + create: (data: CreatePackageRequest): Promise => http.post('/packages', data), + update: (id: number, data: UpdatePackageRequest): Promise => + http.patch(`/packages/${id}`, data), + delete: (id: number): Promise => http.delete(`/packages/${id}`), + // 套餐共享 + share: (packageId: number, friendId: number, quotaMultiplier?: number | null, maxInstances?: number | null): Promise<{ message: string }> => + http.post(`/packages/${packageId}/share`, { friendId, quotaMultiplier, maxInstances }), + unshare: (packageId: number, userId: number): Promise<{ message: string }> => + http.delete(`/packages/${packageId}/share/${userId}`), + updateShareQuota: (packageId: number, shareId: number, quotaMultiplier?: number | null, maxInstances?: number | null): Promise<{ message: string }> => + http.patch(`/packages/${packageId}/shares/${shareId}`, { quotaMultiplier, maxInstances }), + getShares: (packageId: number): Promise<{ + shares: Array<{ + id: number + packageId: number + packageName: string + ownerId: number + ownerUsername: string + sharedToId: number + sharedToUsername: string + sharedToAvatarStyle?: string | null + sharedToAvatarBadgeId?: string | null + quotaMultiplier: number | null + maxInstances: number | null + usage?: { + instanceCount: number + totalCpu: number + totalMemory: number + } + createdAt: string + }> + packageQuota: { + cpuMax: number + memoryMax: number + } + }> => http.get(`/packages/${packageId}/shares`), + getShared: (): Promise<{ + shares: Array<{ + id: number + packageId: number + packageName: string + ownerId: number + ownerUsername: string + sharedToId: number + sharedToUsername: string + sharedToAvatarStyle?: string | null + sharedToAvatarBadgeId?: string | null + quotaMultiplier: number | null + maxInstances: number | null + createdAt: string + }> + }> => http.get('/packages/my-shares'), + + // 资源释放功能 + getHostsDetail: (packageId: number): Promise<{ + hosts: Array<{ + id: number + name: string + countryCode: string + cpuAllowanceMax: number + memoryMax: number + cpuUsed: number + memoryUsed: number + }> + packageName: string + cpuMax: number + memoryMax: number + }> => http.get(`/packages/${packageId}/hosts-detail`), + + releaseQuota: (packageId: number, data: { + hostIds: number[] + cpuAdd: number + memoryAdd: number + notify?: boolean + }): Promise<{ + message: string + results: Array<{ + hostId: number + hostName: string + countryCode: string + cpuAllowanceMax: number + memoryMax: number + cpuAvailable: number + memoryAvailable: number + }> + }> => http.post(`/packages/${packageId}/release-quota`, data), + + // 获取托管套餐所有者信息 + getOwnerInfo: (packageId: number): Promise<{ + id: number + username: string + email: string + avatarStyle: string + avatarBadgeId?: string | null + hostCount: number + instanceCount: number + registeredDays: number + vipLevel: number + vipBadgeStyle?: VipBadgeStyle | null + }> => http.get(`/packages/${packageId}/owner-info`), + + // 套餐方案管理 + getPlans: (packageId: number, options?: { activeOnly?: boolean }): Promise<{ + plans: Array<{ + id: number + name: string + description: string | null + cpu: number + memory: number + disk: number + portLimit: number + snapshotLimit: number + backupLimit: number + siteLimit: number + swapSize: number + trafficLimit: string + trafficLimitSpeed: string + price: number + billingCycle: number + setupFee: number + monthlyPrice: number + isActive: boolean + isSoldOut: boolean + sortOrder: number + slaGuarantee: number | null + }> + }> => http.get(`/packages/${packageId}/plans`, { params: options }), + + createPlan: (packageId: number, data: { + name: string + description?: string + cpu: number + memory: number + disk: number + portLimit: number + snapshotLimit: number + backupLimit: number + siteLimit: number + swapSize: number + trafficLimit: string + trafficLimitSpeed?: string + price: number + billingCycle?: number + setupFee?: number + isActive?: boolean + isSoldOut?: boolean + sortOrder?: number + slaGuarantee?: number | null + }): Promise<{ id: number; name: string; message: string }> => + http.post(`/packages/${packageId}/plans`, data), + + updatePlan: (packageId: number, planId: number, data: { + name?: string + description?: string + cpu?: number + memory?: number + disk?: number + portLimit?: number + snapshotLimit?: number + backupLimit?: number + siteLimit?: number + swapSize?: number + trafficLimit?: string + trafficLimitSpeed?: string + price?: number + billingCycle?: number + setupFee?: number + isActive?: boolean + isSoldOut?: boolean + sortOrder?: number + slaGuarantee?: number | null + }): Promise<{ id: number; name: string; message: string }> => + http.put(`/packages/${packageId}/plans/${planId}`, data), + + deletePlan: (packageId: number, planId: number): Promise<{ message: string }> => + http.delete(`/packages/${packageId}/plans/${planId}`), + + // 获取可用地区列表(拥有付费方案的套餐所在的国家/地区) + getRegions: (params?: { source?: 'official' | 'market' | 'friends' | 'zone'; zoneId?: number }): Promise<{ + regions: Array<{ + code: string + name: string + packageCount: number + packageIds: number[] // 该地区包含的套餐 ID 列表 + }> + }> => http.get('/packages/regions', { params }), + }, + + // OAuth 配置(管理员) + oauth: { + getConfigs: (): Promise => http.get('/oauth/configs'), + updateConfig: (provider: 'github' | 'google', data: UpdateOAuthConfigRequest): Promise => + http.put(`/oauth/configs/${provider}`, data), + deleteConfig: (provider: 'github' | 'google'): Promise => + http.delete(`/oauth/configs/${provider}`), + getProviders: (): Promise> => + http.get('/oauth/providers'), + getBindings: (): Promise => http.get('/oauth/bindings'), + unbind: (provider: 'github' | 'google'): Promise => + http.delete(`/oauth/bindings/${provider}`), + createBindTicket: (): Promise<{ ticket: string; expiresIn: number }> => + http.post('/oauth/bind-ticket', {}), + // 交换 OAuth 登录码获取 Token(安全改进) + exchangeCode: (code: string): Promise<{ token: string; user: { id: number; username: string; role: string } }> => + http.post('/oauth/exchange-code', { code }) + }, + + // 帮助文章(公开) + help: { + list: (params: Record = {}): Promise> => + http.get('/help', { params }), + pinned: (limit?: number): Promise<{ articles: Array> }> => + http.get('/help/pinned', { params: limit ? { limit } : {} }), + categories: (): Promise => http.get('/help/categories'), + categoryConfig: (): Promise<{ categories: Array<{ id: string; name: string; color: string }> }> => + http.get('/help/category-config'), + getBySlug: (slug: string): Promise => http.get(`/help/article/${slug}`), + // 管理员接口 + adminList: (params: Record = {}): Promise> => + http.get('/help/admin', { params }), + adminGet: (id: number): Promise => http.get(`/help/admin/${id}`), + create: (data: CreateHelpArticleRequest): Promise => http.post('/help/admin', data), + update: (id: number, data: UpdateHelpArticleRequest): Promise => + http.patch(`/help/admin/${id}`, data), + delete: (id: number): Promise => http.delete(`/help/admin/${id}`), + saveCategoryConfig: (categories: Array<{ id: string; name: string; color: string }>): Promise<{ message: string }> => + http.put('/help/admin/category-config', { categories }) + }, + + // 镜像管理 + images: { + // 获取系统预定义镜像列表(用户端) + // @param type - 可选,按实例类型过滤 (container/vm) + // @param memory - 可选,内存大小(MB),128MB 时只返回 Alpine/Debian + getSystemImages: (type?: 'container' | 'vm', memory?: number, hostId?: number): Promise<{ success: boolean; images: SystemImage[] }> => { + const params: Record = {} + if (type) params.type = type + if (memory !== undefined) params.memory = String(memory) + if (hostId !== undefined) params.hostId = String(hostId) + return http.get('/images/system', { params }) + }, + // 管理员:获取所有镜像列表 + list: (): Promise<{ success: boolean; images: SystemImage[] }> => + http.get('/images/admin'), + // 管理员:创建镜像 + create: (data: CreateSystemImageRequest): Promise<{ success: boolean; image: SystemImage }> => + http.post('/images/admin', data), + // 管理员:更新镜像 + update: (id: number, data: UpdateSystemImageRequest): Promise<{ success: boolean; image: SystemImage }> => + http.patch(`/images/admin/${id}`, data), + // 管理员:删除镜像 + delete: (id: number): Promise<{ success: boolean }> => + http.delete(`/images/admin/${id}`) + }, + + // 日志管理 + logs: { + list: (params: Record = {}): Promise> => + http.get('/logs', { params }), + getModules: (): Promise => http.get('/logs/modules') + }, + + // 健康检查 + health: (): Promise<{ status: string; timestamp: string }> => http.get('/health'), + + // 系统配置(管理员) + systemConfig: { + // 公开配置(无需登录) + getPublic: (): Promise<{ + registrationEnabled: boolean + requireInviteCode: boolean + turnstileEnabled?: boolean + turnstileSiteKey?: string | null + ticketEnabled?: boolean + freeSiteMode?: boolean + mailAvailable?: boolean + avatarApiBase?: string + emailVerificationEnabled?: boolean + emailDomainWhitelistEnabled?: boolean + allowedEmailDomains?: string[] | null + transferFee?: number + footerContactEmail?: string | null + footerTelegramLink?: string | null + hostingMarketEntryEnabled?: boolean + hostingNotice?: string | null + brandName?: string | null + brandSubtitle?: string | null + brandLogoUrl?: string | null + popupAnnouncement?: string | null + popupAnnouncementUpdatedAt?: string | null + popupPromoImageUrl?: string | null + popupPromoPackage?: { + id: number + name: string + description: string | null + source: 'official' | 'market' + plans: Array<{ + id: number + name: string + description: string | null + cpu: number + memory: number + disk: number + trafficLimit: string + price: number + billingCycle: number + isSoldOut: boolean + }> + } | null + popupPromoUpdatedAt?: string | null + }> => + http.get('/system-config/public'), + // 测试 SMTP 连接 + testSmtp: (): Promise<{ success: boolean; message?: string; error?: string }> => + http.post('/system-config/smtp/test'), + // 发送测试邮件 + sendTestEmail: (to: string): Promise<{ success: boolean; message?: string; error?: string }> => + http.post('/system-config/smtp/send-test', { to }), + list: (): Promise<{ configs: Array<{ id: number; key: string; value: string; type: string; label: string | null; description: string | null }> }> => + http.get('/system-config'), + getDefaultQuota: (): Promise<{ quota: { hostLimit: number; friendLimit: number; packageLimit: number } }> => + http.get('/system-config/default-quota'), + update: (configs: Array<{ key: string; value: string }>): Promise<{ message: string }> => + http.put('/system-config', { configs }) + }, + + // Storage Configs (远程存储配置) + storageConfigs: { + list: (): Promise> => http.get('/storage-configs'), + + create: (data: { + name: string + type: 'WEBDAV' | 'FTP' | 'SFTP' | 'S3' + host: string + port?: number + username?: string + password?: string + basePath?: string + isDefault?: boolean + }): Promise<{ id: number }> => http.post('/storage-configs', data), + + update: (id: number, data: { + name?: string + type?: 'WEBDAV' | 'FTP' | 'SFTP' | 'S3' + host?: string + port?: number | null + username?: string | null + password?: string | null + basePath?: string | null + isDefault?: boolean + }): Promise<{ id: number }> => http.patch(`/storage-configs/${id}`, data), + + delete: (id: number): Promise => http.delete(`/storage-configs/${id}`), + + test: (id: number): Promise<{ success: boolean; message?: string }> => + http.post(`/storage-configs/${id}/test`), + + setDefault: (id: number): Promise<{ success: boolean }> => + http.post(`/storage-configs/${id}/set-default`) + }, + + // Traffic (流量统计) + traffic: { + // 获取当前用户流量 + getMyTraffic: (): Promise<{ + monthlyUsed: string + monthlyUsedFormatted: string + monthlyLimit: string | null + monthlyLimitFormatted: string | null + extraQuota: string + trafficStatus: 'NORMAL' | 'WARNING' | 'LIMITED' + percentage: number + }> => http.get('/me/traffic'), + + // 获取指定用户流量(管理员) + getUserTraffic: (userId: number): Promise<{ + monthlyUsed: string + monthlyUsedFormatted: string + monthlyLimit: string | null + monthlyLimitFormatted: string | null + extraQuota: string + trafficStatus: 'NORMAL' | 'WARNING' | 'LIMITED' + percentage: number + }> => http.get(`/users/${userId}/traffic`), + + // 更新用户流量限额(管理员) + updateUserTrafficLimit: (userId: number, monthlyLimit: string | null): Promise<{ success: boolean }> => + http.put(`/users/${userId}/traffic/limit`, { monthlyLimit }), + + // 获取实例流量 + getInstanceTraffic: (instanceId: number): Promise<{ + monthlyUsed: string + monthlyUsedFormatted: string + monthlyLimit: string | null + monthlyLimitFormatted: string | null + trafficStatus: 'NORMAL' | 'WARNING' | 'LIMITED' + percentage: number + trafficResetDay: number + periodStart: string + periodEnd: string + }> => http.get(`/instances/${instanceId}/traffic`), + + // 获取实例流量历史 + getInstanceTrafficHistory: (instanceId: number, days?: number): Promise<{ + trafficResetDay: number + periodStart: string + periodEnd: string + data: Array<{ + date: string + rxTotal: string + txTotal: string + rxFormatted: string + txFormatted: string + total: string + totalFormatted: string + }> + }> => http.get(`/instances/${instanceId}/traffic/history`, { params: { days } }), + + // 更新实例流量限额(管理员) + updateInstanceTrafficLimit: (instanceId: number, monthlyLimit: string | null): Promise<{ success: boolean }> => + http.put(`/instances/${instanceId}/traffic/limit`, { monthlyLimit }), + + // 重置实例月度流量(宿主机所有者) + resetInstanceTraffic: (instanceId: number): Promise<{ success: boolean; message: string }> => + http.post(`/instances/${instanceId}/traffic/reset`), + + // 手动触发流量采集(管理员) + triggerCollection: (): Promise<{ success: boolean; message: string }> => + http.post('/traffic/collect'), + + // 同步套餐流量限额到所有实例(管理员) + syncPackageLimits: (): Promise<{ success: boolean; message: string; updatedCount: number }> => + http.post('/traffic/sync-package-limits'), + + // 获取节点流量统计(管理员) + getHostTrafficHistory: (hostId: number): Promise<{ + trafficResetDay: number + periodStart: string + periodEnd: string + data: Array<{ + date: string + rxTotal: string + txTotal: string + rxFormatted: string + txFormatted: string + total: string + totalFormatted: string + }> + summary: { + totalUsed: string + totalUsedFormatted: string + totalLimit: string + totalLimitFormatted: string + } + }> => http.get(`/hosts/${hostId}/traffic/history`) + }, + + // Friends (好友系统) + friends: { + // 获取好友列表 + list: (): Promise<{ + friends: Array<{ + id: number // 好友的用户ID + friendshipId: number // friendship 记录的ID + friendId: number // 好友的用户ID(与 id 相同) + username: string + avatarStyle: string + avatarBadgeId?: string | null + status: string + createdAt: string + acceptedAt: string | null + initiatedByMe: boolean + hostCount?: number + instanceCount?: number + }> + }> => http.get('/friends'), + + // 发送好友请求 + sendRequest: (username: string, remark?: string): Promise<{ message: string; request?: { id: number }; friendship?: any }> => + http.post('/friends/request', { username, remark }), + + // 获取待处理的好友请求 + getPendingRequests: (): Promise<{ + requests: Array<{ + id: number + userId: number + username: string + avatarStyle: string + avatarBadgeId?: string | null + remark: string | null + createdAt: string + }> + }> => http.get('/friends/requests'), + + // 获取已发送的好友请求 + getSentRequests: (): Promise<{ + requests: Array<{ + id: number + userId: number + username: string + avatarStyle: string + avatarBadgeId?: string | null + remark: string | null + createdAt: string + }> + }> => http.get('/friends/requests/sent'), + + // 获取历史记录(已处理的请求) + getHistory: (filter?: 'accepted' | 'rejected' | 'removed' | 'all'): Promise<{ + history: Array<{ + id: number + userId: number + username: string + avatarStyle: string + avatarBadgeId?: string | null + remark: string | null + status: 'accepted' | 'rejected' | 'removed' + createdAt: string + acceptedAt: string | null + rejectedAt: string | null + initiatedByMe: boolean // 是否是我发起的请求 + }> + }> => http.get('/friends/history', { params: { filter } }), + + // 接受好友请求 + accept: (requestId: number): Promise<{ message: string; friendship: any }> => + http.post(`/friends/${requestId}/accept`), + + // 拒绝好友请求 + reject: (requestId: number): Promise<{ message: string }> => + http.post(`/friends/${requestId}/reject`), + + // 取消已发送的好友请求 + cancelRequest: (requestId: number): Promise<{ message: string }> => + http.delete(`/friends/request/${requestId}`), + + // 删除好友 + remove: (friendshipId: number): Promise<{ message: string }> => + http.delete(`/friends/${friendshipId}`), + + // 搜索用户(用于添加好友) + searchUser: (username: string): Promise<{ + user: { + id: number + username: string + avatarStyle: string + isFriend: boolean + } + }> => http.get('/friends/search', { params: { username } }), + + // 获取好友的资源统计 + getFriendResources: (friendshipId: number): Promise<{ + resources: { + hosts: number + images: number + packages: number + } + }> => http.get(`/friends/${friendshipId}/resources`) + }, + + // Transfers (实例转移) + transfers: { + // 搜索用户(用于转移时选择接收方) + searchUser: (username: string): Promise<{ + user: { + id: number + username: string + status: string + } + }> => http.get('/transfers/users/search', { params: { username } }), + + // 发起转移请求 + create: (instanceId: number, targetUsername: string, remark?: string): Promise<{ + message: string + transfer: { id: number; status: string } + }> => http.post(`/transfers/instances/${instanceId}/transfer`, { targetUsername, remark }), + + // 获取转移列表 + list: (type: 'sent' | 'received', params: { status?: string; page?: number; pageSize?: number; search?: string } = {}): Promise<{ + transfers: Array<{ + id: number + instanceId: number + instanceName: string + instanceStatus: string + instanceImage: string + fromUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null } | null + toUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null } | null + status: string + snapshot: any + remark: string | null + rejectReason: string | null + createdAt: string + acceptedAt: string | null + rejectedAt: string | null + cancelledAt: string | null + canPush?: boolean + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get('/transfers', { params: { type, ...params } }), + + // 获取待接收数量 + getPendingCount: (): Promise<{ count: number }> => http.get('/transfers/pending-count'), + + // 获取转移详情 + get: (id: number): Promise<{ + transfer: { + id: number + instanceId: number + instanceName: string + instanceStatus: string + fromUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null } + toUser: { id: number; username: string; email?: string | null; avatarStyle?: string; avatarBadgeId?: string | null } + status: string + snapshot: any + remark: string | null + rejectReason: string | null + createdAt: string + acceptedAt: string | null + rejectedAt: string | null + cancelledAt: string | null + } + }> => http.get(`/transfers/${id}`), + + // 接受转移 + accept: (id: number): Promise<{ message: string }> => http.post(`/transfers/${id}/accept`), + + // 拒绝转移 + reject: (id: number, reason?: string): Promise<{ message: string }> => + http.post(`/transfers/${id}/reject`, { reason }), + + // 取消转移 + cancel: (id: number): Promise<{ message: string }> => http.post(`/transfers/${id}/cancel`), + + // 直接推送(宿主机所有者可直接将实例推送给接收方,无需对方接受) + push: (id: number): Promise<{ message: string }> => http.post(`/transfers/${id}/push`) + }, + + // 敏感操作二次验证 + verification: { + // 请求验证码 + request: (operationType: string, resourceId?: number): Promise<{ + message: string + success?: boolean + required?: boolean + channel: 'email' | 'telegram' | 'discord' | 'webhook' + target?: string + maskedTarget?: string + expiresAt?: string + expiresIn?: number + operationName?: string + }> => http.post('/verification/request', { operationType, resourceId }), + + // 验证码校验 + verify: (operationType: string, code: string, resourceId?: number): Promise<{ + message: string + verified: boolean + }> => http.post('/verification/verify', { operationType, code, resourceId }), + + // 检查是否已验证 + check: (operationType: string, resourceId?: number): Promise<{ + verified: boolean + expiresAt?: string + }> => http.get('/verification/check', { params: { operationType, resourceId } }), + + // 获取支持的操作类型 + getOperationTypes: (): Promise<{ + operationTypes: Array<{ type: string; name: string; requiresResource: boolean }> + }> => http.get('/verification/operation-types') + }, + + // 站内信 + inbox: { + // 获取消息列表 + list: (params?: { page?: number; pageSize?: number; isRead?: boolean }): Promise<{ + messages: Array<{ + id: number + userId: number + eventType: string + title: string + content: string + isRead: boolean + data: Record | null + createdAt: string + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get('/inbox', { params }), + + // 获取未读数量 + getUnreadCount: (): Promise<{ count: number }> => http.get('/inbox/unread-count'), + + // 标记单条已读 + markAsRead: (id: number): Promise<{ success: boolean }> => http.post(`/inbox/${id}/read`), + + // 全部标记已读 + markAllAsRead: (): Promise<{ success: boolean; count: number }> => http.post('/inbox/read-all'), + + // 删除单条消息 + delete: (id: number): Promise<{ success: boolean }> => http.delete(`/inbox/${id}`), + + // 清空已读消息 + deleteRead: (): Promise<{ success: boolean; count: number }> => http.delete('/inbox/read'), + + // 管理员发送全站站内信 + broadcast: (data: { title: string; content: string }): Promise<{ success: boolean; count: number }> => + http.post('/inbox/admin/broadcast', data), + + // 管理员发送站内信给特定用户 + sendToUser: (userId: number, data: { title: string; content: string }): Promise<{ success: boolean }> => + http.post(`/inbox/admin/send/${userId}`, data), + + // 节点所有者通知实例用户 + notifyHostUsers: ( + hostId: number, + data: { title: string; content: string; instanceIds?: number[]; sendEmail?: boolean } + ): Promise<{ + success: boolean + count: number + email?: { + requested: boolean + mode: 'none' | 'direct' | 'queued' + sentCount: number + queuedCount: number + skippedCount: number + failedCount: number + } + }> => + http.post(`/inbox/hosts/${hostId}/notify`, data), + + // 节点所有者发送站内信给特定实例用户 + notifyInstanceUser: (instanceId: number, data: { title: string; content: string }): Promise<{ success: boolean }> => + http.post(`/inbox/instances/${instanceId}/notify`, data) + }, + + // 公告/通知历史 + announcements: { + // 获取公告历史列表(管理员) + list: (params?: { page?: number; pageSize?: number; type?: string }): Promise<{ + items: Array<{ + id: number + type: 'system_broadcast' | 'host_broadcast' | 'admin_message' | 'host_message' + title: string + content: string + recipientCount: number + hostId: number | null + targetUserId: number | null + instanceId: number | null + createdAt: string + sender: { id: number; username: string } + host: { id: number; name: string } | null + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get('/announcements', { params }), + + // 获取宿主机所有者的公告历史 + listMy: (params?: { page?: number; pageSize?: number; hostId?: number }): Promise<{ + items: Array<{ + id: number + type: 'host_broadcast' | 'host_message' + title: string + content: string + recipientCount: number + hostId: number | null + targetUserId: number | null + instanceId: number | null + createdAt: string + host: { id: number; name: string } | null + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get('/announcements/my', { params }), + + // 获取公告详情 + get: (id: number): Promise<{ + id: number + type: 'system_broadcast' | 'host_broadcast' | 'admin_message' | 'host_message' + title: string + content: string + recipientCount: number + hostId: number | null + targetUserId: number | null + instanceId: number | null + createdAt: string + sender: { id: number; username: string } + host: { id: number; name: string } | null + }> => http.get(`/announcements/${id}`) + }, + + // 工单系统 + tickets: { + // 创建工单 + create: (data: CreateTicketRequest): Promise<{ message: string; ticket: { id: number; messageId: number } }> => + (data.attachments && data.attachments.length > 0) + ? http.post('/tickets', buildTicketFormData(data), { headers: { 'Content-Type': 'multipart/form-data' }, timeout: TIMEOUT.MEDIUM }) + : http.post('/tickets', { + instanceId: data.instanceId, + subject: data.subject, + category: data.category, + priority: data.priority, + content: data.content + }), + + // 获取我的工单列表(支持 active 状态筛选和搜索) + list: (params?: { status?: TicketStatus | 'active'; search?: string; page?: number; pageSize?: number }): Promise => + http.get('/tickets', { params }), + + // 获取工单详情 + get: (id: number): Promise<{ ticket: Ticket; isOwner: boolean; isCreator: boolean }> => + http.get(`/tickets/${id}`), + + // 获取工单消息列表 + getMessages: (id: number, params?: { page?: number; pageSize?: number }): Promise => + http.get(`/tickets/${id}/messages`, { params }), + + // 回复工单 + reply: (id: number, content: string, attachments?: File[]): Promise<{ message: string; data: TicketMessage }> => + (attachments && attachments.length > 0) + ? http.post(`/tickets/${id}/messages`, buildTicketFormData({ content, attachments }), { headers: { 'Content-Type': 'multipart/form-data' }, timeout: TIMEOUT.MEDIUM }) + : http.post(`/tickets/${id}/messages`, { content }), + + // 读取工单图片内容 + getAttachmentContent: (attachmentId: number): Promise => + http.get(`/tickets/attachments/${attachmentId}/content`, { responseType: 'blob', timeout: TIMEOUT.MEDIUM }), + + // 删除工单消息(仅管理员) + deleteMessage: (ticketId: number, messageId: number): Promise<{ message: string }> => + http.delete(`/tickets/${ticketId}/messages/${messageId}`), + + // 更新工单状态(宿主机所有者) + updateStatus: (id: number, status: TicketStatus): Promise<{ message: string; status: TicketStatus }> => + http.patch(`/tickets/${id}/status`, { status }), + + // 关闭工单 + close: (id: number): Promise<{ message: string }> => + http.post(`/tickets/${id}/close`), + + // 获取宿主机收到的工单列表(支持 active 状态筛选和搜索) + getHostTickets: (hostId: number, params?: { status?: TicketStatus | 'active'; search?: string; page?: number; pageSize?: number }): Promise => + http.get(`/tickets/hosts/${hostId}`, { params }), + + // 获取所有宿主机的工单(汇总视图,支持 active 状态筛选和搜索) + getMyHostTickets: (params?: { status?: TicketStatus | 'active'; hostId?: number; sourceType?: 'all' | 'user' | 'official' | 'hosted'; search?: string; page?: number; pageSize?: number }): Promise => + http.get('/tickets/my-hosts', { params }), + + // 获取待处理工单数量 + getPendingCount: (): Promise<{ userTickets: number; hostTickets: number; total: number; isHostOwner: boolean }> => + http.get('/tickets/pending-count') + }, + + // 签到系统 + checkin: { + // 获取签到状态 + getStatus: (): Promise<{ + hasCheckedIn: boolean + hasInstances: boolean + selfOnlyMode: boolean + consecutiveOthersUse: number + }> => http.get('/checkin/status'), + + // 执行签到(资源直接存入资源池) + checkin: (): Promise<{ + message: string + codeType: string + codeValue: number + toResourcePool: boolean + bonusPoints: number + }> => http.post('/checkin/checkin'), + + // 兑换系统兑换码 + // 仅支持系统码(h-前缀):instanceId 必须,直接应用到实例 + redeem: (redeemCode: string, instanceId: number): Promise<{ + message: string + codeType: string + codeValue: number + actualAdded: number + instanceId?: number + instanceName?: string + isSystemCode: boolean + toResourcePool: boolean + }> => http.post('/checkin/redeem', { redeemCode, instanceId }), + + // 获取可用实例列表 + getInstances: (): Promise<{ + instances: Array<{ + id: number + name: string + status: string + cpu: number + memory: number + disk: number + monthlyTrafficLimit: string | null + package: { + id: number + name: string + cpuMax: number + memoryMax: number + diskMax: number + monthlyTrafficLimit: string | null + } | null + host: { + id: number + name: string + location: string | null + countryCode: string + } + }> + }> => http.get('/checkin/instances'), + + // 获取签到记录 + getRecords: (params?: { limit?: number; offset?: number }): Promise<{ + records: Array<{ + id: number + redeemCode: string + codeType: string + codeValue: number + expiresAt: string + usedAt: string | null + usedBy: { id: number; username: string } | null + usedFor: { id: number; name: string } | null + createdAt: string + }> + total: number + }> => http.get('/checkin/records', { params }), + + // 获取兑换记录 + getRedeems: (params?: { limit?: number; offset?: number }): Promise<{ + records: Array<{ + id: number + redeemCode: string + codeType: string + codeValue: number + owner: { id: number; username: string } + usedFor: { id: number; name: string } | null + usedAt: string | null + isSystemCode?: boolean + }> + total: number + }> => http.get('/checkin/redeems', { params }) + }, + + // 资源池系统 + resourcePool: { + // 获取用户资源池 + get: (): Promise<{ + cpu: number + memory: number + disk: number + traffic: number + }> => http.get('/resource-pool'), + + // 应用资源到实例 + apply: (data: { + instanceId: number + resourceType: 'c' | 'r' | 'd' | 't' + amount: number + }): Promise<{ + message: string + resourceType: string + amount: number + instanceId: number + instanceName: string + }> => http.post('/resource-pool/apply', data), + + // 获取资源池变动记录 + getLogs: (params?: { + action?: string + resourceType?: string + limit?: number + offset?: number + }): Promise<{ + records: Array<{ + id: number + action: string + resourceType: string + amount: number + instance: { id: number; name: string } | null + remark: string | null + createdAt: string + }> + total: number + }> => http.get('/resource-pool/logs', { params }), + + // 获取可应用资源的实例列表(包括免费和付费) + getInstances: (): Promise<{ + instances: Array<{ + id: number + name: string + status: string + cpu: number + memory: number + disk: number + monthlyTrafficLimit: string | null + isPaid: boolean + instanceType: 'vm' | 'container' + host: { + id: number + name: string + location: string | null + countryCode: string + } + }> + }> => http.get('/resource-pool/instances') + }, + + // 用户自定义初始化命令 + initCommands: { + // 获取用户的所有初始化命令模板 + list: (): Promise<{ + commands: Array<{ + id: number + name: string + commandPreview: string + commandLineCount: number + distros: string[] + description: string | null + enabled: boolean + createdAt: string + updatedAt: string + }> + }> => http.get('/init-commands'), + + // 获取适配指定发行版的命令列表(创建/重装时使用) + getAvailable: (distro: string): Promise<{ + commands: Array<{ + id: number + name: string + commandLineCount: number + distros: string[] + description: string | null + }> + }> => http.get('/init-commands/available', { params: { distro } }), + + // 获取单个命令详情 + get: (id: number): Promise<{ + command: { + id: number + name: string + command: string + distros: string[] + description: string | null + enabled: boolean + createdAt: string + updatedAt: string + } + }> => http.get(`/init-commands/${id}`), + + // 创建初始化命令模板 + create: (data: { + name: string + command: string + distros: string[] + description?: string + }): Promise<{ message: string; id: number }> => http.post('/init-commands', data), + + // 更新初始化命令模板 + update: (id: number, data: { + name?: string + command?: string + distros?: string[] + description?: string | null + enabled?: boolean + }): Promise<{ message: string }> => http.put(`/init-commands/${id}`, data), + + // 删除初始化命令模板 + delete: (id: number): Promise<{ message: string }> => http.delete(`/init-commands/${id}`), + + // 获取可用的发行版列表(名称由前端翻译) + getDistros: (): Promise<{ + distros: Array<{ + id: string + icon: string + }> + }> => http.get('/init-commands/distros') + }, + + // 计费相关 + billing: { + // 获取实例计费信息 + getInstanceBilling: (instanceId: number): Promise<{ + billing: { + instanceId: number + instanceName: string + planId: number | null + planName: string | null + price: number | null + billingCycle: number | null + expiresAt: string | null + autoRenew: boolean + renewPreview: Array<{ months: number; price: number; expiresAt: string }> | null + } + }> => http.get(`/instances/${instanceId}/billing`), + + // 实例续费 + renewInstance: (instanceId: number, months: number): Promise<{ + message: string + amount: number + months: number + newExpiresAt: string + }> => http.post(`/instances/${instanceId}/renew`, { months }), + + applyAffCodeToInstance: (instanceId: number, affCode: string): Promise<{ + success: boolean + message: string + discountRate: number + discountPercent: number + }> => http.post(`/instances/${instanceId}/apply-aff`, { affCode }), + + previewBatchRenew: (instanceIds: number[]): Promise<{ + items: Array<{ + id: number + name: string + canRenew: boolean + autoRenew: boolean + reason?: string + isHostedInstance: boolean + daysUntilExpire: number | null + options: Array<{ + months: number + price: number + discountedPrice: number + expiresAt: string + }> + }> + }> => http.post('/instances/batch/renew-preview', { instanceIds }, { timeout: TIMEOUT.BATCH }), + + renewInstancesBatch: (instanceIds: number[], months: number): Promise<{ + message: string + successCount: number + skippedCount: number + failedCount: number + results: Array<{ + id: number + name: string + success: boolean + skipped?: boolean + reason?: string + amount?: number + newExpiresAt?: string + }> + }> => http.post('/instances/batch/renew', { instanceIds, months }, { timeout: TIMEOUT.BATCH }), + + // 升降级预览 + getChangePlanPreview: (instanceId: number, newPlanId: number): Promise<{ + preview: { + oldPlan: { id: number; name: string; price: number; billingCycle: number } + newPlan: { id: number; name: string; price: number; billingCycle: number; isActive: boolean; isSoldOut: boolean } + remainingDays: number + oldDailyPrice: number + newDailyPrice: number + remainingValue: number + newPlanCost: number + discountRate: number + discountAmount: number + priceDiff: number + isUpgrade: boolean + newExpiresAt: string + newConfig: { cpu: number; memory: number; disk: number } + resourceWarnings: string[] | null + canChange: boolean + cannotChangeReason?: string + } + }> => http.get(`/instances/${instanceId}/change-plan/preview`, { params: { newPlanId } }), + + // 执行升降级 + changePlan: (instanceId: number, newPlanId: number): Promise<{ + message: string + priceDiff: number + newConfig: { cpu: number; memory: number; disk: number } + needRestart: boolean + restartMessage: string | null + }> => http.post(`/instances/${instanceId}/change-plan`, { newPlanId }), + + // 获取销毁预览信息 + getDestroyInfo: (instanceId: number): Promise<{ + canDestroy: boolean + cannotDestroyReason: string + isFreeInstance: boolean + isFirstTime: boolean + rules: { + feeRate: number + } + refund: { + remainingDays: number + remainingValue: number + feeRate: number + feeAmount: number + refundAmount: number + destroyCount: number + } + instance: { + id: number + name: string + hostName: string + planName: string | null + } + }> => http.get(`/instances/${instanceId}/destroy-info`), + + // 执行销毁 + destroyInstance: (instanceId: number, options?: { feeWaiver?: string }): Promise<{ + success: boolean + message: string + refundAmount: number + feeAmount: number + isFirstTime: boolean + isFreeInstance: boolean + }> => http.post(`/instances/${instanceId}/destroy${options?.feeWaiver ? `?feeWaiver=${options.feeWaiver}` : ''}`), + + getBatchDestroyInfo: (instanceIds: number[]): Promise<{ + items: Array<{ + id: number + name: string + canDestroy: boolean + cannotDestroyReason: string + isFreeInstance: boolean + isFirstTime: boolean + feeWaiverEligible: boolean + refund: { + remainingDays: number + remainingValue: number + feeRate: number + feeAmount: number + refundAmount: number + destroyCount: number + maxRefundable: number + } + instance: { + id: number + name: string + hostName: string + planName: string | null + } + }> + }> => http.post('/instances/batch/destroy-info', { instanceIds }, { timeout: TIMEOUT.BATCH }), + + destroyInstancesBatch: (instanceIds: number[]): Promise<{ + message: string + successCount: number + skippedCount: number + failedCount: number + results: Array<{ + id: number + name: string + success: boolean + skipped?: boolean + reason?: string + refundAmount?: number + feeAmount?: number + isFirstTime?: boolean + isFreeInstance?: boolean + }> + }> => http.post('/instances/batch/destroy', { instanceIds }, { timeout: TIMEOUT.BATCH }), + + // 切换自动续费 + setAutoRenew: (instanceId: number, autoRenew: boolean): Promise<{ + message: string + autoRenew: boolean + }> => http.patch(`/instances/${instanceId}/auto-renew`, { autoRenew }), + + setAutoRenewBatch: (instanceIds: number[], autoRenew: boolean): Promise<{ + message: string + successCount: number + skippedCount: number + failedCount: number + results: Array<{ + id: number + name: string + success: boolean + skipped?: boolean + reason?: string + autoRenew?: boolean + }> + }> => http.patch('/instances/batch/auto-renew', { instanceIds, autoRenew }, { timeout: TIMEOUT.BATCH }), + + // 获取实例计费记录 + getInstanceBillingRecords: (instanceId: number, params?: { page?: number; pageSize?: number; type?: string }): Promise<{ + records: Array<{ + id: number + type: string + amount: number + months: number | null + periodStart: string + periodEnd: string + remark: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get(`/instances/${instanceId}/billing/records`, { params }), + + // 获取用户余额 + getUserBalance: (): Promise<{ + balance: { balance: number; frozen: number; totalRecharge: number; totalConsume: number; destroyedValue: number } + }> => http.get('/balance/me'), + + // 获取余额记录 + getBalanceLogs: (params?: { page?: number; pageSize?: number; type?: string; lotteryGift?: 'exclude' | 'only' }): Promise<{ + records: Array<{ + id: number + type: string + amount: number + balanceBefore: number + balanceAfter: number + instanceId: number | null + instanceName: string | null + remark: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/balance/me/logs', { params }), + + // 获取可用支付渠道 + getPaymentProviders: (): Promise<{ + providers: Array<{ + id: number + name: string + type: string + methods: string[] + methodFees?: Record + minAmount: number + maxAmount: number | null + feeRate: number + feeFixed: number + }> + }> => http.get('/recharge/providers'), + + // 创建充值订单 + createRechargeOrder: (providerId: number, amount: number, paymentMethod?: string): Promise<{ + order: { + orderNo: string + amount: number + payableAmount: number + actualAmount: number + fee: number + status: string + expiredAt: string + createdAt: string + } + provider: { + id: number + name: string + type: string + methods: string[] + } + payUrl: string | null + }> => http.post('/recharge/orders', { providerId, amount, paymentMethod }), + + // 获取充值记录列表 + getRechargeOrders: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{ + records: Array<{ + id: number + orderNo: string + amount: number + payableAmount: number + actualAmount: number | null + fee: number + status: string + provider: { id: number; name: string; type: string } | null + paymentMethod: string | null + actualPaymentMethod: string | null + paymentCurrency: string | null + paymentNetwork: string | null + paymentUuid: string | null + paymentTxid: string | null + invoiceCurrency: string | null + gatewayStatus: string | null + gatewayStatusDescription: string | null + tradeNo: string | null + createdAt: string + expiredAt: string | null + completedAt: string | null + }> + total: number + page: number + pageSize: number + }> => http.get('/recharge/orders', { params }), + + // 获取充值订单详情 + getRechargeOrder: (orderNo: string): Promise<{ + order: { + id: number + orderNo: string + amount: number + payableAmount: number + actualAmount: number | null + fee: number + status: string + provider: { id: number; name: string; type: string } | null + paymentMethod: string | null + actualPaymentMethod: string | null + paymentCurrency: string | null + paymentNetwork: string | null + paymentUuid: string | null + paymentTxid: string | null + invoiceCurrency: string | null + gatewayStatus: string | null + gatewayStatusDescription: string | null + tradeNo: string | null + failReason: string | null + createdAt: string + expiredAt: string | null + completedAt: string | null + } + }> => http.get(`/recharge/orders/${orderNo}`), + + // 取消充值订单 + cancelRechargeOrder: (orderNo: string): Promise<{ + success: boolean + message: string + }> => http.post(`/recharge/orders/${orderNo}/cancel`), + + // 重新支付订单 + repayRechargeOrder: (orderNo: string, paymentMethod?: string): Promise<{ + order: { + orderNo: string + amount: number + payableAmount: number + actualAmount: number | null + status: string + expiredAt: string | null + } + payUrl: string + }> => http.post(`/recharge/orders/${orderNo}/repay`, { paymentMethod }), + + // 获取用户充值统计 + getRechargeStats: (): Promise<{ + stats: { + totalAmount: number + completedCount: number + pendingCount: number + } + }> => http.get('/recharge/stats'), + + // 验证订单支付状态(主动查询易支付) + verifyRechargeOrder: (orderNo: string): Promise<{ + success: boolean + verified: boolean + status: string + message: string + order?: { + id?: number + orderNo: string + amount: number + payableAmount?: number + actualAmount?: number | null + fee?: number + status: string + provider?: { id: number; name: string; type: string } | null + paymentMethod?: string | null + actualPaymentMethod?: string | null + paymentCurrency?: string | null + paymentNetwork?: string | null + paymentUuid?: string | null + paymentTxid?: string | null + invoiceCurrency?: string | null + gatewayStatus?: string | null + gatewayStatusDescription?: string | null + tradeNo?: string | null + failReason?: string | null + createdAt?: string + expiredAt?: string | null + completedAt?: string | null + } + }> => http.post(`/recharge/orders/${orderNo}/verify`), + + // 获取套餐方案列表 + getPackagePlans: (packageId: number): Promise<{ + plans: Array<{ + id: number + name: string + description: string | null + price: number + billingCycle: number + cpu: number + memory: number + disk: number + portLimit: number + snapshotLimit: number + backupLimit: number + siteLimit: number + swapSize: number + monthlyTrafficLimit: string | null + isActive: boolean + isSoldOut: boolean + slaGuarantee: number | null + }> + }> => http.get(`/packages/${packageId}/plans`) + }, + + // 管理员 API + admin: { + // ==================== 支付渠道管理 ==================== + + // 获取支付渠道列表 + getPaymentProviders: (): Promise<{ + providers: Array<{ + id: number + name: string + type: string + status: string + config: Record + methods: string[] + methodFees?: Record + minAmount: number + maxAmount: number | null + feeRate: number + feeFixed: number + sortOrder: number + createdAt: string + updatedAt: string + }> + }> => http.get('/admin/payment-providers'), + + // 创建支付渠道 + createPaymentProvider: (data: { + name: string + type: string + config?: Record + methods?: string[] + minAmount?: number + maxAmount?: number | null + feeRate?: number + feeFixed?: number + sortOrder?: number + }): Promise<{ + provider: { id: number; name: string; type: string; status: string } + message: string + }> => http.post('/admin/payment-providers', data), + + // 更新支付渠道 + updatePaymentProvider: (id: number, data: { + name?: string + config?: Record + methods?: string[] + minAmount?: number + maxAmount?: number | null + feeRate?: number + feeFixed?: number + sortOrder?: number + }): Promise<{ + provider: { id: number; name: string; type: string; status: string } + message: string + }> => http.put(`/admin/payment-providers/${id}`, data), + + // 更新支付渠道状态 + updatePaymentProviderStatus: (id: number, status: string): Promise<{ + message: string + }> => http.patch(`/admin/payment-providers/${id}/status`, { status }), + + // 删除支付渠道 + deletePaymentProvider: (id: number): Promise<{ + message: string + }> => http.delete(`/admin/payment-providers/${id}`), + + // ==================== 统计 ==================== + + getStatisticsOverview: (): Promise<{ + meta: { + timezone: string + dailyDays: number + monthlyMonths: number + } + users: { + total: number + dailyNewUsers: Array<{ label: string; value: number }> + monthlyNewUsers: Array<{ label: string; value: number }> + } + instances: { + total: number + active: number + paid: number + free: number + dailyCreatedInstances: Array<{ label: string; value: number }> + monthlyCreatedInstances: Array<{ label: string; value: number }> + } + billing: { + totals: { + recharge: number + consume: number + aff: number + destroyFee: number + } + dailyRecharge: Array<{ label: string; value: number }> + monthlyRecharge: Array<{ label: string; value: number }> + dailyConsume: Array<{ label: string; value: number }> + monthlyConsume: Array<{ label: string; value: number }> + dailyAff: Array<{ label: string; value: number }> + monthlyAff: Array<{ label: string; value: number }> + dailyDestroyFee: Array<{ label: string; value: number }> + monthlyDestroyFee: Array<{ label: string; value: number }> + } + }> => http.get('/admin/statistics/overview'), + + // ==================== VIP 等级规则 ==================== + + getVipLevelRules: (type: VipRuleType): Promise => + http.get(`/admin/vip-levels/${type}`), + + updateVipLevelRules: (type: VipRuleType, rules: VipLevelRule[], options?: { userMetric?: UserVipMetric }): Promise => + http.put(`/admin/vip-levels/${type}`, { rules, ...options }), + + getVipBenefitRewards: (): Promise<{ rewards: VipBenefitReward[] }> => + http.get('/admin/vip-benefits/rewards'), + + createVipBenefitReward: (data: VipBenefitRewardInput): Promise<{ reward: VipBenefitReward }> => + http.post('/admin/vip-benefits/rewards', data), + + updateVipBenefitReward: (id: number, data: VipBenefitRewardInput): Promise<{ reward: VipBenefitReward }> => + http.put(`/admin/vip-benefits/rewards/${id}`, data), + + deleteVipBenefitReward: (id: number): Promise<{ success: boolean }> => + http.delete(`/admin/vip-benefits/rewards/${id}`), + + // ==================== 托管管理 ==================== + + getHostingOwners: (params?: { + page?: number + pageSize?: number + search?: string + sortBy?: 'vipLevel' | 'hostingBalance' | 'frozenBalance' | 'totalIncome' | 'hostCount' | 'packageCount' | 'instanceCount' + sortOrder?: 'asc' | 'desc' + }): Promise<{ + owners: Array<{ + id: number + username: string + email: string | null + status: string + avatarStyle: string + avatarBadgeId?: string | null + vipLevel: number + vipBadgeStyle?: VipBadgeStyle | null + hostingBalance: { + available: number + frozen: number + total: number + historicalTotal: number + } + hostCount: number + listedPackageCount: number + instanceCount: number + createdAt: string + hostingZoneId: number | null + }> + summary: { + totalHosts: number + totalListedPackages: number + totalInstances: number + totalAvailableBalance: number + totalFrozenBalance: number + totalHostingIncome: number + } + total: number + page: number + pageSize: number + totalPages: number + }> => http.get('/admin/hosting/owners', { params }), + getHostingZones: (): Promise<{ + zones: Array<{ + id: number + name: string + logoUrl: string + active: boolean + sortOrder: number + createdAt: string + updatedAt: string + listedPackageCount: number + hostCount: number + owner: { + id: number + username: string + email: string | null + avatarStyle: string + avatarBadgeId?: string | null + } + }> + }> => http.get('/admin/hosting/zones'), + createHostingZone: (data: { name: string; ownerId: number; logoUrl: string }): Promise<{ + zone: { + id: number + name: string + logoUrl: string + active: boolean + sortOrder: number + createdAt: string + updatedAt: string + listedPackageCount: number + hostCount: number + owner: { + id: number + username: string + email: string | null + avatarStyle: string + avatarBadgeId?: string | null + } + } + message: string + }> => http.post('/admin/hosting/zones', data), + deleteHostingZone: (id: number): Promise<{ message: string }> => + http.delete(`/admin/hosting/zones/${id}`), + + // ==================== 计费管理 ==================== + + // 获取计费概览 + getBillingOverview: (): Promise<{ + overview: { + totalRevenue: number + thisMonthRevenue: number + lastMonthRevenue: number + todayRevenue: number + totalRefunds: number + netRevenue: number + paidInstancesCount: number + activePaidInstancesCount: number + suspendedCount: number + expiringCount: number + revenueMix: { + direct: { + totalAmount: number + thisMonthAmount: number + todayAmount: number + } + hosted: { + totalAmount: number + thisMonthAmount: number + todayAmount: number + } + } + recharge: { + totalAmount: number + totalCount: number + thisMonthAmount: number + thisMonthCount: number + todayAmount: number + todayCount: number + } + aff: { + totalCommission: number + totalOrders: number + thisMonthCommission: number + totalConverted: number + pendingConvertCount: number + } + } + }> => http.get('/admin/billing/overview'), + + // 获取扣费记录 + getBillingRecords: (params?: { page?: number; pageSize?: number; type?: string; userId?: number; instanceId?: number }): Promise<{ + records: Array<{ + id: number + type: string + amount: number + months: number | null + periodStart: string | null + periodEnd: string | null + remark: string | null + createdAt: string + instance: { id: number; name: string } | null + user: { id: number; username: string } | null + }> + total: number + page: number + pageSize: number + }> => http.get('/admin/billing/records', { params }), + + // 获取付费实例列表 + getBillingInstances: (params?: { page?: number; pageSize?: number; status?: string; expiring?: boolean; hostId?: number | ''; search?: string }): Promise<{ + instances: Array<{ + id: number + incusId: string + name: string + status: string + user: { id: number; username: string } + host: { id: number; name: string; instanceType?: string } | null + package: { id: number; name: string } | null + packagePlan: { id: number; name: string } | null + packagePlanId: number | null + billingPrice: number | null + billingCycle: number | null + expiresAt: string | null + createdAt: string + remainingDays: number | null + instanceTypeLabel: string + autoRenew: boolean + suspendedAt: string | null + suspendReason: string | null + }> + total: number + page: number + pageSize: number + hosts: Array<{ id: number; name: string }> // 有付费实例的节点列表 + }> => http.get('/admin/billing/instances', { params }), + + // 管理员封停实例 + suspendInstance: (instanceId: number, reason?: string): Promise<{ + success: boolean + message: string + }> => http.post(`/admin/instances/${instanceId}/suspend`, { reason }), + + // 管理员解封实例 + unsuspendInstance: (instanceId: number): Promise<{ + success: boolean + message: string + }> => http.post(`/admin/instances/${instanceId}/unsuspend`), + + // 管理员延期实例 + extendInstance: (instanceId: number, days: number, reason?: string, freeExtend?: boolean): Promise<{ + success: boolean + message: string + amount: number + newExpiresAt: string + }> => http.post(`/admin/instances/${instanceId}/extend`, { days, reason, freeExtend }), + + // 管理员退款 + refundInstance: (instanceId: number, amount: number, reason: string): Promise<{ + success: boolean + message: string + amount: number + maxRefundable: number + }> => http.post(`/admin/instances/${instanceId}/refund`, { amount, reason }), + + // 管理员删除并退款 + deleteAndRefundInstance: (instanceId: number, refundType: 'remaining' | 'full', reason: string, databaseOnly: boolean = false): Promise<{ + success: boolean + message: string + refundAmount: number + refundType: string + }> => http.post(`/admin/instances/${instanceId}/delete-and-refund`, { refundType, reason, databaseOnly }), + + // 为实例应用AFF优惠码 + applyAffCode: (instanceId: number, affCode: string): Promise<{ + success: boolean + message: string + discountRate: number + }> => http.post(`/admin/instances/${instanceId}/apply-aff`, { affCode }), + + // 修改实例专属价格 + updateInstancePrice: (instanceId: number, newPrice: number, settleBalance: boolean, expectedVersion?: number): Promise<{ + success: boolean + message: string + oldPrice: number + newPrice: number + priceDiff: number + remainingDays: number + billingCycle: number + }> => http.post(`/admin/instances/${instanceId}/update-price`, { newPrice, settleBalance, expectedVersion }), + + previewInstancePriceUpdate: (instanceId: number, newPrice: number, settleBalance: boolean): Promise<{ + oldPrice: number + newPrice: number + billingCycle: number + remainingDays: number + priceDiff: number + discountRate: number + userBalance: number + instanceVersion: number + }> => http.post(`/admin/instances/${instanceId}/update-price/preview`, { newPrice, settleBalance }), + + previewBatchInstancePriceUpdate: (instanceIds: number[], newPrice: number, settleBalance: boolean): Promise<{ + summary: { + selectedCount: number + validCount: number + changedCount: number + unchangedCount: number + failedCount: number + totalCharge: number + totalRefund: number + netAmount: number + } + canSubmit: boolean + items: Array<{ + id: number + name: string | null + user: { id: number; username: string; balance: number } | null + oldPrice: number | null + newPrice: number + billingCycle: number | null + remainingDays: number + priceDiff: number + discountRate: number + status: 'ready' | 'unchanged' | 'failed' + error?: string + instanceVersion?: number + }> + userImpacts: Array<{ + userId: number + username: string + balanceBefore: number + balanceAfter: number + totalCharge: number + totalRefund: number + netDiff: number + insufficientBalance: boolean + }> + }> => http.post('/admin/instances/batch-update-price/preview', { instanceIds, newPrice, settleBalance }, { timeout: TIMEOUT.MEDIUM }), + + updateBatchInstancePrice: (instanceIds: number[], newPrice: number, settleBalance: boolean, expectations?: Array<{ instanceId: number; version: number }>): Promise<{ + success: boolean + message: string + preview: { + summary: { + selectedCount: number + validCount: number + changedCount: number + unchangedCount: number + failedCount: number + totalCharge: number + totalRefund: number + netAmount: number + } + canSubmit: boolean + items: Array<{ + id: number + name: string | null + user: { id: number; username: string; balance: number } | null + oldPrice: number | null + newPrice: number + billingCycle: number | null + remainingDays: number + priceDiff: number + discountRate: number + status: 'ready' | 'unchanged' | 'failed' + error?: string + instanceVersion?: number + }> + userImpacts: Array<{ + userId: number + username: string + balanceBefore: number + balanceAfter: number + totalCharge: number + totalRefund: number + netDiff: number + insufficientBalance: boolean + }> + } + }> => http.post('/admin/instances/batch-update-price', { instanceIds, newPrice, settleBalance, expectations }, { timeout: TIMEOUT.MEDIUM }), + + // 获取可升级的方案列表 + getAvailablePlans: (instanceId: number): Promise<{ + currentPlan: { + id: number + name: string + price: number + billingCycle: number + monthlyPrice: number + cpu: number + memory: number + disk: number + } + remainingDays: number + availablePlans: Array<{ + id: number + name: string + description: string | null + price: number + billingCycle: number + monthlyPrice: number + priceDiff: number + cpu: number + memory: number + disk: number + portLimit: number + snapshotLimit: number + backupLimit: number + siteLimit: number + swapSize: number + trafficLimit: string + isSoldOut: boolean + slaGuarantee: number | null + }> + userBalance: number + }> => http.get(`/admin/instances/${instanceId}/available-plans`), + + // 升级实例方案 + upgradePlan: (instanceId: number, newPlanId: number): Promise<{ + success: boolean + message: string + priceDifference: number + oldPlan: { id: number; name: string } + newPlan: { id: number; name: string } + resourcesSynced: boolean + }> => http.post(`/admin/instances/${instanceId}/upgrade-plan`, { newPlanId }), + + // ==================== 充值记录管理 ==================== + + // 获取充值记录列表 + getRechargeRecords: (params?: { page?: number; pageSize?: number; status?: string; userId?: number }): Promise<{ + records: Array<{ + id: number + orderNo: string + userId: number + user: { id: number; username: string } + amount: number + payableAmount: number + actualAmount: number | null + fee: number + status: string + payMethod: string | null + actualPaymentMethod: string | null + paymentCurrency: string | null + paymentNetwork: string | null + paymentUuid: string | null + paymentTxid: string | null + invoiceCurrency: string | null + gatewayStatus: string | null + gatewayStatusDescription: string | null + provider: { id: number; name: string; displayName: string; type: string } | null + tradeNo: string | null + createdAt: string + paidAt: string | null + completedAt: string | null + expiresAt: string | null + }> + total: number + page: number + pageSize: number + }> => http.get('/admin/billing/recharge-records', { params }), + + // 同步充值订单状态 + syncRechargeRecord: (id: number): Promise<{ + success: boolean + synced: boolean + status: string + message: string + }> => http.post(`/admin/billing/recharge-records/${id}/sync`), + + // 获取充值订单列表 + getRechargeOrders: (params?: { page?: number; pageSize?: number; status?: string; userId?: number }): Promise<{ + records: Array<{ + id: number + orderNo: string + userId: number + amount: number + actualAmount: number | null + fee: number + status: string + provider: { id: number; name: string; type: string } | null + createdAt: string + completedAt: string | null + }> + total: number + page: number + pageSize: number + }> => http.get('/admin/recharge/orders', { params }), + + // 获取充值统计 + getRechargeStats: (): Promise<{ + stats: { + todayAmount: number + todayCount: number + thisMonthAmount: number + thisMonthCount: number + totalAmount: number + totalCount: number + } + }> => http.get('/admin/recharge/stats'), + + // 手动完成充值订单 + completeRechargeOrder: (orderNo: string, tradeNo?: string): Promise<{ + success: boolean + message: string + }> => http.post(`/admin/recharge/orders/${orderNo}/complete`, { tradeNo }), + + // 手动失败充值订单 + failRechargeOrder: (orderNo: string, reason: string): Promise<{ + success: boolean + message: string + }> => http.post(`/admin/recharge/orders/${orderNo}/fail`, { reason }), + + // ==================== 用户余额管理 ==================== + + // 获取用户余额 + getUserBalance: (userId: number): Promise<{ + userId: number + username: string + balance: number + totalRecharge: number + totalConsume: number + totalRefund: number + }> => http.get(`/balance/admin/${userId}`), + + // 获取用户余额日志 + getUserBalanceLogs: (userId: number, params?: { page?: number; pageSize?: number; type?: string; lotteryGift?: 'exclude' | 'only' }): Promise<{ + logs: Array<{ + id: number + type: string + amount: number + balanceBefore: number + balanceAfter: number + orderId: string | null + instanceId: number | null + remark: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get(`/balance/admin/${userId}/logs`, { params }), + + // 调整用户余额 + adjustUserBalance: (userId: number, amount: number, remark: string): Promise<{ + message: string + newBalance: number + balanceLog: { + id: number + type: string + amount: number + balanceBefore: number + balanceAfter: number + } | null + }> => http.post(`/balance/admin/${userId}/adjust`, { amount, remark }), + + // 赠送用户余额 + giftUserBalance: (userId: number, amount: number, remark?: string): Promise<{ + message: string + newBalance: number + }> => http.post(`/balance/admin/${userId}/gift`, { amount, remark }), + + // 获取用户托管余额明细 + getHostingBalanceLogs: (userId: number, params?: { page?: number; pageSize?: number }): Promise<{ + user: { id: number; username: string } + available: number + frozen: number + logs: Array<{ + id: number + type: string + actionType: string | null + amount: number + frozen: boolean + unfreezeAt: string | null + description: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + totalPages: number + }> => http.get(`/users/${userId}/hosting-balance/logs`, { params }), + + // 调整用户托管余额 + adjustHostingBalance: (userId: number, type: 'available' | 'frozen', amount: number, reason: string): Promise<{ + success: boolean + available: number + frozen: number + }> => http.post(`/users/${userId}/hosting-balance/adjust`, { type, amount, reason }), + + // ==================== 用户管理 ==================== + + // 获取用户列表(搜索) + getUsers: (params?: { + search?: string + searchFields?: string + exact?: boolean + page?: number + pageSize?: number + }): Promise<{ + users: Array<{ + id: number + username: string + email: string + role: string + status: string + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/users', { params }), + + // 按用户名精确查找用户 + lookupUser: (username: string): Promise<{ + user: { + id: number + username: string + email: string | null + role: string + status: string + } + }> => http.get('/users/lookup', { params: { username } }), + + // ==================== 实例管理 ==================== + + // 管理员创建实例(支持免费赠送或付费实例) + createInstance: (data: { + username: string + name: string + packageId: number + image: string + cpu?: number // 免费实例自定义配置 + memory?: number // 免费实例自定义配置 + disk?: number // 免费实例自定义配置 + hostId?: number + customInitCommandIds?: number[] + planId?: number // 付费方案ID(传入则创建付费实例) + chargeFirstMonth?: boolean // 是否扣除首月费用(默认 true) + }): Promise<{ + message: string + instance: { + id: number + name: string + incusId: string + host: string + status: string + user: { id: number; username: string } + sshPort: number + rootPassword: string + isPaid: boolean + planName: string | null + charged: boolean + amount: number + expiresAt: string | null + } + }> => http.post('/admin/instances/create', data) + }, + + // ==================== AFF 推荐计划 ==================== + aff: { + // 获取 AFF 状态和统计 + getStatus: (): Promise<{ + activated: boolean + totalEarnings: number + totalConverted: number + currentBalance: number + totalCodes: number + totalUsed: number + }> => http.get('/aff/me'), + + // 获取我的优惠码列表(包括全局码) + getCodes: (): Promise<{ + codes: Array<{ + id: number + code: string + packagePlanId: number | null + planName: string | null + packageName: string | null + price: number | null + isGlobal: boolean + discountRate: number + commissionRate: number + usedCount: number + totalEarnings: number + createdAt: string + }> + }> => http.get('/aff/me/codes'), + + // 获取可创建优惠码的方案列表(包含全局码状态) + getAvailablePlans: (): Promise<{ + plans: Array<{ + id: number + name: string + price: number + packageName: string + hasCode: boolean + }> + hasGlobalCode: boolean + }> => http.get('/aff/me/available-plans'), + + // 创建优惠码(不传 packagePlanId 则创建全局码,固定 5% 折扣/5% 返利) + createCode: (packagePlanId?: number): Promise<{ + message: string + code: { + id: number + code: string + packagePlanId: number | null + isGlobal: boolean + discountRate: number + commissionRate: number + createdAt: string + } + }> => { + const body: { packagePlanId?: number } = {} + if (packagePlanId !== undefined) body.packagePlanId = packagePlanId + return http.post('/aff/me/codes', body) + }, + + // 删除优惠码(仅允许删除使用次数为 0 的优惠码) + deleteCode: (codeId: number): Promise<{ message: string }> => + http.delete(`/aff/me/codes/${codeId}`), + + // 获取 AFF 收益日志 + getLogs: (params?: { page?: number; pageSize?: number; type?: string }): Promise<{ + logs: Array<{ + id: number + type: string + amount: number + originalAmount: number | null + balanceBefore: number + balanceAfter: number + affCodeId: number | null + affCode: string | null + instanceId: number | null + remark: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/aff/me/logs', { params }), + + // 获取 AFF 收益榜单 TOP 10 + getLeaderboard: (): Promise<{ + leaderboard: Array<{ + rank: number + username: string + totalEarnings: number + isCurrentUser: boolean + }> + }> => http.get('/aff/leaderboard'), + + // 验证优惠码 + validateCode: (code: string, packagePlanId: number): Promise<{ + valid: boolean + discountRate?: number + }> => http.post('/aff/validate', { code, packagePlanId }), + + // 创建转化申请 + createConvert: (amount: number): Promise<{ + message: string + withdrawal: { + id: number + amount: number + status: string + createdAt: string + } + }> => http.post('/aff/me/convert', { amount }), + + // 获取我的转化申请 + getWithdrawals: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{ + withdrawals: Array<{ + id: number + amount: number + status: string + rejectReason: string | null + reviewedAt: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/aff/me/withdrawals', { params }), + + // 管理员:获取所有转化申请 + adminGetWithdrawals: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{ + withdrawals: Array<{ + id: number + userId: number + username: string + userAffBalance: number + amount: number + status: string + rejectReason: string | null + reviewedBy: number | null + reviewedAt: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/aff/admin/withdrawals', { params }), + + // 管理员:审核通过 + adminApprove: (withdrawalId: number): Promise<{ message: string }> => + http.post(`/aff/admin/withdrawals/${withdrawalId}/approve`), + + // 管理员:审核拒绝 + adminReject: (withdrawalId: number, reason: string): Promise<{ message: string }> => + http.post(`/aff/admin/withdrawals/${withdrawalId}/reject`, { reason }) + }, + + // ==================== 娱乐系统 ==================== + entertainment: { + // ==================== 积分相关 ==================== + + // 获取用户积分信息 + getPoints: (): Promise<{ + points: number + totalEarned: number + totalSpent: number + lastConvertedAt: string | null + totalConsume: number + convertedConsume: number + convertibleAmount: number + convertiblePoints: number + }> => http.get('/entertainment/points'), + + // 兑换积分 + convertPoints: (): Promise<{ + success: boolean + converted: number + newPoints: number + consumeConverted: number + }> => http.post('/entertainment/points/convert'), + + // 获取积分变动日志 + getPointsLogs: (params?: { page?: number; pageSize?: number; type?: string }): Promise<{ + logs: Array<{ + id: number + type: string + amount: number + pointsBefore: number + pointsAfter: number + remark: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/entertainment/points/logs', { params }), + + // ==================== 徽章相关 ==================== + + getBadgeCatalog: (): Promise<{ + series: BadgeSeriesItem[] + badges: BadgeCatalogItem[] + }> => http.get('/entertainment/badges/catalog'), + + getBadgeOverview: (): Promise => http.get('/entertainment/badges/overview'), + + drawBadgeRandom: (): Promise<{ + success: boolean + currentPoints: number + ownership: BadgeOwnership + }> => http.post('/entertainment/badges/draw/random'), + + drawBadgeRandomMulti: (): Promise => http.post('/entertainment/badges/draw/random-multi'), + + drawBadgeSelect: (badgeId: string): Promise<{ + success: boolean + currentPoints: number + ownership: BadgeOwnership + }> => http.post('/entertainment/badges/draw/select', { badgeId }), + + applyBadgeToAvatar: (ownershipId: number): Promise<{ + success: boolean + ownership: BadgeOwnership + }> => http.post('/entertainment/badges/apply/avatar', { ownershipId }), + + applyBadgeToInstance: (ownershipId: number, instanceId: number): Promise<{ + success: boolean + ownership: BadgeOwnership + }> => http.post('/entertainment/badges/apply/instance', { ownershipId, instanceId }), + + unapplyBadge: (ownershipId: number): Promise<{ + success: boolean + ownership: BadgeOwnership + }> => http.post('/entertainment/badges/unapply', { ownershipId }), + + // ==================== 抽奖相关 ==================== + + // 获取可用抽奖列表 + getLotteries: (): Promise<{ + lotteries: Array<{ + id: number + name: string + description: string | null + costPoints: number + startAt: string | null + endAt: string | null + totalDraws: number + prizes: Array<{ + id: number + name: string + type: string + probability: number + remainQuantity: number | null + totalQuantity: number | null + displayOrder: number + instanceDesc: string | null + }> + }> + }> => http.get('/entertainment/lotteries'), + + // 获取抽奖详情 + getLottery: (id: number): Promise<{ + lottery: { + id: number + name: string + description: string | null + costPoints: number + startAt: string | null + endAt: string | null + totalDraws: number + prizes: Array<{ + id: number + name: string + type: string + probability: number + remainQuantity: number | null + totalQuantity: number | null + displayOrder: number + instanceDesc: string | null + }> + } + }> => http.get(`/entertainment/lotteries/${id}`), + + // 执行抽奖 + draw: (lotteryId: number): Promise<{ + success: boolean + currentPoints: number + record: { + id: number + prizeId: number + prizeType: string + prizeName: string + prizeValue: number + badgeOwnership: BadgeOwnership | null + status: string + pointsSpent: number + createdAt: string + } + action: string | null + message: string + }> => http.post(`/entertainment/lotteries/${lotteryId}/draw`), + + // 十连抽 + drawMulti: (lotteryId: number): Promise<{ + success: boolean + records: Array<{ + id: number + prizeId: number + prizeType: string + prizeName: string + prizeValue: number + badgeOwnership: BadgeOwnership | null + status: string + pointsSpent: number + createdAt: string + }> + totalDraws: number + totalPointsSpent?: number + stoppedAt?: number + stopReason?: string + }> => http.post(`/entertainment/lotteries/${lotteryId}/draw-multi`), + + // 获取用户中奖记录 + getLotteryRecords: (params?: { page?: number; pageSize?: number; prizeType?: string }): Promise<{ + records: Array<{ + id: number + lotteryId: number + lotteryName: string + prizeType: string + prizeName: string + prizeValue: number + instanceDesc: string | null + status: string + pointsSpent: number + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/entertainment/lottery-records', { params }), + + // ==================== 管理端 API ==================== + + // 获取所有抽奖列表 + adminGetLotteries: (params?: { page?: number; pageSize?: number; isActive?: boolean }): Promise<{ + lotteries: Array<{ + id: number + name: string + description: string | null + costPoints: number + isActive: boolean + startAt: string | null + endAt: string | null + totalDraws: number + prizesCount: number + recordsCount: number + createdAt: string + prizes: Array<{ + id: number + name: string + type: string + value: number + probability: number + totalQuantity: number | null + remainQuantity: number | null + displayOrder: number + instanceDesc: string | null + }> + }> + total: number + page: number + pageSize: number + }> => http.get('/admin/entertainment/lotteries', { params }), + + // 创建抽奖 + adminCreateLottery: (data: { + name: string + description?: string + costPoints: number + isActive?: boolean + startAt?: string + endAt?: string + }): Promise<{ success: boolean; lottery: { id: number; name: string } }> => + http.post('/admin/entertainment/lotteries', data), + + // 更新抽奖 + adminUpdateLottery: (id: number, data: { + name?: string + description?: string + costPoints?: number + isActive?: boolean + startAt?: string | null + endAt?: string | null + }): Promise<{ success: boolean }> => + http.put(`/admin/entertainment/lotteries/${id}`, data), + + // 删除抽奖 + adminDeleteLottery: (id: number): Promise<{ success: boolean }> => + http.delete(`/admin/entertainment/lotteries/${id}`), + + // 获取抽奖详情 + adminGetLottery: (id: number): Promise<{ + lottery: { + id: number + name: string + description: string | null + costPoints: number + isActive: boolean + startAt: string | null + endAt: string | null + totalDraws: number + createdAt: string + prizes: Array<{ + id: number + name: string + type: string + value: number + probability: number + totalQuantity: number | null + remainQuantity: number | null + displayOrder: number + instanceDesc: string | null + }> + notificationConfig: { + enabled: boolean + type: string + config: Record + notifyBalance: boolean + notifyInstance: boolean + } | null + stats: { + totalDraws: number + prizeStats: Array<{ type: string; count: number }> + } + } + }> => http.get(`/admin/entertainment/lotteries/${id}`), + + // 添加奖品 + adminCreatePrize: (lotteryId: number, data: { + name: string + type: string + value?: number + probability: number + totalQuantity?: number + displayOrder?: number + instanceDesc?: string + }): Promise<{ success: boolean; prize: { id: number; name: string } }> => + http.post(`/admin/entertainment/lotteries/${lotteryId}/prizes`, data), + + // 更新奖品 + adminUpdatePrize: (prizeId: number, data: { + name?: string + type?: string + value?: number + probability?: number + totalQuantity?: number | null + remainQuantity?: number | null + displayOrder?: number + instanceDesc?: string | null + }): Promise<{ success: boolean }> => + http.put(`/admin/entertainment/prizes/${prizeId}`, data), + + // 删除奖品 + adminDeletePrize: (prizeId: number): Promise<{ success: boolean }> => + http.delete(`/admin/entertainment/prizes/${prizeId}`), + + // 更新抽奖通知配置 + adminUpdateNotification: (lotteryId: number, data: { + enabled: boolean + type: string + config: Record + notifyBalance: boolean + notifyInstance: boolean + }): Promise<{ success: boolean }> => + http.put(`/admin/entertainment/lotteries/${lotteryId}/notification`, data), + + adminGetBadgeCatalog: (): Promise<{ + series: BadgeSeriesItem[] + badges: BadgeCatalogItem[] + }> => http.get('/admin/entertainment/badges/catalog'), + + adminCreateBadgeSeries: (data: { + id: string + title: string + nameZh: string + nameEn?: string | null + description: string + sourceId?: string | null + sourceLabel?: string | null + displayOrder?: number + isActive?: boolean + }): Promise<{ success: boolean; series: BadgeSeriesItem }> => + http.post('/admin/entertainment/badges/series', data), + + adminUpdateBadgeSeries: (id: string, data: { + title?: string + nameZh?: string + nameEn?: string | null + description?: string + sourceId?: string | null + sourceLabel?: string | null + displayOrder?: number + isActive?: boolean + }): Promise<{ success: boolean }> => + http.put(`/admin/entertainment/badges/series/${id}`, data), + + adminDeleteBadgeSeries: (id: string): Promise<{ success: boolean }> => + http.delete(`/admin/entertainment/badges/series/${id}`), + + adminCreateBadge: (data: { + id: string + name: string + nameEn?: string | null + fullLabel: string + seriesId: string + sourceId?: string | null + sourceLabel?: string | null + assetUrl: string + assetUrlDark?: string | null + assetUrlLight?: string | null + displayOrder?: number + isActive?: boolean + }): Promise<{ success: boolean; badge: BadgeCatalogItem }> => + http.post('/admin/entertainment/badges', data), + + adminUpdateBadge: (id: string, data: { + name?: string + nameEn?: string | null + fullLabel?: string + seriesId?: string + sourceId?: string | null + sourceLabel?: string | null + assetUrl?: string + assetUrlDark?: string | null + assetUrlLight?: string | null + displayOrder?: number + isActive?: boolean + }): Promise<{ success: boolean }> => + http.put(`/admin/entertainment/badges/${id}`, data), + + adminDeleteBadge: (id: string): Promise<{ success: boolean }> => + http.delete(`/admin/entertainment/badges/${id}`), + + // 获取所有中奖记录 + adminGetLotteryRecords: (params?: { + page?: number + pageSize?: number + lotteryId?: number + prizeType?: string + status?: string + search?: string + }): Promise<{ + records: Array<{ + id: number + lotteryId: number + lotteryName: string + userId: number + username: string + userAvatar: string + prizeType: string + prizeName: string + prizeValue: number + status: string + pointsSpent: number + deliveredAt: string | null + deliveredBy: number | null + ticketId: number | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/admin/entertainment/lottery-records', { params }), + + // 标记实例奖励为已发放 + adminDeliverPrize: (recordId: number, ticketId?: number): Promise<{ success: boolean }> => + http.post(`/admin/entertainment/lottery-records/${recordId}/deliver`, { ticketId }), + + // 获取所有用户积分列表 + adminGetUserPoints: (params?: { + page?: number + pageSize?: number + search?: string + orderBy?: string + order?: string + }): Promise<{ + records: Array<{ + userId: number + username: string + userAvatar: string + points: number + totalEarned: number + totalSpent: number + convertedConsume: number + lastConvertedAt: string | null + updatedAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/admin/entertainment/user-points', { params }), + + // 调整用户积分 + adminAdjustPoints: (userId: number, amount: number, remark: string): Promise<{ + success: boolean + newPoints: number + }> => http.post(`/admin/entertainment/user-points/${userId}/adjust`, { amount, remark }), + + // 获取用户积分详情 + adminGetUserPointsDetail: (userId: number): Promise<{ + user: { id: number; username: string; avatarStyle: string; avatarBadgeId?: string | null } + points: number + totalEarned: number + totalSpent: number + convertedConsume: number + lastConvertedAt: string | null + totalConsume: number + convertibleAmount: number + convertiblePoints: number + }> => http.get(`/admin/entertainment/user-points/${userId}`), + + // 获取用户积分日志 + adminGetUserPointsLogs: (userId: number, params?: { page?: number; pageSize?: number }): Promise<{ + logs: Array<{ + id: number + type: string + amount: number + pointsBefore: number + pointsAfter: number + remark: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get(`/admin/entertainment/user-points/${userId}/logs`, { params }) + }, + + // ==================== 托管余额 API ==================== + hosting: { + // 检查托管准入条件 + checkAccess: (): Promise<{ + allowed: boolean + reason?: string + details?: { + instanceCount: number + hasCreatedHostBefore?: boolean + featureEnabled?: boolean + hiddenBySystemSetting?: boolean + } + }> => http.get('/hosting/access-check'), + + // 获取托管余额 + getBalance: (): Promise<{ + balance: { + available: number + frozen: number + pendingWithdrawal: number + totalIncome: number + totalWithdrawn: number + } + config: { + minWithdrawalAmount: number + feeRateBalance: number + } + }> => http.get('/hosting/balance'), + + // 获取托管统计 + getStats: (): Promise<{ + stats: { + myHostsCount: number + instancesOnMyHosts: number + uniqueCustomersCount: number + monthIncome: number + vipLevel: number + vipBadgeStyle?: VipBadgeStyle | null + } + recentIncome: Array<{ + id: number + amount: number + instanceId: number | null + remark: string | null + createdAt: string + }> + }> => http.get('/hosting/stats'), + + // 获取托管余额日志 + getLogs: (params?: { page?: number; pageSize?: number; actionType?: string; frozen?: string; search?: string }): Promise<{ + logs: Array<{ + id: number + type: string + actionType: string | null + amount: number + frozen: boolean + unfreezeAt: string | null + relatedId: number | null + remark: string | null + createdAt: string + instance: { + id: number + name: string + buyer: { + id: number + username: string + email: string | null + avatarStyle: string + avatarBadgeId?: string | null + } + host: { + id: number + name: string + } + package: { + id: number + name: string + } | null + plan: { + id: number + name: string + } | null + } | null + }> + total: number + page: number + pageSize: number + }> => http.get('/hosting/logs', { params }), + + // 申请提现 + withdraw: (data: { amount: number }): Promise<{ + message: string + withdrawal: { + id: number + amount: number + feeRate: number + feeAmount: number + actualAmount: number + target: string + status: string + createdAt: string + } + }> => http.post('/hosting/withdraw', data), + + // 获取提现记录 + getWithdrawals: (params?: { page?: number; pageSize?: number; status?: string }): Promise<{ + records: Array<{ + id: number + amount: number + feeRate: number + feeAmount: number + actualAmount: number + target: string + status: string + rejectReason: string | null + processedAt: string | null + createdAt: string + }> + total: number + page: number + pageSize: number + }> => http.get('/hosting/withdrawals', { params }), + + getBlocks: (): Promise<{ + blocks: Array<{ + id: number + blockedUserId: number + username: string + email: string | null + avatarStyle: string + avatarBadgeId?: string | null + remark: string | null + createdAt: string + }> + }> => http.get('/hosting/blocks'), + + searchBlockUsers: (keyword: string): Promise<{ + users: Array<{ + id: number + username: string + email: string | null + avatarStyle: string + avatarBadgeId?: string | null + blocked: boolean + }> + }> => http.get('/hosting/blocks/search', { params: { keyword } }), + + blockUser: (data: { userId: number; remark?: string | null }): Promise<{ + block: { + id: number + blockedUserId: number + username: string + email: string | null + avatarStyle: string + avatarBadgeId?: string | null + remark: string | null + createdAt: string + } + }> => http.post('/hosting/blocks', data), + + unblockUser: (userId: number): Promise<{ success: boolean }> => + http.delete(`/hosting/blocks/${userId}`) + }, + + // ==================== VIP 等级 API ==================== + vipLevels: { + getMyOverview: (): Promise => http.get('/vip-levels/me') + }, + + // ==================== VIP 福利 API ==================== + vipBenefits: { + getMyOverview: (): Promise => http.get('/vip-benefits/me'), + claim: (rewardId: number): Promise<{ + claim: VipBenefitClaim + overview: VipBenefitOverviewResponse + }> => http.post(`/vip-benefits/${rewardId}/claim`), + claimAll: (): Promise<{ + claims: VipBenefitClaim[] + overview: VipBenefitOverviewResponse + }> => http.post('/vip-benefits/claim-all') + }, + + // 域名邮箱 + mail: { + // 获取可购买的邮箱源和方案 + getSources: (): Promise<{ + sources: Array<{ + id: number + name: string + code: string + plans: Array<{ + id: number + name: string + description: string | null + domainLimit: number + diskLimitGb: number + billingCycle: 'monthly' | 'yearly' + price: number + }> + }> + }> => http.get('/mail/sources'), + + // 获取我的订阅 + getSubscription: (): Promise<{ + subscription: { + id: number + status: 'active' | 'expired' | 'suspended' + expiresAt: string + autoRenew: boolean + source: { id: number; name: string; code: string } + plan: { + id: number + name: string + domainLimit: number + diskLimitGb: number + billingCycle: 'monthly' | 'yearly' + price: number + } + usage: { + domainCount: number + accountCount: number + diskUsedGb: number + } + domains: Array<{ + id: number + domain: string + status: 'pending' | 'verified' | 'suspended' + accountCount: number + diskUsedMb: number + createdAt: string + }> + } | null + }> => http.get('/mail/subscription'), + + // 购买订阅 + purchaseSubscription: (planId: number, affCode?: string): Promise<{ + subscription: { + id: number + status: string + expiresAt: string + source: { id: number; name: string } + plan: { id: number; name: string } + } + discountApplied?: boolean + discountAmount?: number + finalPrice?: number + }> => http.post('/mail/subscription', { planId, affCode }), + + // 验证优惠码 + validateAffCode: (code: string): Promise<{ + valid: boolean + discountRate?: number + commissionRate?: number + error?: string + }> => http.post('/mail/validate-aff', { code }), + + // 续费订阅 + renewSubscription: (months: number): Promise<{ + expiresAt: string + discountApplied?: boolean + discountAmount?: number + finalPrice?: number + }> => + http.post('/mail/subscription/renew', { months }), + + // 获取域名列表 + getDomains: (): Promise<{ + domains: Array<{ + id: number + domain: string + status: 'pending' | 'verified' | 'suspended' + accountCount: number + diskUsedMb: number + verifiedAt: string | null + createdAt: string + }> + }> => http.get('/mail/domains'), + + // 获取域名详情 + getDomain: (id: number): Promise<{ + domain: { + id: number + domain: string + status: 'pending' | 'verified' | 'suspended' + diskUsedMb: number + verifiedAt: string | null + createdAt: string + accounts: Array<{ + id: number + email: string + username: string + displayName: string | null + diskLimitMb: number + diskUsedMb: number + isAdmin: boolean + createdAt: string + }> + } + }> => http.get(`/mail/domains/${id}`), + + // 添加域名 + addDomain: (domain: string): Promise<{ + domain: { + id: number + domain: string + status: string + createdAt: string + } + }> => http.post('/mail/domains', { domain }), + + // 刷新域名验证状态 + verifyDomain: (id: number): Promise<{ + status: string + verified: boolean + txtRecord?: string + }> => http.post(`/mail/domains/${id}/verify`), + + // 获取域名 DNS 配置 + getDomainDns: (id: number): Promise<{ + verified: boolean + txtRecord?: string + dnsRecords?: Array<{ type: string; record: string; value: string; prio?: number }> + mxRecords?: string[] + spfRecord?: string + dkimRecord?: string + cnameRecords?: Array<{ record: string; value: string }> + }> => http.get(`/mail/domains/${id}/dns`), + + // 删除域名 + deleteDomain: (id: number): Promise<{ success: boolean }> => + http.delete(`/mail/domains/${id}`), + + // 获取邮箱账户列表 + getAccounts: (domainId: number): Promise<{ + accounts: Array<{ + id: number + email: string + username: string + displayName: string | null + diskLimitMb: number + diskUsedMb: number + isAdmin: boolean + createdAt: string + }> + }> => http.get(`/mail/domains/${domainId}/accounts`), + + // 创建邮箱账户 + createAccount: (domainId: number, data: { + username: string + password: string + displayName?: string + diskLimitMb?: number + isAdmin?: boolean + }): Promise<{ + account: { + id: number + email: string + username: string + displayName: string | null + diskLimitMb: number + isAdmin: boolean + } + }> => http.post(`/mail/domains/${domainId}/accounts`, data), + + // 更新邮箱账户 + updateAccount: (domainId: number, accountId: number, data: { + displayName?: string + diskLimitMb?: number + }): Promise<{ + account: { + id: number + email: string + displayName: string | null + diskLimitMb: number + } + }> => http.put(`/mail/domains/${domainId}/accounts/${accountId}`, data), + + // 重置邮箱账户密码 + resetAccountPassword: (domainId: number, accountId: number, password: string): Promise<{ success: boolean }> => + http.post(`/mail/domains/${domainId}/accounts/${accountId}/reset-password`, { password }), + + // 删除邮箱账户 + deleteAccount: (domainId: number, accountId: number): Promise<{ success: boolean }> => + http.delete(`/mail/domains/${domainId}/accounts/${accountId}`), + + // ==================== 管理员 API ==================== + + // 获取所有邮箱源 + adminGetSources: (): Promise<{ + sources: Array<{ + id: number + name: string + code: string + apiUrl: string + apiKey: string + smarterMailUrl: string + enabled: boolean + sortOrder: number + planCount: number + subscriptionCount: number + domainCount: number + createdAt: string + updatedAt: string + }> + }> => http.get('/mail/admin/sources'), + + // 创建邮箱源 + adminCreateSource: (data: { + name: string + code: string + apiUrl: string + apiKey: string + smarterMailUrl: string + enabled?: boolean + sortOrder?: number + }): Promise<{ source: any }> => http.post('/mail/admin/sources', data), + + // 更新邮箱源 + adminUpdateSource: (id: number, data: { + name?: string + code?: string + apiUrl?: string + apiKey?: string + smarterMailUrl?: string + enabled?: boolean + sortOrder?: number + }): Promise<{ source: any }> => http.put(`/mail/admin/sources/${id}`, data), + + // 删除邮箱源 + adminDeleteSource: (id: number): Promise<{ success: boolean }> => + http.delete(`/mail/admin/sources/${id}`), + + // 获取所有方案 + adminGetPlans: (): Promise<{ + plans: Array<{ + id: number + sourceId: number + name: string + description: string | null + domainLimit: number + diskLimitGb: number + billingCycle: 'monthly' | 'yearly' + price: string + enabled: boolean + sortOrder: number + source: { id: number; name: string; code: string } + createdAt: string + updatedAt: string + }> + }> => http.get('/mail/admin/plans'), + + // 创建方案 + adminCreatePlan: (data: { + sourceId: number + name: string + description?: string + domainLimit: number + diskLimitGb: number + billingCycle: 'monthly' | 'yearly' + price: number + enabled?: boolean + sortOrder?: number + }): Promise<{ plan: any }> => http.post('/mail/admin/plans', data), + + // 更新方案 + adminUpdatePlan: (id: number, data: { + name?: string + description?: string + domainLimit?: number + diskLimitGb?: number + billingCycle?: 'monthly' | 'yearly' + price?: number + enabled?: boolean + sortOrder?: number + }): Promise<{ plan: any }> => http.put(`/mail/admin/plans/${id}`, data), + + // 删除方案 + adminDeletePlan: (id: number): Promise<{ success: boolean }> => + http.delete(`/mail/admin/plans/${id}`), + + // 获取所有订阅 + adminGetSubscriptions: (params?: { + sourceId?: number + status?: string + search?: string + page?: number + pageSize?: number + }): Promise<{ + subscriptions: Array + total: number + }> => http.get('/mail/admin/subscriptions', { params }), + + // 获取所有域名 + adminGetDomains: (params?: { + sourceId?: number + status?: string + search?: string + page?: number + pageSize?: number + }): Promise<{ + domains: Array + total: number + }> => http.get('/mail/admin/domains', { params }), + + // 管理员退订 + adminCancelSubscription: (id: number, data: { + refundType: 'none' | 'full' | 'remaining' + reason?: string + }): Promise<{ + success: boolean + refundAmount: number + refundType: string + }> => http.post(`/mail/admin/subscriptions/${id}/cancel`, data) + } +} + +export default api + +// 导出各模块方便单独使用 +export const authApi = api.auth +export const usersApi = api.users +export const instancesApi = api.instances +export const transfersApi = api.transfers diff --git a/client/src/components/BackupManager.vue b/client/src/components/BackupManager.vue new file mode 100644 index 0000000..7e7d49c --- /dev/null +++ b/client/src/components/BackupManager.vue @@ -0,0 +1,996 @@ + + + + diff --git a/client/src/components/BadgeImage.vue b/client/src/components/BadgeImage.vue new file mode 100644 index 0000000..55c9856 --- /dev/null +++ b/client/src/components/BadgeImage.vue @@ -0,0 +1,61 @@ + + + diff --git a/client/src/components/CheckinModal.vue b/client/src/components/CheckinModal.vue new file mode 100644 index 0000000..82e38a4 --- /dev/null +++ b/client/src/components/CheckinModal.vue @@ -0,0 +1,841 @@ + + + + + diff --git a/client/src/components/FlagIcon.vue b/client/src/components/FlagIcon.vue new file mode 100644 index 0000000..ffd0814 --- /dev/null +++ b/client/src/components/FlagIcon.vue @@ -0,0 +1,37 @@ + + + diff --git a/client/src/components/InstanceDisplayIcon.vue b/client/src/components/InstanceDisplayIcon.vue new file mode 100644 index 0000000..1b2504c --- /dev/null +++ b/client/src/components/InstanceDisplayIcon.vue @@ -0,0 +1,32 @@ + + + diff --git a/client/src/components/InstanceSelector.vue b/client/src/components/InstanceSelector.vue new file mode 100644 index 0000000..17e3c31 --- /dev/null +++ b/client/src/components/InstanceSelector.vue @@ -0,0 +1,193 @@ + + + diff --git a/client/src/components/LazyImage.vue b/client/src/components/LazyImage.vue new file mode 100644 index 0000000..53cd34f --- /dev/null +++ b/client/src/components/LazyImage.vue @@ -0,0 +1,92 @@ + + + diff --git a/client/src/components/LoadingSpinner.vue b/client/src/components/LoadingSpinner.vue new file mode 100644 index 0000000..e864aec --- /dev/null +++ b/client/src/components/LoadingSpinner.vue @@ -0,0 +1,61 @@ + + + diff --git a/client/src/components/NotificationBell.vue b/client/src/components/NotificationBell.vue new file mode 100644 index 0000000..e5f77d5 --- /dev/null +++ b/client/src/components/NotificationBell.vue @@ -0,0 +1,272 @@ + + + diff --git a/client/src/components/PackageQuotaReleaseModal.vue b/client/src/components/PackageQuotaReleaseModal.vue new file mode 100644 index 0000000..96bc0c8 --- /dev/null +++ b/client/src/components/PackageQuotaReleaseModal.vue @@ -0,0 +1,414 @@ + + + diff --git a/client/src/components/PopupAnnouncementModal.vue b/client/src/components/PopupAnnouncementModal.vue new file mode 100644 index 0000000..4fa9c3f --- /dev/null +++ b/client/src/components/PopupAnnouncementModal.vue @@ -0,0 +1,414 @@ + + + diff --git a/client/src/components/SensitiveVerificationModal.vue b/client/src/components/SensitiveVerificationModal.vue new file mode 100644 index 0000000..62e8c9e --- /dev/null +++ b/client/src/components/SensitiveVerificationModal.vue @@ -0,0 +1,270 @@ + + + diff --git a/client/src/components/Skeleton.vue b/client/src/components/Skeleton.vue new file mode 100644 index 0000000..aba00d0 --- /dev/null +++ b/client/src/components/Skeleton.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/client/src/components/SkeletonLoader.vue b/client/src/components/SkeletonLoader.vue new file mode 100644 index 0000000..cea9835 --- /dev/null +++ b/client/src/components/SkeletonLoader.vue @@ -0,0 +1,306 @@ + + + diff --git a/client/src/components/SnapshotManager.vue b/client/src/components/SnapshotManager.vue new file mode 100644 index 0000000..55331dd --- /dev/null +++ b/client/src/components/SnapshotManager.vue @@ -0,0 +1,442 @@ + + + + diff --git a/client/src/components/TermsOfServiceModal.vue b/client/src/components/TermsOfServiceModal.vue new file mode 100644 index 0000000..84e6c86 --- /dev/null +++ b/client/src/components/TermsOfServiceModal.vue @@ -0,0 +1,282 @@ + + + + + + + diff --git a/client/src/components/Toast.vue b/client/src/components/Toast.vue new file mode 100644 index 0000000..0a475c9 --- /dev/null +++ b/client/src/components/Toast.vue @@ -0,0 +1,86 @@ + + + + diff --git a/client/src/components/ToastContainer.vue b/client/src/components/ToastContainer.vue new file mode 100644 index 0000000..8e13049 --- /dev/null +++ b/client/src/components/ToastContainer.vue @@ -0,0 +1,23 @@ + + + + diff --git a/client/src/components/TurnstileWidget.vue b/client/src/components/TurnstileWidget.vue new file mode 100644 index 0000000..8780e9c --- /dev/null +++ b/client/src/components/TurnstileWidget.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/client/src/components/UserAvatar.vue b/client/src/components/UserAvatar.vue new file mode 100644 index 0000000..3553006 --- /dev/null +++ b/client/src/components/UserAvatar.vue @@ -0,0 +1,131 @@ + + + diff --git a/client/src/components/VirtualList.vue b/client/src/components/VirtualList.vue new file mode 100644 index 0000000..b1af552 --- /dev/null +++ b/client/src/components/VirtualList.vue @@ -0,0 +1,130 @@ + + + diff --git a/client/src/components/admin/BillingOverviewIcon.vue b/client/src/components/admin/BillingOverviewIcon.vue new file mode 100644 index 0000000..fd0066b --- /dev/null +++ b/client/src/components/admin/BillingOverviewIcon.vue @@ -0,0 +1,181 @@ + + + diff --git a/client/src/components/admin/VipBenefitHallSettings.vue b/client/src/components/admin/VipBenefitHallSettings.vue new file mode 100644 index 0000000..6296dde --- /dev/null +++ b/client/src/components/admin/VipBenefitHallSettings.vue @@ -0,0 +1,486 @@ + + + diff --git a/client/src/components/admin/VipLevelRulesEditor.vue b/client/src/components/admin/VipLevelRulesEditor.vue new file mode 100644 index 0000000..d93d62a --- /dev/null +++ b/client/src/components/admin/VipLevelRulesEditor.vue @@ -0,0 +1,399 @@ + + + diff --git a/client/src/components/entertainment/BadgeBatchRewardModal.vue b/client/src/components/entertainment/BadgeBatchRewardModal.vue new file mode 100644 index 0000000..af7e585 --- /dev/null +++ b/client/src/components/entertainment/BadgeBatchRewardModal.vue @@ -0,0 +1,103 @@ + + + diff --git a/client/src/components/entertainment/BadgeCenter.vue b/client/src/components/entertainment/BadgeCenter.vue new file mode 100644 index 0000000..be0890f --- /dev/null +++ b/client/src/components/entertainment/BadgeCenter.vue @@ -0,0 +1,609 @@ + + + diff --git a/client/src/components/entertainment/BadgeRewardModal.vue b/client/src/components/entertainment/BadgeRewardModal.vue new file mode 100644 index 0000000..43f5295 --- /dev/null +++ b/client/src/components/entertainment/BadgeRewardModal.vue @@ -0,0 +1,130 @@ + + + diff --git a/client/src/components/entertainment/VipBenefitHall.vue b/client/src/components/entertainment/VipBenefitHall.vue new file mode 100644 index 0000000..cae0846 --- /dev/null +++ b/client/src/components/entertainment/VipBenefitHall.vue @@ -0,0 +1,453 @@ + + + + + diff --git a/client/src/components/extensions/InitCommandDetailModal.vue b/client/src/components/extensions/InitCommandDetailModal.vue new file mode 100644 index 0000000..0889b70 --- /dev/null +++ b/client/src/components/extensions/InitCommandDetailModal.vue @@ -0,0 +1,227 @@ + + + diff --git a/client/src/components/extensions/InitCommandModal.vue b/client/src/components/extensions/InitCommandModal.vue new file mode 100644 index 0000000..ab7ebbe --- /dev/null +++ b/client/src/components/extensions/InitCommandModal.vue @@ -0,0 +1,325 @@ + + + diff --git a/client/src/components/extensions/InitCommandSelector.vue b/client/src/components/extensions/InitCommandSelector.vue new file mode 100644 index 0000000..f8f0bef --- /dev/null +++ b/client/src/components/extensions/InitCommandSelector.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/client/src/components/host/BatchConfigModal.vue b/client/src/components/host/BatchConfigModal.vue new file mode 100644 index 0000000..2367858 --- /dev/null +++ b/client/src/components/host/BatchConfigModal.vue @@ -0,0 +1,793 @@ + + + diff --git a/client/src/components/host/HostCaddyTab.vue b/client/src/components/host/HostCaddyTab.vue new file mode 100644 index 0000000..8953ff8 --- /dev/null +++ b/client/src/components/host/HostCaddyTab.vue @@ -0,0 +1,595 @@ + + + diff --git a/client/src/components/host/HostCreateInstanceTab.vue b/client/src/components/host/HostCreateInstanceTab.vue new file mode 100644 index 0000000..c5dd90a --- /dev/null +++ b/client/src/components/host/HostCreateInstanceTab.vue @@ -0,0 +1,714 @@ + + + diff --git a/client/src/components/host/HostImagesTab.vue b/client/src/components/host/HostImagesTab.vue new file mode 100644 index 0000000..c12454b --- /dev/null +++ b/client/src/components/host/HostImagesTab.vue @@ -0,0 +1,294 @@ + + + diff --git a/client/src/components/host/HostInfoTab.vue b/client/src/components/host/HostInfoTab.vue new file mode 100644 index 0000000..ac112c7 --- /dev/null +++ b/client/src/components/host/HostInfoTab.vue @@ -0,0 +1,1039 @@ + + + diff --git a/client/src/components/host/HostInstancesList.vue b/client/src/components/host/HostInstancesList.vue new file mode 100644 index 0000000..bd5e043 --- /dev/null +++ b/client/src/components/host/HostInstancesList.vue @@ -0,0 +1,329 @@ + + + diff --git a/client/src/components/host/HostInstancesTab.vue b/client/src/components/host/HostInstancesTab.vue new file mode 100644 index 0000000..1d26128 --- /dev/null +++ b/client/src/components/host/HostInstancesTab.vue @@ -0,0 +1,2034 @@ + + +