first commit
@@ -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/
|
||||
@@ -0,0 +1,185 @@
|
||||
|
||||
根据分析 `docker-compose.yml`、`.env.example` 和代码中使用的环境变量,以下是完整的环境变量清单:
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose 部署环境变量
|
||||
|
||||
### 必需变量(必须配置)
|
||||
|
||||
| 变量名 | 说明 | 示例值 | 生成方式 |
|
||||
|--------|------|--------|----------|
|
||||
| `POSTGRES_PASSWORD` | PostgreSQL 数据库密码 | `BJ4hbYQTLsz97zzJUGg8RFiq` | `openssl rand -base64 24` |
|
||||
| `JWT_SECRET` | JWT 签名密钥(≥32字符,至少 3 类字符) | `A1!b7e9c4d0...` | `printf 'A1!%s\n' "$(openssl rand -hex 48)" \| cut -c1-64` |
|
||||
| `ENCRYPTION_KEY` | 敏感数据加密密钥(≥32字符,需稳定保存) | `XpPc3tcVSmy4/...` | `openssl rand -base64 32` |
|
||||
|
||||
---
|
||||
|
||||
### 可选变量(有默认值)
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `POSTGRES_USER` | 数据库用户名 | `incudal` |
|
||||
| `POSTGRES_DB` | 数据库名称 | `incudal` |
|
||||
| `REDIS_PASSWORD` | Redis 密码 | 空(无密码) |
|
||||
| `APP_PORT` | 外部暴露端口 | `3000` |
|
||||
| `ADMIN_PASSWORD` | 首次初始化管理员密码 | `admin123` |
|
||||
| `LOG_LEVEL` | 日志级别 | `info` |
|
||||
| `DISABLE_REQUEST_LOG` | 禁用请求日志 | `true` |
|
||||
| `COOKIE_SECRET` | Cookie 签名密钥 | 空 |
|
||||
| `FRONTEND_URL` | 前端 URL(CORS 白名单 + 页面跳转) | 空 |
|
||||
| `PAYMENT_CALLBACK_BASE_URL` | 支付回调使用的后端公网 URL | 空(默认回退到 `FRONTEND_URL`) |
|
||||
| `PAYMENT_CALLBACK_IP_WHITELIST` | 支付回调来源 IP 白名单(逗号分隔,留空时 Heleket 默认收紧到官方 IP) | 空 |
|
||||
| `PAYMENT_CALLBACK_SKIP_IP_WHITELIST` | 跳过支付回调 IP 白名单校验(仅开发调试) | `false` |
|
||||
| `ALERT_WEBHOOK_URL` | 系统告警 Webhook URL | 空 |
|
||||
| `DB_POOL_MAX` | PostgreSQL 连接池最大连接数 | `20` |
|
||||
| `DB_POOL_MIN` | PostgreSQL 连接池最小连接数 | `5` |
|
||||
| `DB_CONNECTION_TIMEOUT` | 获取数据库连接超时(毫秒) | `5000` |
|
||||
| `DB_IDLE_TIMEOUT` | 数据库空闲连接超时(毫秒) | `30000` |
|
||||
| `DB_STATEMENT_TIMEOUT` | PostgreSQL 语句超时(毫秒) | `30000` |
|
||||
| `DB_QUERY_TIMEOUT` | PostgreSQL 查询超时(毫秒) | `30000` |
|
||||
| `TRAFFIC_CONCURRENCY_PER_HOST` | 每宿主机流量采集并发数 | `10` |
|
||||
| `TRAFFIC_HOST_CONCURRENCY` | 同时处理宿主机数量 | `3` |
|
||||
| `DB_WORKER_BACKOFF_MS` | worker 遇到数据库异常后的退避时间(毫秒) | `15000` |
|
||||
|
||||
---
|
||||
|
||||
### 最小 .env 配置示例
|
||||
|
||||
可以直接自动生成或补齐缺失项:
|
||||
|
||||
```bash
|
||||
bash scripts/init-env.sh
|
||||
```
|
||||
|
||||
脚本只补齐缺失或空值的变量,不覆盖已有非空配置。
|
||||
|
||||
```bash
|
||||
# ============ 必需配置 ============
|
||||
# 数据库密码 - 使用 openssl rand -base64 24 生成
|
||||
POSTGRES_PASSWORD=your_strong_password_here
|
||||
|
||||
# JWT 密钥 - 使用下面命令生成:
|
||||
# printf 'A1!%s\n' "$(openssl rand -hex 48)" | cut -c1-64
|
||||
JWT_SECRET=your_mixed_64_char_secret_here
|
||||
|
||||
# 敏感数据加密密钥 - 使用 openssl rand -base64 32 生成
|
||||
# 注意:上线后必须稳定保存,不能随意更换
|
||||
ENCRYPTION_KEY=your_base64_32_byte_encryption_key_here
|
||||
|
||||
# ============ 建议配置 ============
|
||||
# 管理员初始密码(首次启动后建议立即修改)
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
|
||||
# Redis 密码(可选但推荐)
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 完整 .env 配置示例(生产环境推荐)
|
||||
|
||||
```bash
|
||||
# ============ 数据库配置 ============
|
||||
POSTGRES_USER=incudal
|
||||
POSTGRES_PASSWORD=BJ4hbYQTLsz97zzJUGg8RFiq
|
||||
POSTGRES_DB=incudal
|
||||
|
||||
# ============ Redis 配置 ============
|
||||
REDIS_PASSWORD=redis_password_here
|
||||
|
||||
# ============ 安全配置(必须修改!)============
|
||||
# JWT 密钥 - 生成方式:
|
||||
# printf 'A1!%s\n' "$(openssl rand -hex 48)" | cut -c1-64
|
||||
JWT_SECRET=A1!b7e9c4d0f2a8e6b9d1c3f5a7b8d0e2c4f6a9b1d3e5c7a8f0b2d4e6c8
|
||||
|
||||
# Cookie 签名密钥(可选,增强安全性)
|
||||
COOKIE_SECRET=another_random_string_here
|
||||
|
||||
# 敏感数据加密密钥(2FA 等)- 生成方式: openssl rand -base64 32
|
||||
ENCRYPTION_KEY=XpPc3tcVSmy4/pxpLL40jVfMDJyayR+bE1ibamEWlys=
|
||||
|
||||
# ============ 应用配置 ============
|
||||
APP_PORT=3000
|
||||
ADMIN_PASSWORD=QpXEZrBkDosvu7TFMcAJjbv7
|
||||
LOG_LEVEL=info
|
||||
DISABLE_REQUEST_LOG=true
|
||||
|
||||
# ============ CORS 配置 + 页面跳转(必配!)============
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
# ============ 支付回调(前后端分域时必配)============
|
||||
# 第三方支付平台回调到后端时使用的公网地址
|
||||
# 例如前端为 https://panel.example.com,后端为 https://api.example.com
|
||||
# 则这里应配置为 https://api.example.com
|
||||
PAYMENT_CALLBACK_BASE_URL=https://api.your-domain.com
|
||||
|
||||
# 支付回调来源 IP 白名单(逗号分隔)
|
||||
# 留空时 Heleket 默认仅允许官方文档中的 webhook IP:31.133.220.8
|
||||
# PAYMENT_CALLBACK_IP_WHITELIST=31.133.220.8
|
||||
|
||||
# 仅开发调试时可临时跳过支付回调 IP 白名单校验
|
||||
# PAYMENT_CALLBACK_SKIP_IP_WHITELIST=false
|
||||
|
||||
# ============ 监控告警(可选)============
|
||||
# 系统异常时发送 Webhook 告警
|
||||
# ALERT_WEBHOOK_URL=https://your-webhook-url
|
||||
|
||||
# ============ 数据库稳定性调优(可选)============
|
||||
DB_POOL_MAX=30
|
||||
DB_POOL_MIN=2
|
||||
DB_CONNECTION_TIMEOUT=10000
|
||||
DB_IDLE_TIMEOUT=15000
|
||||
DB_STATEMENT_TIMEOUT=30000
|
||||
DB_QUERY_TIMEOUT=30000
|
||||
TRAFFIC_CONCURRENCY_PER_HOST=10
|
||||
TRAFFIC_HOST_CONCURRENCY=3
|
||||
DB_WORKER_BACKOFF_MS=15000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 部署命令
|
||||
|
||||
```bash
|
||||
# 1. 创建 .env 文件
|
||||
cp .env.example .env
|
||||
|
||||
# 2. 编辑 .env,配置上述变量
|
||||
nano .env
|
||||
|
||||
# 3. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 查看日志
|
||||
docker-compose logs -f app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 变量来源说明
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 环境变量流向图 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ .env 文件 │
|
||||
│ │ │
|
||||
│ ├─── POSTGRES_* ──────► db 容器 (PostgreSQL) │
|
||||
│ │ │
|
||||
│ ├─── REDIS_PASSWORD ──► redis 容器 │
|
||||
│ │ │
|
||||
│ └─── 其他变量 ────────► app 容器 (Incudal) │
|
||||
│ │ │
|
||||
│ ├── DATABASE_URL (自动组装) │
|
||||
│ ├── REDIS_URL (自动组装) │
|
||||
│ ├── JWT_SECRET │
|
||||
│ ├── ENCRYPTION_KEY │
|
||||
│ ├── ADMIN_PASSWORD │
|
||||
│ └── LOG_LEVEL 等 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**重点**:`DATABASE_URL` 和 `REDIS_URL` 由 docker-compose.yml 自动组装,无需在 .env 中配置。
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# ============================================================================
|
||||
# Incudal 预构建产物包发布工作流
|
||||
# 在推送版本 tag (v*) 或手动触发时,自动构建前后端并打包为 .tar.gz 发行包
|
||||
# 支持 amd64 和 arm64 双架构
|
||||
# ============================================================================
|
||||
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: '发行版本号(例如 v1.0.0),留空则使用 git SHA 作为预览版'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
|
||||
jobs:
|
||||
# ===== 构建发行包(多架构矩阵) =====
|
||||
build:
|
||||
name: 构建 (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: 安装 Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
# ===== 安装依赖 =====
|
||||
- name: 安装全部依赖
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: 重新构建 esbuild(Vite 构建需要)
|
||||
run: pnpm rebuild esbuild
|
||||
|
||||
# ===== 生成 Prisma Client =====
|
||||
- name: 生成 Prisma Client
|
||||
run: pnpm --filter server exec prisma generate
|
||||
env:
|
||||
DATABASE_URL: postgresql://user:pass@localhost:5432/db
|
||||
|
||||
# ===== 构建前端 =====
|
||||
- name: 构建前端 (Vite)
|
||||
run: pnpm --filter client build
|
||||
|
||||
# ===== 构建后端 =====
|
||||
- name: 编译后端 (TypeScript)
|
||||
run: pnpm --filter server build
|
||||
|
||||
# ===== 准备发行目录 =====
|
||||
- name: 准备发行目录结构
|
||||
run: |
|
||||
mkdir -p release/client/dist
|
||||
mkdir -p release/server
|
||||
|
||||
# 复制前端构建产物
|
||||
cp -r client/dist/* release/client/dist/
|
||||
|
||||
# 复制后端构建产物
|
||||
cp -r server/dist release/server/
|
||||
cp -r server/prisma release/server/
|
||||
cp server/prisma.config.ts release/server/
|
||||
cp server/package.json release/server/
|
||||
cp -r server/templates release/server/
|
||||
|
||||
# 复制根配置文件
|
||||
cp package.json release/
|
||||
cp pnpm-workspace.yaml release/
|
||||
|
||||
# ===== 安装生产依赖 =====
|
||||
- name: 安装后端生产依赖
|
||||
working-directory: release/server
|
||||
run: |
|
||||
# 在发行目录中只安装生产依赖
|
||||
npm install --omit=dev --ignore-scripts
|
||||
|
||||
# 在当前架构的 runner 上原生生成 Prisma Client
|
||||
DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
|
||||
|
||||
# ===== 创建启动辅助脚本 =====
|
||||
- name: 创建管理脚本
|
||||
run: |
|
||||
cat > release/incudal.sh << 'SCRIPT'
|
||||
#!/bin/bash
|
||||
# Incudal 快捷管理脚本
|
||||
set -e
|
||||
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
case "${1:-}" in
|
||||
start)
|
||||
echo "🚀 启动 Incudal..."
|
||||
cd "$APP_DIR/server"
|
||||
npx prisma migrate deploy
|
||||
cd "$APP_DIR"
|
||||
exec node server/dist/app.js
|
||||
;;
|
||||
migrate)
|
||||
echo "🔄 执行数据库迁移..."
|
||||
cd "$APP_DIR/server"
|
||||
npx prisma migrate deploy
|
||||
echo "✅ 迁移完成"
|
||||
;;
|
||||
version)
|
||||
echo "Incudal $(node -e "console.log(require('./package.json').version)")"
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|migrate|version}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x release/incudal.sh
|
||||
|
||||
# ===== 确定版本号 =====
|
||||
- name: 确定版本号
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
elif [[ -n "${{ github.event.inputs.tag }}" ]]; then
|
||||
VERSION="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
VERSION="preview-$(echo ${{ github.sha }} | head -c 7)"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "📦 版本号: $VERSION"
|
||||
|
||||
# ===== 打包 =====
|
||||
- name: 打包为 tar.gz
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
cd release
|
||||
tar -czf "../incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz" .
|
||||
cd ..
|
||||
ls -lh "incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz"
|
||||
echo "📦 发行包大小: $(du -h "incudal-${VERSION}-linux-${{ matrix.arch }}.tar.gz" | cut -f1)"
|
||||
|
||||
# ===== 上传构建产物 =====
|
||||
- name: 上传构建产物
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: incudal-${{ steps.version.outputs.version }}-linux-${{ matrix.arch }}
|
||||
path: incudal-${{ steps.version.outputs.version }}-linux-${{ matrix.arch }}.tar.gz
|
||||
retention-days: 30
|
||||
|
||||
# ===== 发布 Release(等待所有架构构建完成) =====
|
||||
release:
|
||||
name: 发布 Release
|
||||
needs:
|
||||
- build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: 确定版本号
|
||||
id: version
|
||||
run: echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# 下载所有架构的构建产物
|
||||
- name: 下载 amd64 产物
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: incudal-${{ steps.version.outputs.version }}-linux-amd64
|
||||
|
||||
- name: 下载 arm64 产物
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: incudal-${{ steps.version.outputs.version }}-linux-arm64
|
||||
|
||||
- name: 列出发行文件
|
||||
run: ls -lh incudal-*.tar.gz
|
||||
|
||||
# 创建 Release 并上传所有架构的产物
|
||||
- name: 创建 GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
incudal-*.tar.gz
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
|
||||
|
||||
# ===== 构建并推送 Docker 镜像(多架构) =====
|
||||
docker:
|
||||
name: 构建 Docker 镜像
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 设置 QEMU(用于跨架构构建 arm64)
|
||||
- name: 设置 QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# 设置 Docker Buildx(多架构构建引擎)
|
||||
- name: 设置 Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 登录 GitHub Container Registry
|
||||
- name: 登录 GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 提取版本号和元数据
|
||||
- name: 提取 Docker 元数据
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
# 构建并推送多架构镜像
|
||||
- name: 构建并推送 Docker 镜像
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -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/
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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`。
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
/incudal-agent
|
||||
/dist/*
|
||||
*.test
|
||||
coverage.out
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
v0.0.1
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
module incudal-agent
|
||||
|
||||
go 1.19
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |