first commit

This commit is contained in:
qwer-xyz
2026-06-20 14:22:31 +08:00
commit c2498911ab
793 changed files with 291660 additions and 0 deletions
+56
View File
@@ -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/
+185
View File
@@ -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 IP31.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 中配置。
+14
View File
@@ -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
+190
View File
@@ -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
+81
View File
@@ -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
+65
View File
@@ -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
+257
View File
@@ -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: 重新构建 esbuildVite 构建需要)
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
+93
View File
@@ -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/
+85
View File
@@ -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"]
+28
View File
@@ -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.
+194
View File
@@ -0,0 +1,194 @@
<h1 align="center"><img src="./client/public/incudal_logo.webp" width="100" align="absmiddle" alt="Incudal logo"> Incudal</h1>
<p align="center">基于 Incus 的 LXC / KVM NAT VPS 销售、交付与管理面板。</p>
## 项目简介
Incudal 基于 Incus 的 NAT VPS 销售与管理面板。
项目支持 LXC / KVM 实例、套餐与镜像管理、账务计费、节点托管、用户后台、管理员后台以及宿主机 Agent。
> 演示站:https://demo.incudal.com<br/>
> <strong>仅供学习与参考,本项目存在诸多不完善之处。有任何问题建议 Fork 后使用 AI 解决。</strong><br/>
## 主要功能
- 实例交付:基于 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`
+104
View File
@@ -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
```
+4
View File
@@ -0,0 +1,4 @@
/incudal-agent
/dist/*
*.test
coverage.out
+174
View File
@@ -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://<panel>/api/agent/binary/incudal-agent-linux-amd64?v=v1.0.1",
"sha256": "<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://<panel>/api/agent/binary/incudal-agent-linux-amd64
https://<panel>/api/agent/binary/incudal-agent-linux-arm64
```
默认下载会先读取面板的 manifest:
```text
https://<panel>/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
```
+1
View File
@@ -0,0 +1 @@
v0.0.1
+110
View File
@@ -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)
}()
}
+8
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module incudal-agent
go 1.19
+158
View File
@@ -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
}
+51
View File
@@ -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
}
+119
View File
@@ -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
}
+52
View File
@@ -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
}
+49
View File
@@ -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")
}
}
+362
View File
@@ -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
}
+298
View File
@@ -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
}
+166
View File
@@ -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
}
+400
View File
@@ -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
}
+230
View File
@@ -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[:])
}
+82
View File
@@ -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" <<EOF_MANIFEST
{
"version": "${VERSION}",
"generatedAt": "${GENERATED_AT}",
"files": {
"linux-amd64": {
"name": "incudal-agent-linux-amd64.gz",
"sha256": "$(sha256_file "${AMD64_GZ}")",
"size": $(size_file "${AMD64_GZ}"),
"gzip": true
},
"linux-arm64": {
"name": "incudal-agent-linux-arm64.gz",
"sha256": "$(sha256_file "${ARM64_GZ}")",
"size": $(size_file "${ARM64_GZ}"),
"gzip": true
}
}
}
EOF_MANIFEST
ls -lh "${DIST_DIR}"/incudal-agent-linux-* "${DIST_DIR}/manifest.json"
+106
View File
@@ -0,0 +1,106 @@
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
import tseslint from '@typescript-eslint/eslint-plugin'
import tsparser from '@typescript-eslint/parser'
export default [
// 忽略文件
{
ignores: [
'dist/**',
'node_modules/**',
'*.config.js',
'*.config.ts',
'vite.config.ts'
]
},
// JavaScript/TypeScript 文件配置
js.configs.recommended,
// Vue 文件配置
...vue.configs['flat/recommended'],
// TypeScript 文件配置
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
'@typescript-eslint': tseslint
},
rules: {
// TypeScript 规则(放宽限制,因为项目中有很多 API 响应使用 any)
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}],
'@typescript-eslint/no-explicit-any': 'off', // 允许使用 any(API 响应等场景需要)
'no-undef': 'off', // TypeScript 会处理
'no-unused-vars': 'off', // 使用 TypeScript 版本
'no-useless-escape': 'error' // 保留这个错误检查
}
},
// Vue 文件配置(包含 TypeScript
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: tsparser // 使用 TypeScript 解析器解析 <script lang="ts">
}
},
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'
}
}
]
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="精选全球多节点 LXC / KVM 套餐,配置丰富、方案齐全,持续提供高性价比 NAT VPS 选择。">
<meta name="keywords" content="Incus, NAT VPS, LXC, KVM, VPS 面板, 云服务器, NAT云主机">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#0a0a0a">
<meta property="og:site_name" content="Incus">
<meta property="og:type" content="website">
<meta property="og:title" content="基于 Incus 的低价 NAT VPS 平台">
<meta property="og:description" content="精选全球多节点 LXC / KVM 套餐,配置丰富、方案齐全,持续提供高性价比 NAT VPS 选择。">
<meta property="og:image" content="/incudal_logo.webp">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="基于 Incus 的低价 NAT VPS 平台">
<meta name="twitter:description" content="精选全球多节点 LXC / KVM 套餐,配置丰富、方案齐全,持续提供高性价比 NAT VPS 选择。">
<meta name="twitter:image" content="/incudal_logo.webp">
<link rel="icon" type="image/webp" href="/incudal_logo.webp">
<link rel="apple-touch-icon" href="/incudal_logo.webp">
<title>基于 Incus 的低价 NAT VPS 平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<script>
(function () {
const theme = localStorage.getItem('theme') || 'system';
let resolved = theme;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.classList.add(resolved);
document.querySelector('meta[name="theme-color"]').setAttribute('content', resolved === 'dark' ? '#0a0a0a' : '#ffffff');
})();
</script>
<style>
html.dark body {
background: #0a0a0a;
}
html.light body {
background: #ffffff;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+54
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More