mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e3c059236 | |||
| 61137b837d | |||
| 596bf86477 | |||
| 47a09aa177 | |||
| 2456b65ce2 | |||
| 292686a19a | |||
| 9eb7c322cf | |||
| 5ec62ca732 | |||
| a79df0d2dd | |||
| cc8fdbfede | |||
| 702d6975e5 | |||
| 84d98e40c6 | |||
| fd974d95b9 | |||
| cd258fd6ac | |||
| 0c2dd457d4 | |||
| 92e846eecc | |||
| a1d9ce8b1c | |||
| 49d8093f45 | |||
| 98ed716225 | |||
| 78276d303b | |||
| 30d2a4f4da | |||
| a54e03b924 | |||
| 4cdc6e68ba | |||
| 2ed42992ed | |||
| 4dfd7c0885 | |||
| 5ed5b4509d | |||
| 18f297b988 | |||
| d5a236943b | |||
| c54f92f892 | |||
| 86f0d079ab | |||
| 3a65d5d24a | |||
| cbe9339316 | |||
| 79d6dad684 | |||
| 55c7a9796c | |||
| f4a15a0d90 | |||
| c7742319b2 | |||
| 01c14ecba6 | |||
| 989e1b6645 | |||
| da5eea5193 | |||
| 4de86c458f | |||
| baf213e769 | |||
| 819a79e00d | |||
| 18bee369c1 | |||
| 2463715e32 | |||
| fbc539ea47 | |||
| 875cd4716b | |||
| 6194b6e364 | |||
| 30d6b2d9f7 | |||
| 2f94498df2 | |||
| c303fe6d17 | |||
| eedb2d7fb0 | |||
| bfc98d043b | |||
| 0a4cf5c0bd | |||
| 54a608c19d | |||
| 73bd6934f9 | |||
| a35595edcf | |||
| b605df613e | |||
| 3cc8f7df7f | |||
| 744246c0a8 | |||
| aed512cb09 | |||
| b6f48acc4c | |||
| d83a5e3473 | |||
| 18c9d75a05 | |||
| b80e4817ef | |||
| 0b052f2217 | |||
| 80386d35ba | |||
| a45e063fc2 | |||
| e71adf6830 | |||
| bb0c4f999d | |||
| 0c9f420474 | |||
| 9a826add87 | |||
| 63611dc932 | |||
| e6551bf4ae | |||
| 5bf2b6534a | |||
| 917afc3157 | |||
| c8081edbac | |||
| 95eb00a31d | |||
| f4edf94800 | |||
| 82b42e7961 | |||
| e971d99070 | |||
| 8dd09ff009 | |||
| 7f4755788a | |||
| c24df1d42f | |||
| cf00d0d03d | |||
| 79be2d5cbd | |||
| e66327db29 | |||
| 2b4fe4f5bc |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: mengmengcode
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
+79
-23
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -14,9 +14,15 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
linux-amd64:
|
||||
name: Linux amd64
|
||||
linux:
|
||||
name: Linux ${{ matrix.goarch }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -25,51 +31,101 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
docs/package-lock.json
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
go-version: '1.24.5'
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci --prefix frontend
|
||||
|
||||
- name: Install docs dependencies
|
||||
run: npm ci --prefix docs
|
||||
|
||||
- name: Build docs
|
||||
run: npm run build --prefix docs
|
||||
|
||||
- name: Set version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CLICD_VERSION=dev" >> $GITHUB_ENV
|
||||
echo "CLICD_VERSION=dev" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
- name: Build CLICD
|
||||
env:
|
||||
CLICD_GOARCH: ${{ matrix.goarch }}
|
||||
run: bash build.sh
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
- name: Package CLICD
|
||||
env:
|
||||
CLICD_GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
mkdir -p dist package/clicd-linux-amd64
|
||||
cp build/clicd package/clicd-linux-amd64/clicd
|
||||
cp build/install.sh package/clicd-linux-amd64/install.sh
|
||||
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
|
||||
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
|
||||
cp build/clicd dist/clicd-linux-amd64
|
||||
sha256sum dist/* > dist/SHA256SUMS
|
||||
asset_dir="clicd-linux-${CLICD_GOARCH}"
|
||||
mkdir -p "dist" "package/${asset_dir}"
|
||||
cp build/clicd "package/${asset_dir}/clicd"
|
||||
cp build/install.sh "package/${asset_dir}/install.sh"
|
||||
chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh"
|
||||
tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}"
|
||||
cp build/clicd "dist/${asset_dir}"
|
||||
|
||||
- name: Package Mofang module
|
||||
run: |
|
||||
if [ ! -f Mofang/clicd.php ]; then
|
||||
echo "Mofang module not present; skipping package."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v zip >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip
|
||||
fi
|
||||
|
||||
cd Mofang
|
||||
zip -r ../dist/clicd-mofang.zip clicd.php handlers templates -x '*.DS_Store' -x '*/.DS_Store'
|
||||
|
||||
- name: Generate checksums
|
||||
run: sha256sum dist/* > dist/SHA256SUMS
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: clicd-linux-amd64
|
||||
name: clicd-linux-${{ matrix.goarch }}
|
||||
path: dist/*
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
needs: linux
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist-artifacts
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p dist
|
||||
find dist-artifacts -maxdepth 2 -type f ! -name SHA256SUMS -print -exec cp -f {} dist/ \;
|
||||
sha256sum dist/* > dist/SHA256SUMS
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
|
||||
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
||||
|
||||
@@ -13,6 +13,8 @@ backend/internal/server/web/*
|
||||
|
||||
# Build artifacts
|
||||
/build/
|
||||
/dist/
|
||||
Mofang/*.zip
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
@@ -67,3 +69,5 @@ linux.txt
|
||||
push-release.ps1
|
||||
deploy.ps1
|
||||
backend/clicd
|
||||
api.md
|
||||
deploy-arm.ps1
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
# CLICD 魔方财务对接模块
|
||||
|
||||
这是用于智简魔方 / IDCSMART 的 CLICD 服务器模块。模块通过 CLICD API 完成实例开通、删除、开关机、重启、重装、改密、资源变更、流量重置、NAT 端口映射管理、实例信息展示和 WebSSH 入口。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```text
|
||||
clicd.php
|
||||
README.md
|
||||
handlers/
|
||||
webssh.php
|
||||
templates/
|
||||
firewall.html
|
||||
info.html
|
||||
nat.html
|
||||
```
|
||||
|
||||
安装时请保持目录结构不变,将整个 `clicd` 目录放入魔方服务器模块目录:
|
||||
|
||||
```text
|
||||
public/plugins/servers/clicd/
|
||||
```
|
||||
|
||||
## 服务器配置
|
||||
|
||||
在魔方后台添加服务器时,模块名称选择 `clicd`。
|
||||
|
||||
CLICD 面板地址建议使用 HTTPS:
|
||||
|
||||
```text
|
||||
主机名 = https://0.0.0.0:8999
|
||||
```
|
||||
|
||||
也可以拆分填写:
|
||||
|
||||
```text
|
||||
IP地址 = 0.0.0.0
|
||||
端口 = 8999
|
||||
secure = 开启
|
||||
```
|
||||
|
||||
API Key 可以填写在以下任意一个字段中:
|
||||
|
||||
```text
|
||||
Hash
|
||||
密码
|
||||
```
|
||||
|
||||
模块请求 CLICD 时会同时携带:
|
||||
|
||||
```text
|
||||
X-API-Key: clicd_sk_xxxx
|
||||
Authorization: Bearer clicd_sk_xxxx
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
## 产品配置项
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `virtualization` | 虚拟化类型,`lxc` 或 `kvm` |
|
||||
| `template_id` | CLICD 模板 / 镜像 ID |
|
||||
| `vcpu` | CPU 核心数 |
|
||||
| `cpu_percent` | CPU 使用率限制,`0` 表示不额外限制 |
|
||||
| `ram_mb` | 内存,单位 MB |
|
||||
| `disk_gb` | 系统盘,单位 GB |
|
||||
| `network_bw_mbps` | 带宽,单位 Mbps |
|
||||
| `traffic_mode` | `total` 总流量,或 `in_out` 入 / 出分开 |
|
||||
| `monthly_traffic_gb` | 月流量 GB |
|
||||
| `traffic_in_gb` | 入站流量 GB,`in_out` 模式使用 |
|
||||
| `traffic_out_gb` | 出站流量 GB,`in_out` 模式使用 |
|
||||
| `io_speed_mbps` | 磁盘 IO 限制,`0` 表示不限制 |
|
||||
| `port_mapping_count` | 开通时分配的 NAT 端口数量,最小 2 |
|
||||
| `snapshot_limit` | 快照配额 |
|
||||
| `extra_ports` | 额外映射的容器端口,逗号分隔,例如 `80,443` |
|
||||
| `assign_ipv6` | 开通时是否自动分配 IPv6 |
|
||||
| `sync_expiry` | 是否同步魔方到期时间到 CLICD |
|
||||
|
||||
客户产品的 `domain` 会作为 CLICD 容器名称。模块会自动把不适合作为容器名的字符替换为 `-`。
|
||||
|
||||
## 开通后字段同步
|
||||
|
||||
开通、同步、重装、改密后,模块会从 CLICD 容器详情拉取最新信息并写回魔方主机表:
|
||||
|
||||
| 魔方字段 | 写入内容 |
|
||||
| --- | --- |
|
||||
| `dedicatedip` | NAT 外网 IP,优先使用 API 返回的公网字段,否则使用服务器 IP |
|
||||
| `username` | 固定写入 `root` |
|
||||
| `password` | CLICD 返回的 SSH 密码,兼容魔方 `cmf_encrypt()` |
|
||||
| `port` | CLICD 返回的 `ssh_port` |
|
||||
| `domainstatus` | CLICD 状态为 `running` 时写 `Active`,否则写 `Suspended` |
|
||||
|
||||
如果接口返回的密码是 `***` 这类脱敏值,模块不会覆盖魔方里已有密码。
|
||||
|
||||
## 客户区页面
|
||||
|
||||
模块提供三个客户区选项卡:
|
||||
|
||||
```text
|
||||
实例信息
|
||||
NAT转发
|
||||
防火墙
|
||||
```
|
||||
|
||||
客户区按钮提供:
|
||||
|
||||
```text
|
||||
WebSSH
|
||||
```
|
||||
|
||||
## 实例信息
|
||||
|
||||
实例信息页展示:
|
||||
|
||||
- 实例名称、运行状态、SSH 地址、IPv6
|
||||
- CPU、内存、负载、磁盘圆环状态
|
||||
- 月流量进度
|
||||
- CPU 使用率、内存使用、网络流量、磁盘 IO 图表
|
||||
- IPv4、SSH 端口、SSH 密码、资源配置、到期时间
|
||||
|
||||
图表数据通过客户区懒加载接口获取,不会强制刷新整个魔方页面。页面首次打开会加载一次数据,之后由用户选择是否自动刷新:
|
||||
|
||||
```text
|
||||
不刷新
|
||||
10 秒
|
||||
1 分钟
|
||||
5 分钟
|
||||
10 分钟
|
||||
```
|
||||
|
||||
也可以点击“立即刷新”手动刷新一次。当前 CLICD 用量接口返回的是实时值,不是历史数组;图表曲线由客户区前端持续采样生成。若需要打开页面立即显示历史曲线,需要 CLICD 额外提供历史指标接口。
|
||||
|
||||
流量显示支持智能单位,小流量会显示 B / KB / MB,大流量显示 GB,例如:
|
||||
|
||||
```text
|
||||
370.5 KB / 100 GB
|
||||
```
|
||||
|
||||
模块会优先调用:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{name}/usage
|
||||
GET /api/v1/containers/{name}/traffic
|
||||
```
|
||||
|
||||
如果 `/api/v1/containers/{name}/usage` 不可用,模块会在容器详情存在 `uuid` 时尝试兼容:
|
||||
|
||||
```text
|
||||
GET /api/containers/{uuid}/usage
|
||||
```
|
||||
|
||||
已兼容的常见用量字段包括:
|
||||
|
||||
```text
|
||||
cpu_usage_pct
|
||||
memory_usage_bytes
|
||||
disk_usage_bytes
|
||||
network_rx_bps
|
||||
network_tx_bps
|
||||
disk_read_bps
|
||||
disk_write_bps
|
||||
rx_used_bytes
|
||||
tx_used_bytes
|
||||
total_used_bytes
|
||||
limit_gb
|
||||
used_pct
|
||||
```
|
||||
|
||||
## NAT 转发
|
||||
|
||||
NAT 转发是独立页面,支持:
|
||||
|
||||
- 查看端口映射
|
||||
- 获取随机可用端口
|
||||
- 添加端口映射
|
||||
- 修改端口映射
|
||||
- 删除端口映射
|
||||
|
||||
删除端口映射时使用页面内确认弹窗,不使用浏览器自带确认框。
|
||||
|
||||
使用的 CLICD API:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{id|uuid|name}
|
||||
GET /api/v1/containers/{id}/random-port
|
||||
POST /api/v1/containers/{id}/port-mappings
|
||||
PUT /api/v1/containers/{id}/port-mappings/{index}
|
||||
DELETE /api/v1/containers/{id}/port-mappings/{index}
|
||||
```
|
||||
|
||||
添加 / 修改 NAT 映射时必须使用 JSON 请求体,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"container_port": 8080,
|
||||
"host_port": 61320,
|
||||
"protocol": "tcp",
|
||||
"description": "HTTP"
|
||||
}
|
||||
```
|
||||
|
||||
## 防火墙
|
||||
|
||||
防火墙是独立客户区页面,支持:
|
||||
|
||||
- 查看防火墙启用状态、默认动作和规则列表
|
||||
- 启用 / 停用防火墙
|
||||
- 设置默认动作:未匹配拒绝或未匹配放行
|
||||
- 添加规则
|
||||
- 编辑规则
|
||||
- 删除规则
|
||||
- 单独启用 / 停用某条规则
|
||||
|
||||
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
|
||||
|
||||
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
|
||||
|
||||
使用的 CLICD API:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{id}/firewall
|
||||
PUT /api/v1/containers/{id}/firewall
|
||||
```
|
||||
|
||||
更新防火墙时必须使用 JSON 请求体,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "ACCEPT",
|
||||
"rules": [
|
||||
{
|
||||
"id": "",
|
||||
"network": "ipv4",
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"port": "22",
|
||||
"source_ip": "",
|
||||
"action": "ACCEPT",
|
||||
"description": "Allow SSH",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
|
||||
| `direction` | 方向,`in` 入站,`out` 出站 |
|
||||
| `protocol` | 协议,`tcp` 或 `udp` |
|
||||
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22`、`80,443`、`8000-9000` |
|
||||
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
|
||||
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
|
||||
| `description` | 规则描述 |
|
||||
| `enabled` | 是否启用该规则 |
|
||||
|
||||
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`。
|
||||
## WebSSH
|
||||
|
||||
WebSSH 按钮会调用:
|
||||
|
||||
```text
|
||||
POST /api/v1/ssh-ticket
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"container_name": "example-vm"
|
||||
}
|
||||
```
|
||||
|
||||
接口返回 60 秒有效票据后,模块会打开本地 handler:
|
||||
|
||||
```text
|
||||
/plugins/servers/clicd/handlers/webssh.php
|
||||
```
|
||||
|
||||
浏览器会从该页面直连 CLICD:
|
||||
|
||||
```text
|
||||
wss://0.0.0.0:8999/api/ssh?container=example-vm
|
||||
Sec-WebSocket-Protocol: clicd-ticket.xxxxx
|
||||
```
|
||||
|
||||
注意:WebSSH 受浏览器安全策略和 CLICD 后端 Origin 校验影响。魔方客户区通常是 HTTPS,因此 CLICD 面板也必须启用 HTTPS/WSS。请把魔方服务器配置里的 `主机名` 改为 `https://0.0.0.0:8999`,或把 `secure` 设为 `开启`。
|
||||
|
||||
新版 CLICD 已支持 WebSSH Origin 放行。部署时需要在 CLICD 后端把魔方财务客户区域名加入 WebSSH Origin 白名单,例如:
|
||||
|
||||
```text
|
||||
https://www.example.com
|
||||
```
|
||||
|
||||
如果 WebSSH 页面显示 `WebSocket error`、`Disconnected code=1006`,但直接以 CLICD 自身 Origin 测试能返回 `101 Switching Protocols`,通常说明 CLICD 后端未放行魔方客户区域名的 WebSocket Origin。此时请检查 CLICD 的 WebSSH Origin 白名单配置;前端页面无法伪造浏览器 Origin。
|
||||
|
||||
## 支持的魔方操作
|
||||
|
||||
| 魔方操作 | CLICD API |
|
||||
| --- | --- |
|
||||
| 连接测试 | `GET /api/v1/dashboard` |
|
||||
| 开通 | `POST /api/v1/containers` |
|
||||
| 删除 | `DELETE /api/v1/containers/{name}/delete` |
|
||||
| 开机 | `POST /api/v1/containers/{name}/start` |
|
||||
| 关机 | `POST /api/v1/containers/{name}/stop` |
|
||||
| 重启 | `POST /api/v1/containers/{name}/restart` |
|
||||
| 重装 | `POST /api/v1/containers/{name}/reinstall` |
|
||||
| 改密 | `POST /api/v1/containers/{name}/reset-password` |
|
||||
| 重置流量 | `POST /api/v1/containers/{name}/traffic-reset` |
|
||||
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
|
||||
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
|
||||
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
|
||||
| 查询防火墙 | `GET /api/v1/containers/{id}/firewall` |
|
||||
| 更新防火墙 | `PUT /api/v1/containers/{id}/firewall` |
|
||||
| WebSSH | `POST /api/v1/ssh-ticket` |
|
||||
|
||||
## 建议 API 权限
|
||||
|
||||
API Key 至少需要以下权限,具体名称以 CLICD 后端实际权限系统为准:
|
||||
|
||||
```text
|
||||
dashboard:read
|
||||
container:read
|
||||
container:create
|
||||
container:power
|
||||
container:delete
|
||||
container:reinstall
|
||||
container:password
|
||||
container:traffic
|
||||
container:resize
|
||||
container:port
|
||||
container:firewall
|
||||
task:read
|
||||
ssh-ticket:create
|
||||
```
|
||||
|
||||
如果 API Key 使用 `*` 或 `admin:*`,通常可以覆盖上述权限。
|
||||
|
||||
## 建议先测试的 curl
|
||||
|
||||
连接测试:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||
https://0.0.0.0:8999/api/v1/dashboard
|
||||
```
|
||||
|
||||
容器详情:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||
https://0.0.0.0:8999/api/v1/containers/example-vm
|
||||
```
|
||||
|
||||
资源用量:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||
https://0.0.0.0:8999/api/v1/containers/example-vm/usage
|
||||
```
|
||||
|
||||
流量统计:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||
https://0.0.0.0:8999/api/v1/containers/example-vm/traffic
|
||||
```
|
||||
|
||||
修改 NAT:
|
||||
|
||||
```bash
|
||||
curl --location --request PUT \
|
||||
"https://0.0.0.0:8999/api/v1/containers/10/port-mappings/1" \
|
||||
--header "X-API-Key: clicd_sk_xxxx" \
|
||||
--header "Authorization: Bearer clicd_sk_xxxx" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}'
|
||||
```
|
||||
|
||||
查询防火墙:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||
https://0.0.0.0:8999/api/v1/containers/10/firewall
|
||||
```
|
||||
|
||||
更新防火墙:
|
||||
|
||||
```bash
|
||||
curl --location --request PUT \
|
||||
"https://0.0.0.0:8999/api/v1/containers/10/firewall" \
|
||||
--header "X-API-Key: clicd_sk_xxxx" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data-raw '{"enabled":true,"default_action":"ACCEPT","rules":[{"id":"","network":"ipv4","direction":"in","protocol":"tcp","port":"22","source_ip":"","action":"ACCEPT","description":"Allow SSH","enabled":true}]}'
|
||||
```
|
||||
创建 WebSSH 票据:
|
||||
|
||||
```bash
|
||||
curl --location --request POST \
|
||||
"https://0.0.0.0:8999/api/v1/ssh-ticket" \
|
||||
--header "X-API-Key: clicd_sk_xxxx" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data-raw '{"container_name":"example-vm"}'
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### NAT 修改不生效
|
||||
|
||||
确认请求体必须是 JSON,不要使用 `multipart/form-data`。正确请求头:
|
||||
|
||||
```text
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 防火墙获取提示“不支持的方法”
|
||||
|
||||
请确认模块版本已经包含防火墙页签修复。客户区防火墙列表应通过模块公开的 `firewallList` 调用,再由模块向 CLICD 发起:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{id}/firewall
|
||||
```
|
||||
|
||||
如果页面或二开代码直接把读取请求改成 `POST /api/v1/containers/{id}/firewall`,CLICD 会返回“不支持的方法”。
|
||||
|
||||
### 防火墙保存后规则为空
|
||||
|
||||
请确认更新接口最终发往 CLICD 的请求体是 JSON,并且包含 `rules` 数组。防火墙关闭时也可以保存规则,`enabled: false` 不应自动清空 `rules`。
|
||||
|
||||
正确请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"default_action": "ACCEPT",
|
||||
"rules": [
|
||||
{
|
||||
"id": "",
|
||||
"network": "ipv4",
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"port": "22",
|
||||
"source_ip": "",
|
||||
"action": "ACCEPT",
|
||||
"description": "Allow SSH",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
### 图表刚打开只有一条横线
|
||||
|
||||
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
|
||||
|
||||
### 流量显示为 0
|
||||
|
||||
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
|
||||
|
||||
### 防火墙
|
||||
|
||||
防火墙是独立客户区页面,支持:
|
||||
|
||||
- 查看防火墙启用状态、默认动作和规则列表
|
||||
- 启用 / 停用防火墙
|
||||
- 设置默认动作:未匹配拒绝或未匹配放行
|
||||
- 添加规则
|
||||
- 编辑规则
|
||||
- 删除规则
|
||||
- 单独启用 / 停用某条规则
|
||||
|
||||
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
|
||||
|
||||
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
|
||||
|
||||
使用的 CLICD API:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{id}/firewall
|
||||
PUT /api/v1/containers/{id}/firewall
|
||||
```
|
||||
|
||||
更新防火墙时必须使用 JSON 请求体,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "ACCEPT",
|
||||
"rules": [
|
||||
{
|
||||
"id": "",
|
||||
"network": "ipv4",
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"port": "22",
|
||||
"source_ip": "",
|
||||
"action": "ACCEPT",
|
||||
"description": "Allow SSH",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
|
||||
| `direction` | 方向,`in` 入站,`out` 出站 |
|
||||
| `protocol` | 协议,`tcp` 或 `udp` |
|
||||
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22`、`80,443`、`8000-9000` |
|
||||
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
|
||||
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
|
||||
| `description` | 规则描述 |
|
||||
| `enabled` | 是否启用该规则 |
|
||||
|
||||
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`。
|
||||
## WebSSH 打不开或提示不安全 WebSocket
|
||||
|
||||
请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS:
|
||||
|
||||
```text
|
||||
server_host = https://0.0.0.0:8999
|
||||
```
|
||||
|
||||
如果仍然使用 `http://`,模块会生成 `ws://` 地址,HTTPS 客户区页面会被浏览器拦截。
|
||||
|
||||
如果 WSS 证书正常但仍返回 `Forbidden` 或浏览器显示 `code=1006`,请检查 CLICD 的 WebSSH Origin 白名单。新版 CLICD 已支持放行魔方财务域名,需要把魔方客户区访问域名完整加入白名单,例如:
|
||||
|
||||
```text
|
||||
https://www.example.com
|
||||
```
|
||||
|
||||
注意需要填写浏览器实际访问魔方客户区时的协议和域名,`http` / `https`、带不带 `www` 都要与实际访问地址一致。
|
||||
|
||||
### 开通后魔方里的 IP、端口、密码不对
|
||||
|
||||
执行“同步状态”或重装 / 改密后,模块会重新拉取容器详情。请确认 CLICD 容器详情接口能返回:
|
||||
|
||||
```text
|
||||
ssh_port
|
||||
ssh_password
|
||||
status
|
||||
```
|
||||
|
||||
公网 IP 优先使用 `nat_public_ip/public_ip/host_ip/external_ip/node_ip/nat_host` 等字段;如果接口没有返回,则使用魔方服务器配置的 IP。
|
||||
+1716
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
<?php
|
||||
$ws = isset($_GET['ws']) ? (string)$_GET['ws'] : (isset($_GET['amp;ws']) ? (string)$_GET['amp;ws'] : '');
|
||||
$protocol = isset($_GET['protocol']) ? (string)$_GET['protocol'] : (isset($_GET['amp;protocol']) ? (string)$_GET['amp;protocol'] : '');
|
||||
$container = isset($_GET['container']) ? (string)$_GET['container'] : (isset($_GET['amp;container']) ? (string)$_GET['amp;container'] : '');
|
||||
$ticket = isset($_GET['ticket']) ? (string)$_GET['ticket'] : (isset($_GET['amp;ticket']) ? (string)$_GET['amp;ticket'] : '');
|
||||
|
||||
if ($protocol === '' && $ticket !== '') {
|
||||
$protocol = 'clicd-ticket.' . $ticket;
|
||||
}
|
||||
|
||||
if ($ws === '') {
|
||||
http_response_code(400);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Missing WebSSH parameters\n";
|
||||
echo "Received query: " . ($_SERVER['QUERY_STRING'] ?? '') . "\n";
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WebSSH</title>
|
||||
<style>
|
||||
html,body{height:100%;margin:0;background:#0b1020;color:#e5e7eb;font-family:Consolas,Menlo,monospace}
|
||||
body{cursor:text}
|
||||
.bar{height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:#111827;border-bottom:1px solid #243047}
|
||||
.dot{width:9px;height:9px;border-radius:50%;background:#f59e0b}
|
||||
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}
|
||||
.title{font-size:14px;color:#cbd5e1;flex:1}
|
||||
.tools{display:flex;align-items:center;gap:8px;font-size:12px;color:#94a3b8;flex-wrap:wrap;justify-content:flex-end}
|
||||
.tools select{height:26px;background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:4px}
|
||||
.tools button{height:26px;border:1px solid #334155;background:#0f172a;color:#cbd5e1;border-radius:4px;padding:0 8px;cursor:pointer}
|
||||
#keyhint{min-width:44px;text-align:right}
|
||||
#iostat{min-width:120px;text-align:right}
|
||||
#term{height:calc(100% - 89px);box-sizing:border-box;padding:14px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.45;outline:none}
|
||||
.inputbar{height:44px;display:flex;align-items:center;gap:8px;padding:6px 10px;box-sizing:border-box;background:#111827;border-top:1px solid #243047}
|
||||
#cmd{flex:1;height:30px;background:#020617;color:#e5e7eb;border:1px solid #334155;border-radius:4px;padding:0 8px;font:14px Consolas,Menlo,monospace;outline:none}
|
||||
#sendcmd{height:30px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
|
||||
.hint{color:#94a3b8}
|
||||
.meta{color:#94a3b8}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<span id="state" class="dot"></span>
|
||||
<span class="title">WebSSH <?php echo htmlspecialchars($container, ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
<span class="tools">
|
||||
<span>发送模式</span>
|
||||
<select id="send-mode">
|
||||
<option value="raw" selected>raw</option>
|
||||
<option value="binary">binary</option>
|
||||
<option value="json-input">json input</option>
|
||||
<option value="json-data">json data</option>
|
||||
<option value="json-stdin">json stdin</option>
|
||||
</select>
|
||||
<button id="send-enter" type="button">回车</button>
|
||||
<span id="iostat">S0 R0</span>
|
||||
<span id="keyhint"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="term" tabindex="0"><span class="hint">正在连接...</span></div>
|
||||
<div class="inputbar">
|
||||
<input id="cmd" type="text" autocomplete="off" spellcheck="false" placeholder="在这里输入命令,例如 ls -la">
|
||||
<button id="sendcmd" type="button">发送</button>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var wsUrl = <?php echo json_encode($ws, JSON_UNESCAPED_SLASHES); ?>;
|
||||
var protocol = <?php echo json_encode($protocol, JSON_UNESCAPED_SLASHES); ?>;
|
||||
var ticket = <?php echo json_encode($ticket, JSON_UNESCAPED_SLASHES); ?>;
|
||||
var term = document.getElementById('term');
|
||||
var state = document.getElementById('state');
|
||||
var modeSelect = document.getElementById('send-mode');
|
||||
var keyhint = document.getElementById('keyhint');
|
||||
var iostat = document.getElementById('iostat');
|
||||
var sendEnter = document.getElementById('send-enter');
|
||||
var cmd = document.getElementById('cmd');
|
||||
var sendcmd = document.getElementById('sendcmd');
|
||||
var socket;
|
||||
var hintTimer;
|
||||
var sentCount = 0;
|
||||
var recvCount = 0;
|
||||
var decoder = window.TextDecoder ? new TextDecoder('utf-8') : null;
|
||||
var termLines = [''];
|
||||
var cursorRow = 0;
|
||||
var cursorCol = 0;
|
||||
var maxLines = 2000;
|
||||
|
||||
function append(text) {
|
||||
writeTerminal(stripTerminalControls(String(text || '')));
|
||||
renderTerminal();
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
termLines = [''];
|
||||
cursorRow = 0;
|
||||
cursorCol = 0;
|
||||
renderTerminal();
|
||||
}
|
||||
|
||||
function stripTerminalControls(text) {
|
||||
return text
|
||||
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b\[(?:2J|H)/g, '\f')
|
||||
.replace(/\x1b\[[0-?]*[ -/]*K/g, '\v')
|
||||
.replace(/\ufffd\[[0-?]*[ -/]*K/g, '\v')
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\ufffd\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\x1b[()][A-Za-z0-9]/g, '')
|
||||
.replace(/\x1b[@-Z\\-_]/g, '')
|
||||
.replace(/[\x00-\x08\x0e-\x1f\x7f]/g, '');
|
||||
}
|
||||
|
||||
function ensureLine() {
|
||||
while (cursorRow >= termLines.length) {
|
||||
termLines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
function trimTerminal() {
|
||||
if (termLines.length <= maxLines) {
|
||||
return;
|
||||
}
|
||||
var overflow = termLines.length - maxLines;
|
||||
termLines.splice(0, overflow);
|
||||
cursorRow = Math.max(0, cursorRow - overflow);
|
||||
}
|
||||
|
||||
function writeTerminal(text) {
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var ch = text.charAt(i);
|
||||
if (ch === '\f') {
|
||||
termLines = [''];
|
||||
cursorRow = 0;
|
||||
cursorCol = 0;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\v') {
|
||||
ensureLine();
|
||||
termLines[cursorRow] = termLines[cursorRow].slice(0, cursorCol);
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
cursorCol = 0;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
cursorRow++;
|
||||
cursorCol = 0;
|
||||
ensureLine();
|
||||
trimTerminal();
|
||||
continue;
|
||||
}
|
||||
if (ch === '\b') {
|
||||
cursorCol = Math.max(0, cursorCol - 1);
|
||||
continue;
|
||||
}
|
||||
if (ch === '\t') {
|
||||
var spaces = 4 - (cursorCol % 4);
|
||||
for (var s = 0; s < spaces; s++) {
|
||||
writePrintable(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
writePrintable(ch);
|
||||
}
|
||||
trimTerminal();
|
||||
}
|
||||
|
||||
function writePrintable(ch) {
|
||||
ensureLine();
|
||||
var line = termLines[cursorRow];
|
||||
if (cursorCol > line.length) {
|
||||
line += new Array(cursorCol - line.length + 1).join(' ');
|
||||
}
|
||||
termLines[cursorRow] = line.slice(0, cursorCol) + ch + line.slice(cursorCol + 1);
|
||||
cursorCol++;
|
||||
}
|
||||
|
||||
function renderTerminal() {
|
||||
term.textContent = termLines.join('\n');
|
||||
term.scrollTop = term.scrollHeight;
|
||||
}
|
||||
|
||||
function setState(cls, text) {
|
||||
state.className = 'dot ' + cls;
|
||||
append(text);
|
||||
}
|
||||
|
||||
function updateIoStatus() {
|
||||
if (!iostat) return;
|
||||
var stateText = socket ? ['CONNECTING','OPEN','CLOSING','CLOSED'][socket.readyState] : '-';
|
||||
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
|
||||
}
|
||||
|
||||
function websocketProtocolValue(value) {
|
||||
value = String(value || '');
|
||||
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value) ? value : '';
|
||||
}
|
||||
|
||||
try {
|
||||
var protocolValue = websocketProtocolValue(protocol);
|
||||
if (!protocolValue && ticket) {
|
||||
append('[WebSSH] 票据已通过 URL 参数传递,当前浏览器不会发送子协议。\n');
|
||||
}
|
||||
socket = protocolValue ? new WebSocket(wsUrl, protocolValue) : new WebSocket(wsUrl);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
} catch (e) {
|
||||
setState('err', '\nWebSocket 创建失败:' + e.message + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.onopen = function(){
|
||||
clearTerminal();
|
||||
setState('ok', '已连接。\r\n');
|
||||
updateIoStatus();
|
||||
if (cmd) cmd.focus();
|
||||
};
|
||||
socket.onmessage = function(event){
|
||||
recvCount++;
|
||||
updateIoStatus();
|
||||
if (typeof event.data === 'string') {
|
||||
handleIncomingText(event.data);
|
||||
return;
|
||||
}
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
handleIncomingText(decodeIncoming(event.data));
|
||||
return;
|
||||
}
|
||||
if (window.Blob && event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then(function(buffer){
|
||||
handleIncomingText(decodeIncoming(buffer));
|
||||
}).catch(function(){
|
||||
append('\n[WebSSH] 无法解码服务端返回内容。\n');
|
||||
});
|
||||
}
|
||||
};
|
||||
socket.onerror = function(){
|
||||
setState('err', '\nWebSocket 连接错误,请检查 HTTPS 证书、WSS 服务、Origin 策略和票据有效期。\n');
|
||||
};
|
||||
socket.onclose = function(event){
|
||||
updateIoStatus();
|
||||
setState('err', '\n连接已断开。code=' + event.code + ' reason=' + (event.reason || '-') + ' clean=' + event.wasClean + '\n');
|
||||
};
|
||||
|
||||
function flashKey(text) {
|
||||
if (!keyhint) return;
|
||||
keyhint.textContent = text;
|
||||
window.clearTimeout(hintTimer);
|
||||
hintTimer = window.setTimeout(function(){ keyhint.textContent = ''; }, 500);
|
||||
}
|
||||
|
||||
function decodeIncoming(buffer) {
|
||||
if (decoder) {
|
||||
return decoder.decode(new Uint8Array(buffer));
|
||||
}
|
||||
var bytes = new Uint8Array(buffer);
|
||||
var text = '';
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
text += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(escape(text));
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function handleIncomingText(text) {
|
||||
append(text);
|
||||
if (text.indexOf('SSH shell ready') !== -1) {
|
||||
window.setTimeout(function(){ send('\r'); }, 250);
|
||||
}
|
||||
}
|
||||
|
||||
function wsPayload(data) {
|
||||
var mode = modeSelect ? modeSelect.value : 'raw';
|
||||
if (mode === 'raw') {
|
||||
return data;
|
||||
}
|
||||
if (mode === 'binary') {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (mode === 'json-data') {
|
||||
return JSON.stringify({type:'data', data:data});
|
||||
}
|
||||
if (mode === 'json-stdin') {
|
||||
return JSON.stringify({type:'stdin', data:data});
|
||||
}
|
||||
return JSON.stringify({type:'input', data:data});
|
||||
}
|
||||
|
||||
function send(data) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
socket.send(wsPayload(data));
|
||||
sentCount++;
|
||||
updateIoStatus();
|
||||
flashKey(data === '\r' ? '回车' : data === '\x7f' ? '退格' : data.length > 1 ? data.length + ' 字符' : data);
|
||||
return true;
|
||||
}
|
||||
|
||||
function keyToData(e) {
|
||||
if (e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
|
||||
var code = e.key.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
return String.fromCharCode(code - 64);
|
||||
}
|
||||
}
|
||||
var map = {
|
||||
Enter: '\r',
|
||||
Backspace: '\x7f',
|
||||
Tab: '\t',
|
||||
Escape: '\x1b',
|
||||
ArrowUp: '\x1b[A',
|
||||
ArrowDown: '\x1b[B',
|
||||
ArrowRight: '\x1b[C',
|
||||
ArrowLeft: '\x1b[D',
|
||||
Delete: '\x1b[3~',
|
||||
Home: '\x1b[H',
|
||||
End: '\x1b[F',
|
||||
PageUp: '\x1b[5~',
|
||||
PageDown: '\x1b[6~'
|
||||
};
|
||||
if (map[e.key]) {
|
||||
return map[e.key];
|
||||
}
|
||||
if (!e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
|
||||
return e.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.target === cmd) {
|
||||
return;
|
||||
}
|
||||
var data = keyToData(e);
|
||||
if (data !== null && send(data)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('paste', function(e){
|
||||
if (e.target === cmd) {
|
||||
return;
|
||||
}
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
if (text && send(text)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', function(){
|
||||
if (cmd) cmd.focus();
|
||||
});
|
||||
|
||||
function sendCommandLine() {
|
||||
if (!cmd) return;
|
||||
var value = cmd.value;
|
||||
if (value === '') {
|
||||
send('\r');
|
||||
return;
|
||||
}
|
||||
if (send(value + '\r')) {
|
||||
cmd.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (sendcmd) {
|
||||
sendcmd.addEventListener('click', sendCommandLine);
|
||||
}
|
||||
if (sendEnter) {
|
||||
sendEnter.addEventListener('click', function(){
|
||||
send('\r');
|
||||
if (cmd) cmd.focus();
|
||||
});
|
||||
}
|
||||
if (cmd) {
|
||||
cmd.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Enter') {
|
||||
sendCommandLine();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
|
||||
send('\x03');
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
window.setInterval(updateIoStatus, 1000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
<style>
|
||||
.clicd-info{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
|
||||
.clicd-info *{box-sizing:border-box}
|
||||
.clicd-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:12px}
|
||||
.clicd-mini{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:10px}
|
||||
.clicd-mini-label{font-size:12px;color:#6b7280;margin-bottom:4px}
|
||||
.clicd-mini-value{font-size:16px;font-weight:600;color:#111827;word-break:break-all}
|
||||
.clicd-section{background:#fff;border:1px solid #e5e7eb;border-radius:6px;margin-top:12px;padding:14px}
|
||||
.clicd-section-title{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:15px;font-weight:700;margin-bottom:12px;color:#111827;min-width:0;flex-wrap:wrap}
|
||||
.clicd-section-title>span:first-child{min-width:0}
|
||||
.clicd-refresh{display:flex;align-items:center;justify-content:flex-end;gap:8px;font-size:12px;color:#6b7280;font-weight:400;flex-wrap:wrap;min-width:0;max-width:100%}
|
||||
.clicd-refresh label{display:inline-flex;align-items:center;gap:4px;min-width:0;white-space:nowrap}
|
||||
.clicd-refresh-select{height:28px;border:1px solid #d1d5db;border-radius:4px;background:#fff;color:#374151;padding:3px 6px;font-size:12px}
|
||||
.clicd-refresh-btn{height:28px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:3px 8px;font-size:12px;cursor:pointer;white-space:nowrap;max-width:96px;overflow:hidden;text-overflow:ellipsis}
|
||||
.clicd-refresh-btn[disabled]{opacity:.6;cursor:not-allowed}
|
||||
.clicd-gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
|
||||
.clicd-gauge{display:flex;align-items:center;gap:12px;min-height:92px}
|
||||
.clicd-ring{--p:0%;width:78px;height:78px;border-radius:50%;background:conic-gradient(#2f80ed var(--p),#e5e7eb 0);display:grid;place-items:center;flex:0 0 auto;position:relative}
|
||||
.clicd-ring:before{content:"";width:66px;height:66px;border-radius:50%;background:#fff;position:absolute}
|
||||
.clicd-ring span{position:relative;display:inline-flex;align-items:center;justify-content:center;max-width:62px;min-width:0;font-size:17px;font-weight:700;line-height:1;color:#111827;white-space:nowrap;text-align:center;background:#fff;border-radius:3px;padding:0 1px}
|
||||
.clicd-ring[data-tight="1"] span{font-size:15px}
|
||||
.clicd-ring[data-tight="2"] span{font-size:14px}
|
||||
.clicd-gauge-title{font-weight:700;color:#111827;margin-bottom:4px}
|
||||
.clicd-gauge-sub{font-size:12px;color:#6b7280;line-height:1.45}
|
||||
.clicd-progress{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden}
|
||||
.clicd-progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,#2f80ed,#10b981);transition:width .25s ease}
|
||||
.clicd-traffic-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;margin-top:8px;color:#374151;min-width:0}
|
||||
.clicd-traffic-row>div{min-width:0;word-break:break-word}
|
||||
.clicd-charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
|
||||
.clicd-chart{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff;min-height:190px;min-width:0;overflow:hidden}
|
||||
.clicd-chart-title{display:flex;justify-content:space-between;gap:8px;align-items:center;font-weight:700;margin-bottom:8px;color:#111827;min-width:0;flex-wrap:wrap}
|
||||
.clicd-chart-value{font-size:12px;color:#6b7280;font-weight:400;white-space:normal;overflow-wrap:anywhere;text-align:right}
|
||||
.clicd-chart canvas{width:100%;height:132px;display:block}
|
||||
.clicd-table{width:100%;border-collapse:collapse;background:#fff}
|
||||
.clicd-table th,.clicd-table td{border:1px solid #e5e7eb;padding:8px;text-align:left}
|
||||
.clicd-table th{width:16%;background:#f9fafb;color:#374151;font-weight:600}
|
||||
.clicd-debug{display:none;margin-top:10px;padding:8px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;border-radius:6px;font-size:12px}
|
||||
@media (max-width:640px){
|
||||
.clicd-info{padding:10px}
|
||||
.clicd-section-title{align-items:flex-start}
|
||||
.clicd-refresh{justify-content:flex-start;width:100%}
|
||||
.clicd-charts{grid-template-columns:1fr}
|
||||
.clicd-table th,.clicd-table td{display:block;width:100%}
|
||||
.clicd-ring{width:70px;height:70px}
|
||||
.clicd-ring:before{width:60px;height:60px}
|
||||
.clicd-ring span{max-width:56px;font-size:15px}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="clicd-info" data-clicd-info-root="1">
|
||||
<div class="clicd-head">
|
||||
<div class="clicd-mini">
|
||||
<div class="clicd-mini-label">实例名称</div>
|
||||
<div class="clicd-mini-value">{$container.name|default='-'}</div>
|
||||
</div>
|
||||
<div class="clicd-mini">
|
||||
<div class="clicd-mini-label">运行状态</div>
|
||||
<div class="clicd-mini-value">{$status_text|default='-'}</div>
|
||||
</div>
|
||||
<div class="clicd-mini">
|
||||
<div class="clicd-mini-label">SSH 地址</div>
|
||||
<div class="clicd-mini-value">{$ssh_host|default='-'}:{$ssh_port|default='-'}</div>
|
||||
</div>
|
||||
<div class="clicd-mini">
|
||||
<div class="clicd-mini-label">IPv6</div>
|
||||
<div class="clicd-mini-value">{$ipv6|default='-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clicd-section">
|
||||
<div class="clicd-section-title">
|
||||
<span>状态</span>
|
||||
<span class="clicd-refresh">
|
||||
<span>更新于 <span data-clicd-info="chart_time">-</span></span>
|
||||
<label>
|
||||
自动刷新
|
||||
<select class="clicd-refresh-select" id="clicd-info-refresh">
|
||||
<option value="0" selected>不刷新</option>
|
||||
<option value="10000">10 秒</option>
|
||||
<option value="60000">1 分钟</option>
|
||||
<option value="300000">5 分钟</option>
|
||||
<option value="600000">10 分钟</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="clicd-refresh-btn" type="button" id="clicd-info-refresh-now">立即刷新</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="clicd-gauges">
|
||||
<div class="clicd-gauge">
|
||||
<div class="clicd-ring" data-gauge="cpu_percent"><span><span data-clicd-info="cpu_percent">0</span>%</span></div>
|
||||
<div>
|
||||
<div class="clicd-gauge-title">CPU</div>
|
||||
<div class="clicd-gauge-sub" data-clicd-info="cpu_detail">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clicd-gauge">
|
||||
<div class="clicd-ring" data-gauge="mem_percent"><span><span data-clicd-info="mem_percent">0</span>%</span></div>
|
||||
<div>
|
||||
<div class="clicd-gauge-title">内存</div>
|
||||
<div class="clicd-gauge-sub" data-clicd-info="mem_detail">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clicd-gauge">
|
||||
<div class="clicd-ring" data-gauge="load_percent"><span><span data-clicd-info="load_percent">0</span>%</span></div>
|
||||
<div>
|
||||
<div class="clicd-gauge-title">负载</div>
|
||||
<div class="clicd-gauge-sub" data-clicd-info="load_detail">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clicd-gauge">
|
||||
<div class="clicd-ring" data-gauge="disk_percent"><span><span data-clicd-info="disk_percent">0</span>%</span></div>
|
||||
<div>
|
||||
<div class="clicd-gauge-title">磁盘</div>
|
||||
<div class="clicd-gauge-sub" data-clicd-info="disk_detail">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px">
|
||||
<div class="clicd-traffic-row">
|
||||
<div>月流量</div>
|
||||
<div><span data-clicd-info="traffic_used_text">{$traffic_used_text|default='-'}</span> / <span data-clicd-info="traffic_limit_text">{$traffic_limit_text|default='-'}</span></div>
|
||||
</div>
|
||||
<div class="clicd-progress"><span data-progress="traffic_percent"></span></div>
|
||||
<div class="clicd-traffic-row" style="font-size:12px;color:#6b7280">
|
||||
<div>入站 <span data-clicd-info="traffic_in_text">{$traffic_in_text|default='-'}</span></div>
|
||||
<div>出站 <span data-clicd-info="traffic_out_text">{$traffic_out_text|default='-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clicd-section">
|
||||
<div class="clicd-section-title"><span>统计信息</span></div>
|
||||
<div class="clicd-charts">
|
||||
<div class="clicd-chart">
|
||||
<div class="clicd-chart-title">CPU 使用率 <span class="clicd-chart-value" data-clicd-info="cpu_detail">-</span></div>
|
||||
<canvas data-chart="cpu_percent"></canvas>
|
||||
</div>
|
||||
<div class="clicd-chart">
|
||||
<div class="clicd-chart-title">内存使用 <span class="clicd-chart-value" data-clicd-info="mem_detail">-</span></div>
|
||||
<canvas data-chart="mem_percent"></canvas>
|
||||
</div>
|
||||
<div class="clicd-chart">
|
||||
<div class="clicd-chart-title">网络流量 <span class="clicd-chart-value"><span data-clicd-info="net_in_rate">0 B/s</span> / <span data-clicd-info="net_out_rate">0 B/s</span></span></div>
|
||||
<canvas data-chart="network"></canvas>
|
||||
</div>
|
||||
<div class="clicd-chart">
|
||||
<div class="clicd-chart-title">磁盘 IO <span class="clicd-chart-value"><span data-clicd-info="disk_read_rate">0 B/s</span> / <span data-clicd-info="disk_write_rate">0 B/s</span></span></div>
|
||||
<canvas data-chart="diskio"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clicd-section">
|
||||
<div class="clicd-section-title"><span>实例信息</span></div>
|
||||
<table class="clicd-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>IPv4</th><td>{$ipv4|default='-'}</td>
|
||||
<th>用户名</th><td>root</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SSH 端口</th><td>{$ssh_port|default='-'}</td>
|
||||
<th>SSH 密码</th><td>{$ssh_password|default='-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>CPU</th><td>{$vcpu|default='-'} 核</td>
|
||||
<th>内存</th><td>{$ram_mb|default='-'} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>硬盘</th><td>{$disk_gb|default='-'} GB</td>
|
||||
<th>带宽</th><td>{$bandwidth|default='-'} Mbps</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>到期时间</th><td colspan="3">{$expires_at|default='-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="clicd-debug" id="clicd-info-debug"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var root = document.querySelector('[data-clicd-info-root="1"]:not([data-info-bound="1"])');
|
||||
if (!root) return;
|
||||
root.setAttribute('data-info-bound', '1');
|
||||
|
||||
var history = {
|
||||
cpu_percent: [],
|
||||
mem_percent: [],
|
||||
network_in: [],
|
||||
network_out: [],
|
||||
disk_read: [],
|
||||
disk_write: []
|
||||
};
|
||||
var maxPoints = 18;
|
||||
var refreshTimer = null;
|
||||
|
||||
function endpoint() {
|
||||
return "{$MODULE_CUSTOM_API}";
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
var n = parseFloat(value);
|
||||
return isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function push(name, value) {
|
||||
history[name].push(number(value));
|
||||
if (history[name].length > maxPoints) history[name].shift();
|
||||
}
|
||||
|
||||
function setText(key, value) {
|
||||
root.querySelectorAll('[data-clicd-info="' + key + '"]').forEach(function(node){
|
||||
if (typeof value !== 'object') node.textContent = value;
|
||||
});
|
||||
}
|
||||
|
||||
function setGauge(key, value) {
|
||||
var pct = Math.max(0, Math.min(100, number(value)));
|
||||
root.querySelectorAll('[data-gauge="' + key + '"]').forEach(function(node){
|
||||
node.style.setProperty('--p', pct + '%');
|
||||
});
|
||||
}
|
||||
|
||||
function fitGaugeText() {
|
||||
root.querySelectorAll('.clicd-ring').forEach(function(ring){
|
||||
var label = ring.querySelector('span');
|
||||
if (!label) return;
|
||||
ring.removeAttribute('data-tight');
|
||||
if (label.scrollWidth > label.clientWidth) {
|
||||
ring.setAttribute('data-tight', '1');
|
||||
}
|
||||
if (label.scrollWidth > label.clientWidth) {
|
||||
ring.setAttribute('data-tight', '2');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setProgress(key, value) {
|
||||
var pct = Math.max(0, Math.min(100, number(value)));
|
||||
root.querySelectorAll('[data-progress="' + key + '"]').forEach(function(node){
|
||||
node.style.width = pct + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function draw(canvas, series, colors, maxValue) {
|
||||
if (!canvas || !canvas.getContext) return;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var ratio = window.devicePixelRatio || 1;
|
||||
var width = Math.max(220, Math.floor(rect.width || canvas.clientWidth || 220));
|
||||
var height = Math.max(120, Math.floor(rect.height || canvas.clientHeight || 132));
|
||||
if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
|
||||
canvas.width = width * ratio;
|
||||
canvas.height = height * ratio;
|
||||
}
|
||||
var ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.lineWidth = 1;
|
||||
for (var i = 1; i < 4; i++) {
|
||||
var y = Math.round((height / 4) * i);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
series.forEach(function(values, idx){
|
||||
if (!values.length) return;
|
||||
var color = colors[idx] || '#2f80ed';
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
if (values.length === 1) {
|
||||
var singleY = height - (Math.max(0, Math.min(maxValue, number(values[0]))) / maxValue) * (height - 6) - 3;
|
||||
ctx.moveTo(0, singleY);
|
||||
ctx.lineTo(width, singleY);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(width - 8, singleY, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
return;
|
||||
}
|
||||
values.forEach(function(value, i){
|
||||
var x = values.length <= 1 ? width : (i / (values.length - 1)) * width;
|
||||
var y = height - (Math.max(0, Math.min(maxValue, number(value))) / maxValue) * (height - 6) - 3;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
draw(root.querySelector('[data-chart="cpu_percent"]'), [history.cpu_percent], ['#2f80ed'], 100);
|
||||
draw(root.querySelector('[data-chart="mem_percent"]'), [history.mem_percent], ['#10b981'], 100);
|
||||
var netMax = Math.max(1, Math.max.apply(null, history.network_in.concat(history.network_out, [1])));
|
||||
draw(root.querySelector('[data-chart="network"]'), [history.network_in, history.network_out], ['#2f80ed', '#f59e0b'], netMax);
|
||||
var ioMax = Math.max(1, Math.max.apply(null, history.disk_read.concat(history.disk_write, [1])));
|
||||
draw(root.querySelector('[data-chart="diskio"]'), [history.disk_read, history.disk_write], ['#10b981', '#ef4444'], ioMax);
|
||||
}
|
||||
|
||||
function showInfoError(text) {
|
||||
var debug = document.getElementById('clicd-info-debug');
|
||||
if (debug) {
|
||||
debug.style.display = 'block';
|
||||
debug.textContent = text || 'info load failed';
|
||||
}
|
||||
}
|
||||
|
||||
function loadInfo() {
|
||||
var refreshNow = document.getElementById('clicd-info-refresh-now');
|
||||
if (refreshNow) refreshNow.disabled = true;
|
||||
var body = new URLSearchParams();
|
||||
body.set('id', '{$service_id}');
|
||||
body.set('func', 'infoData');
|
||||
fetch(endpoint(), {
|
||||
method:'POST',
|
||||
headers:{
|
||||
'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'Authorization':'JWT {$Think.get.jwt}'
|
||||
},
|
||||
credentials:'same-origin',
|
||||
body: body.toString()
|
||||
})
|
||||
.then(function(res){ return res.json(); })
|
||||
.then(function(json){
|
||||
if (!json || (json.status !== 200 && json.status !== 'success') || !json.data) {
|
||||
showInfoError(json && json.msg ? json.msg : 'info load failed');
|
||||
return;
|
||||
}
|
||||
var data = json.data;
|
||||
Object.keys(data).forEach(function(key){ setText(key, data[key]); });
|
||||
['cpu_percent','mem_percent','load_percent','disk_percent'].forEach(function(key){ setGauge(key, data[key]); });
|
||||
fitGaugeText();
|
||||
setProgress('traffic_percent', data.traffic_percent);
|
||||
push('cpu_percent', data.cpu_percent);
|
||||
push('mem_percent', data.mem_percent);
|
||||
push('network_in', data.net_in_bps);
|
||||
push('network_out', data.net_out_bps);
|
||||
push('disk_read', data.disk_read_bps);
|
||||
push('disk_write', data.disk_write_bps);
|
||||
redraw();
|
||||
})
|
||||
.catch(function(error){
|
||||
showInfoError(error && error.message ? error.message : 'info request failed');
|
||||
})
|
||||
.finally(function(){
|
||||
if (refreshNow) refreshNow.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
loadInfo();
|
||||
var refreshSelect = document.getElementById('clicd-info-refresh');
|
||||
var refreshNow = document.getElementById('clicd-info-refresh-now');
|
||||
if (refreshNow) {
|
||||
refreshNow.addEventListener('click', loadInfo);
|
||||
}
|
||||
if (refreshSelect) {
|
||||
refreshSelect.addEventListener('change', function(){
|
||||
if (refreshTimer) {
|
||||
window.clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
var ms = number(refreshSelect.value);
|
||||
if (ms > 0) {
|
||||
loadInfo();
|
||||
refreshTimer = window.setInterval(loadInfo, ms);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.addEventListener('resize', function(){ window.setTimeout(function(){ fitGaugeText(); redraw(); }, 50); });
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,328 @@
|
||||
<style>
|
||||
.clicd-nat-panel{font-size:14px;color:#1f2937}
|
||||
.clicd-nat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
|
||||
.clicd-nat-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}
|
||||
.clicd-nat-label{color:#6b7280;font-size:12px;margin-bottom:4px}
|
||||
.clicd-nat-value{font-size:18px;font-weight:600;word-break:break-all}
|
||||
.clicd-nat-title{font-weight:600;margin:18px 0 8px}
|
||||
.clicd-nat-muted{color:#6b7280}
|
||||
.clicd-nat-form{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px;margin-top:8px}
|
||||
.clicd-nat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;align-items:end}
|
||||
.clicd-nat-field label{display:block;color:#6b7280;font-size:12px;margin-bottom:4px}
|
||||
.clicd-nat-input,.clicd-nat-select{width:100%;height:34px;border:1px solid #d1d5db;border-radius:4px;padding:6px 8px;box-sizing:border-box}
|
||||
.clicd-nat-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.clicd-nat-btn{height:34px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
|
||||
.clicd-nat-btn[disabled]{opacity:.6;cursor:not-allowed}
|
||||
.clicd-nat-btn-secondary{border-color:#d1d5db;background:#fff;color:#374151}
|
||||
.clicd-nat-btn-danger{border-color:#dc2626;background:#dc2626;color:#fff}
|
||||
.clicd-nat-list{display:flex;flex-direction:column;gap:10px;margin-top:8px}
|
||||
.clicd-nat-item{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px}
|
||||
.clicd-nat-message{border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:6px;padding:10px 12px;margin-bottom:12px;display:none}
|
||||
.clicd-nat-message.error{border-color:#fecaca;background:#fef2f2;color:#b91c1c}
|
||||
.clicd-nat-debug{margin-top:12px;border:1px dashed #d1d5db;border-radius:6px;background:#f9fafb;padding:10px;color:#374151;white-space:pre-wrap;font-size:12px;display:none}
|
||||
.clicd-nat-modal-mask{position:fixed;inset:0;background:rgba(15,23,42,.42);display:none;align-items:center;justify-content:center;z-index:9999;padding:16px}
|
||||
.clicd-nat-modal{width:min(420px,100%);background:#fff;border-radius:6px;border:1px solid #e5e7eb;box-shadow:0 18px 48px rgba(15,23,42,.22);padding:16px}
|
||||
.clicd-nat-modal-title{font-size:16px;font-weight:700;color:#111827;margin-bottom:8px}
|
||||
.clicd-nat-modal-body{font-size:14px;color:#4b5563;line-height:1.6;margin-bottom:14px}
|
||||
.clicd-nat-modal-actions{display:flex;justify-content:flex-end;gap:8px}
|
||||
</style>
|
||||
|
||||
<div class="clicd-nat-panel" id="clicd-nat-panel" data-service-id="{$service_id}" data-area-key="{$area_key}">
|
||||
<div class="clicd-nat-message" id="clicd-nat-message"></div>
|
||||
|
||||
<div class="clicd-nat-grid">
|
||||
<div class="clicd-nat-card">
|
||||
<div class="clicd-nat-label">实例名称</div>
|
||||
<div class="clicd-nat-value">{$container_name}</div>
|
||||
</div>
|
||||
<div class="clicd-nat-card">
|
||||
<div class="clicd-nat-label">公网地址</div>
|
||||
<div class="clicd-nat-value">{$nat_host}</div>
|
||||
</div>
|
||||
<div class="clicd-nat-card">
|
||||
<div class="clicd-nat-label">SSH 端口</div>
|
||||
<div class="clicd-nat-value">{$ssh_port}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clicd-nat-title">添加端口映射</div>
|
||||
<div class="clicd-nat-form">
|
||||
<div class="clicd-nat-row">
|
||||
<div class="clicd-nat-field">
|
||||
<label>公网端口</label>
|
||||
<input class="clicd-nat-input" id="clicd-add-host-port" type="number" min="1" max="65535" placeholder="61320">
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>容器端口</label>
|
||||
<input class="clicd-nat-input" id="clicd-add-container-port" type="number" min="1" max="65535" placeholder="8080">
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>协议</label>
|
||||
<select class="clicd-nat-select" id="clicd-add-protocol">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>说明</label>
|
||||
<input class="clicd-nat-input" id="clicd-add-description" type="text" placeholder="HTTP">
|
||||
</div>
|
||||
<div class="clicd-nat-actions">
|
||||
<button class="clicd-nat-btn" type="button" data-clicd-action="add">添加</button>
|
||||
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="random-port">获取随机端口</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clicd-nat-title">现有端口映射</div>
|
||||
<div id="clicd-nat-list" class="clicd-nat-list">
|
||||
{if condition="empty($port_mappings)"}
|
||||
<div class="clicd-nat-form clicd-nat-muted">暂无端口映射</div>
|
||||
{else/}
|
||||
{foreach name="port_mappings" item="mapping"}
|
||||
<div class="clicd-nat-item" data-index="{$mapping.index}">
|
||||
<div class="clicd-nat-row">
|
||||
<div class="clicd-nat-field">
|
||||
<label>索引</label>
|
||||
<div class="clicd-nat-value">{$mapping.index}</div>
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>公网访问</label>
|
||||
<div class="clicd-nat-value">{$nat_host}:{$mapping.host_port}</div>
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>公网端口</label>
|
||||
<input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="{$mapping.host_port}">
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>容器端口</label>
|
||||
<input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="{$mapping.container_port}">
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>协议</label>
|
||||
<select class="clicd-nat-select" data-field="protocol">
|
||||
<option value="tcp" {$mapping.tcp_selected}>TCP</option>
|
||||
<option value="udp" {$mapping.udp_selected}>UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="clicd-nat-field">
|
||||
<label>说明</label>
|
||||
<input class="clicd-nat-input" data-field="description" type="text" value="{$mapping.description}">
|
||||
</div>
|
||||
<div class="clicd-nat-actions">
|
||||
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">保存</button>
|
||||
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/foreach}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<pre class="clicd-nat-debug" id="clicd-nat-debug"></pre>
|
||||
<div class="clicd-nat-modal-mask" id="clicd-nat-delete-modal">
|
||||
<div class="clicd-nat-modal">
|
||||
<div class="clicd-nat-modal-title">确认删除</div>
|
||||
<div class="clicd-nat-modal-body" id="clicd-nat-delete-text">确认删除该端口映射?</div>
|
||||
<div class="clicd-nat-modal-actions">
|
||||
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" id="clicd-nat-delete-cancel">取消</button>
|
||||
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" id="clicd-nat-delete-confirm">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var panel = document.getElementById('clicd-nat-panel');
|
||||
if (!panel || panel.getAttribute('data-bound') === '1') return;
|
||||
panel.setAttribute('data-bound', '1');
|
||||
|
||||
var message = document.getElementById('clicd-nat-message');
|
||||
var debugBox = document.getElementById('clicd-nat-debug');
|
||||
var list = document.getElementById('clicd-nat-list');
|
||||
var natHost = '{$nat_host}';
|
||||
var deleteModal = document.getElementById('clicd-nat-delete-modal');
|
||||
var deleteText = document.getElementById('clicd-nat-delete-text');
|
||||
var deleteCancel = document.getElementById('clicd-nat-delete-cancel');
|
||||
var deleteConfirm = document.getElementById('clicd-nat-delete-confirm');
|
||||
var pendingDeletePayload = null;
|
||||
|
||||
function showMessage(type, text) {
|
||||
message.className = 'clicd-nat-message' + (type === 'error' ? ' error' : '');
|
||||
message.style.display = 'block';
|
||||
message.textContent = text || '';
|
||||
}
|
||||
|
||||
function showDebug(data) {
|
||||
debugBox.style.display = 'block';
|
||||
debugBox.textContent = JSON.stringify(data || {}, null, 2);
|
||||
}
|
||||
|
||||
function endpoint() {
|
||||
return "{$MODULE_CUSTOM_API}";
|
||||
}
|
||||
|
||||
function field(item, name) {
|
||||
return item.querySelector('[data-field="' + name + '"]');
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
panel.querySelectorAll('button').forEach(function(btn){ btn.disabled = !!busy; });
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
list.innerHTML = '<div class="clicd-nat-form clicd-nat-muted">暂无端口映射</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map(function(item) {
|
||||
var protocol = (item.protocol || 'tcp').toLowerCase();
|
||||
var desc = escapeHtml(item.description || '');
|
||||
var index = escapeHtml(String(item.index));
|
||||
var hostPort = escapeHtml(String(item.host_port || ''));
|
||||
var containerPort = escapeHtml(String(item.container_port || ''));
|
||||
return '<div class="clicd-nat-item" data-index="' + index + '">' +
|
||||
'<div class="clicd-nat-row">' +
|
||||
'<div class="clicd-nat-field"><label>索引</label><div class="clicd-nat-value">' + index + '</div></div>' +
|
||||
'<div class="clicd-nat-field"><label>公网访问</label><div class="clicd-nat-value">' + escapeHtml(natHost) + ':' + hostPort + '</div></div>' +
|
||||
'<div class="clicd-nat-field"><label>公网端口</label><input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="' + hostPort + '"></div>' +
|
||||
'<div class="clicd-nat-field"><label>容器端口</label><input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="' + containerPort + '"></div>' +
|
||||
'<div class="clicd-nat-field"><label>协议</label><select class="clicd-nat-select" data-field="protocol">' +
|
||||
'<option value="tcp"' + (protocol === 'tcp' ? ' selected' : '') + '>TCP</option>' +
|
||||
'<option value="udp"' + (protocol === 'udp' ? ' selected' : '') + '>UDP</option>' +
|
||||
'</select></div>' +
|
||||
'<div class="clicd-nat-field"><label>说明</label><input class="clicd-nat-input" data-field="description" type="text" value="' + desc + '"></div>' +
|
||||
'<div class="clicd-nat-actions"><button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">保存</button>' +
|
||||
'<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">删除</button></div>' +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function request(action, payload, silent) {
|
||||
setBusy(true);
|
||||
try {
|
||||
var funcMap = {
|
||||
'random-port': 'randomPort',
|
||||
'add': 'addNat',
|
||||
'update': 'updateNat',
|
||||
'delete': 'deleteNat',
|
||||
'list': 'natList'
|
||||
};
|
||||
var body = new URLSearchParams();
|
||||
body.set('id', panel.getAttribute('data-service-id') || '');
|
||||
body.set('func', funcMap[action] || action);
|
||||
Object.keys(payload || {}).forEach(function(key){ body.set(key, payload[key]); });
|
||||
var res = await fetch(endpoint(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'Authorization': 'JWT {$Think.get.jwt}'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString()
|
||||
});
|
||||
var text = await res.text();
|
||||
var data;
|
||||
try { data = JSON.parse(text); } catch (e) { data = {status:'error', msg:'\u975e JSON \u54cd\u5e94: ' + text}; }
|
||||
showDebug((data.data && data.data.debug) || data.debug || data);
|
||||
if (data.status === 200 || data.status === 'success') {
|
||||
if (!silent) {
|
||||
showMessage('success', data.msg || '\u64cd\u4f5c\u6210\u529f');
|
||||
}
|
||||
if (data.data && data.data.port) {
|
||||
document.getElementById('clicd-add-host-port').value = data.data.port;
|
||||
}
|
||||
if (data.data && Array.isArray(data.data.port_mappings)) {
|
||||
renderList(data.data.port_mappings);
|
||||
} else if (action !== 'random-port') {
|
||||
request('list', {}, true);
|
||||
}
|
||||
} else {
|
||||
showMessage('error', data.msg || '\u64cd\u4f5c\u5931\u8d25');
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('error', e.message || '\u8bf7\u6c42\u5931\u8d25');
|
||||
showDebug({error: String(e)});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteModal(payload) {
|
||||
pendingDeletePayload = payload;
|
||||
if (deleteText) {
|
||||
deleteText.textContent = '\u786e\u8ba4\u5220\u9664\u7aef\u53e3\u6620\u5c04 ' + natHost + ':' + (payload.host_port || '-') + ' -> ' + (payload.container_port || '-') + '/' + (payload.protocol || 'tcp') + ' \u5417\uff1f';
|
||||
}
|
||||
if (deleteModal) {
|
||||
deleteModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
pendingDeletePayload = null;
|
||||
if (deleteModal) {
|
||||
deleteModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
panel.addEventListener('click', function(event) {
|
||||
var button = event.target.closest('[data-clicd-action]');
|
||||
if (!button) return;
|
||||
var action = button.getAttribute('data-clicd-action');
|
||||
if (action === 'random-port') {
|
||||
request('random-port', {});
|
||||
return;
|
||||
}
|
||||
if (action === 'add') {
|
||||
request('add', {
|
||||
host_port: document.getElementById('clicd-add-host-port').value,
|
||||
container_port: document.getElementById('clicd-add-container-port').value,
|
||||
protocol: document.getElementById('clicd-add-protocol').value,
|
||||
description: document.getElementById('clicd-add-description').value
|
||||
});
|
||||
return;
|
||||
}
|
||||
var item = button.closest('.clicd-nat-item');
|
||||
if (!item) return;
|
||||
var payload = {
|
||||
index: item.getAttribute('data-index'),
|
||||
host_port: field(item, 'host_port') ? field(item, 'host_port').value : '',
|
||||
container_port: field(item, 'container_port') ? field(item, 'container_port').value : '',
|
||||
protocol: field(item, 'protocol') ? field(item, 'protocol').value : 'tcp',
|
||||
description: field(item, 'description') ? field(item, 'description').value : ''
|
||||
};
|
||||
if (action === 'delete') {
|
||||
openDeleteModal(payload);
|
||||
return;
|
||||
}
|
||||
request(action, payload);
|
||||
});
|
||||
|
||||
if (deleteCancel) {
|
||||
deleteCancel.addEventListener('click', closeDeleteModal);
|
||||
}
|
||||
if (deleteModal) {
|
||||
deleteModal.addEventListener('click', function(event){
|
||||
if (event.target === deleteModal) closeDeleteModal();
|
||||
});
|
||||
}
|
||||
if (deleteConfirm) {
|
||||
deleteConfirm.addEventListener('click', function(){
|
||||
if (!pendingDeletePayload) return;
|
||||
var payload = pendingDeletePayload;
|
||||
closeDeleteModal();
|
||||
request('delete', payload);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -2,56 +2,97 @@
|
||||
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
|
||||
</p>
|
||||
|
||||
<h1 align="center">CLICD</h1>
|
||||
<h1 align="center">CLICD <sub></sub></h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.22-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111111">
|
||||
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-Supported-111111?style=flat-square">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-Supported-EE0000?style=flat-square">
|
||||
</p>
|
||||
|
||||
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
|
||||
<p align="center">
|
||||
<img alt="WebSSH" src="https://img.shields.io/badge/WebSSH-Built--in-009688?style=flat-square">
|
||||
<img alt="VNC" src="https://img.shields.io/badge/VNC-Supported-7B1FA2?style=flat-square">
|
||||
<img alt="IPv6" src="https://img.shields.io/badge/IPv6-Native-1976D2?style=flat-square">
|
||||
<img alt="NAT" src="https://img.shields.io/badge/NAT-Port_Forwarding-FF9800?style=flat-square">
|
||||
<img alt="REST API" src="https://img.shields.io/badge/API-REST-4CAF50?style=flat-square">
|
||||
<img alt="Multi User" src="https://img.shields.io/badge/Multi_User-Supported-8E24AA?style=flat-square">
|
||||
<img alt="Traffic Control" src="https://img.shields.io/badge/Traffic-Control-795548?style=flat-square">
|
||||
<img alt="Security Alert" src="https://img.shields.io/badge/Security-Alert-orange?style=flat-square">
|
||||
<img alt="CLI" src="https://img.shields.io/badge/CLI-Mode-424242?style=flat-square">
|
||||
<img alt="TLS" src="https://img.shields.io/badge/TLS-Let's_Encrypt-003A70?style=flat-square&logo=letsencrypt&logoColor=white">
|
||||
</p>
|
||||
|
||||
## 功能介绍
|
||||
CLICD is a lightweight virtualization management panel for LXC and KVM. It combines a web console, CLI tools, REST API, NAT/IPv6 networking, WebSSH/WebVNC access, resource quotas, traffic limits, snapshots, delegated sub-user access, and security alerts into a single deployable service.
|
||||
|
||||
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
|
||||
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
|
||||
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
||||
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
|
||||
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
|
||||
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
|
||||
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
|
||||
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
||||
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
||||
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,集成 Web 控制台、CLI、REST API、NAT/IPv6 网络、WebSSH/WebVNC、资源配额、流量限制、快照、子用户授权和安全告警能力,适合 VPS 商家、实验室、开发者自建虚拟化节点以及需要批量开通容器的场景。
|
||||
|
||||
## 技术栈
|
||||

|
||||
|
||||
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
||||
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
||||
- Build: GitHub Actions, Node.js 20, Go 1.22
|
||||
## Installation / 安装
|
||||
|
||||
## 安装
|
||||
|
||||
一键安装:
|
||||
One-click Install / 一键安装:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
一键卸载:
|
||||
One-click Uninstall / 一键卸载:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## Features / 功能介绍
|
||||
|
||||
### English
|
||||
|
||||
| Area | What CLICD provides |
|
||||
| --- | --- |
|
||||
| Virtualization | Manage LXC containers and KVM virtual machines from one panel, including create, reinstall, start, stop, restart, delete, password reset, expiry control, and batch actions. |
|
||||
| Images and templates | Built-in template and image management for Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other common distributions. Images can be enabled, disabled, downloaded, cancelled, or removed from cache. |
|
||||
| Networking | NAT4 port quotas, random available port allocation, TCP/UDP port mappings, public IPv4 pool management, IPv6 prefix detection, IPv6 status checks, and per-container IPv6 assignment. |
|
||||
| Resource control | CPU, memory, disk, swap, bandwidth usage, traffic reset, traffic limit, and resource limit management, with automatic shutdown behavior for expired or over-quota containers. |
|
||||
| Console access | Browser-based WebSSH and WebVNC ticket access, so users can open terminals or consoles without manually exchanging credentials. |
|
||||
| Snapshots | Snapshot overview, per-container snapshots, create/delete/restore operations, scheduled snapshots, and quota controls. |
|
||||
| Security | Conntrack-based security alerts for port scans, lateral scans, brute-force behavior, SMTP abuse, UDP reflection, mining ports, proxy/VPN/Tor usage, plus security logs, summaries, and configurable settings. |
|
||||
| Accounts and audit | Delegated sub-user links, sub-user password rotation, per-user container permissions, audit logs, login logs, and API key management. |
|
||||
| Automation | Versioned REST API under `/api/v1`, task queue endpoints, batch create/action endpoints, and a Mofang finance integration module packaged automatically by GitHub Actions. |
|
||||
| Operations | Dashboard statistics, host resource overview, routing overview, swap management, CLI-only mode, and release artifacts generated by GitHub Actions. |
|
||||
|
||||
### 中文
|
||||
|
||||
| 模块 | CLICD 提供的能力 |
|
||||
| --- | --- |
|
||||
| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 |
|
||||
| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 |
|
||||
| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 |
|
||||
| 资源限制 | 支持 CPU、内存、磁盘、Swap、独立上行/下行带宽、读/写 I/O 限速、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
|
||||
| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 |
|
||||
| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 |
|
||||
| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 |
|
||||
| 账号与审计 | 支持子用户管理链接、子用户密码轮换、按容器授权、操作日志、登录日志和 API Key 管理,适合分发给下游用户或拼车用户。 |
|
||||
| 自动化接入 | 全量接口统一使用 `/api/v1`,覆盖任务队列、容器、镜像、网络、流量、安全、批量创建和批量操作;同时提供魔方财务对接模块,并由 GitHub Actions 自动打包发布。 |
|
||||
| 运维入口 | 提供总览统计、主机资源、路由概览、Swap 管理、CLI-only 模式和 GitHub Actions 自动发布产物,便于在小型节点上长期维护。 |
|
||||
|
||||
## Technology Stack / 技术栈
|
||||
|
||||
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
||||
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
||||
- Build: GitHub Actions, Node.js 20, Go 1.24
|
||||
|
||||
## Preview / 预览
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
## Disclaimer/免责声明
|
||||
@@ -71,10 +112,11 @@ This open-source software is intended solely for educational purposes, specifica
|
||||
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
|
||||
|
||||
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
|
||||
## Thanks/鸣谢
|
||||
|
||||
## Thanks / 鸣谢
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — 一个专注于服务器的社区
|
||||
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
||||
@@ -83,4 +125,4 @@ This open-source software is intended solely for educational purposes, specifica
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
</a>
|
||||
|
||||
+4
-6
@@ -1,18 +1,16 @@
|
||||
module clicd
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.5
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/term v0.37.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/sys v0.45.0
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
|
||||
+6
-6
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
|
||||
@@ -90,7 +90,7 @@ func hasScope(r *http.Request, scope string) bool {
|
||||
|
||||
func subUserScopeAllowed(scope string) bool {
|
||||
switch scope {
|
||||
case "container:read", "container:power", "container:reinstall", "container:network",
|
||||
case "container:read", "container:power", "container:reinstall", "container:password", "container:network",
|
||||
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
|
||||
"terminal:ssh", "terminal:vnc":
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
func generateFirewallRuleID() string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 8)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
DefaultAction *string `json:"default_action"`
|
||||
Rules *[]config.FirewallRule `json:"rules"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
oldEnabled := c.FirewallEnabled
|
||||
oldDefaultAction := c.FirewallDefaultAction
|
||||
oldRules := append([]config.FirewallRule(nil), c.FirewallRules...)
|
||||
|
||||
if req.Enabled != nil {
|
||||
c.FirewallEnabled = *req.Enabled
|
||||
}
|
||||
if req.DefaultAction != nil {
|
||||
action := normalizeFirewallDefaultAction(*req.DefaultAction)
|
||||
if action == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"})
|
||||
return
|
||||
}
|
||||
c.FirewallDefaultAction = action
|
||||
} else if strings.TrimSpace(c.FirewallDefaultAction) == "" {
|
||||
c.FirewallDefaultAction = "DROP"
|
||||
}
|
||||
if req.Rules != nil {
|
||||
// Validate and assign IDs to new rules
|
||||
rules := *req.Rules
|
||||
for i := range rules {
|
||||
rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
|
||||
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
|
||||
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action))
|
||||
rules[i].Network = normalizeFirewallNetwork(rules[i].Network)
|
||||
rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP)
|
||||
rules[i].Port = strings.TrimSpace(rules[i].Port)
|
||||
|
||||
if rules[i].Network == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"})
|
||||
return
|
||||
}
|
||||
if rules[i].Direction != "in" && rules[i].Direction != "out" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
|
||||
return
|
||||
}
|
||||
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" && rules[i].Protocol != "icmp" && rules[i].Protocol != "all" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid protocol: " + rules[i].Protocol})
|
||||
return
|
||||
}
|
||||
if rules[i].Action != "ACCEPT" && rules[i].Action != "DROP" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
|
||||
return
|
||||
}
|
||||
if rules[i].SourceIP != "" {
|
||||
if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") {
|
||||
rules[i].ID = generateFirewallRuleID()
|
||||
}
|
||||
// Validate port spec
|
||||
if rules[i].Port != "" {
|
||||
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"})
|
||||
return
|
||||
}
|
||||
if err := validatePortSpec(rules[i].Port); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.FirewallRules = rules
|
||||
}
|
||||
|
||||
// Apply firewall rules to iptables if container is running
|
||||
if c.Status == "running" {
|
||||
if err := lxc.ApplyFirewallRules(id); err != nil {
|
||||
c.FirewallEnabled = oldEnabled
|
||||
c.FirewallDefaultAction = oldDefaultAction
|
||||
c.FirewallRules = oldRules
|
||||
_ = lxc.ApplyFirewallRules(id)
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else if !c.FirewallEnabled {
|
||||
// If disabled and not running, clean any lingering rules
|
||||
lxc.CleanFirewallRules(id)
|
||||
}
|
||||
config.SaveConfig()
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Firewall updated",
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" || action == "DROP" {
|
||||
return action
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeFirewallNetwork(network string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
switch network {
|
||||
case "", "ipv4", "nat4":
|
||||
return "ipv4"
|
||||
case "ipv6":
|
||||
return "ipv6"
|
||||
case "all", "both":
|
||||
return "all"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func validatePortSpec(port string) error {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
|
||||
partCount := 0
|
||||
for _, part := range strings.Split(port, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return &portValidationError{port}
|
||||
}
|
||||
partCount++
|
||||
if strings.Contains(part, "-") {
|
||||
// Range
|
||||
bounds := strings.SplitN(part, "-", 2)
|
||||
lo, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
|
||||
if err != nil || lo < 1 || lo > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
hi, err := strconv.Atoi(strings.TrimSpace(bounds[1]))
|
||||
if err != nil || hi < 1 || hi > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
if hi < lo {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
} else {
|
||||
p, err := strconv.Atoi(part)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
}
|
||||
}
|
||||
if partCount > 15 {
|
||||
return &portValidationError{"too many ports; maximum 15 items per rule"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFirewallIPSpec(value string, network string) error {
|
||||
var addr netip.Addr
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr = prefix.Addr()
|
||||
} else {
|
||||
parsed, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr = parsed
|
||||
}
|
||||
switch network {
|
||||
case "ipv4":
|
||||
if !addr.Is4() {
|
||||
return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value}
|
||||
}
|
||||
case "ipv6":
|
||||
if !addr.Is6() || addr.Is4In6() {
|
||||
return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ipValidationError struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (e *ipValidationError) Error() string {
|
||||
return e.value
|
||||
}
|
||||
|
||||
type portValidationError struct {
|
||||
port string
|
||||
}
|
||||
|
||||
func (e *portValidationError) Error() string {
|
||||
return "invalid port value: " + e.port
|
||||
}
|
||||
@@ -3,11 +3,11 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
@@ -184,6 +184,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
|
||||
case action == "firewall" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
getFirewall(w, r, id)
|
||||
case action == "firewall" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
updateFirewall(w, r, id)
|
||||
case r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
@@ -202,10 +212,21 @@ func listContainers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg lxc.ContainerConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(body, &fields)
|
||||
if err := normalizeCreateResourceLimits(&cfg, fields); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
|
||||
return
|
||||
@@ -228,13 +249,38 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.DiskGB < 1 {
|
||||
cfg.DiskGB = 5
|
||||
}
|
||||
if cfg.PortMappingCount < 2 {
|
||||
if cfg.PortMappingCount < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
}
|
||||
if cfg.PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if cfg.IPv4Count < 0 || cfg.IPv6Count < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if cfg.IPv4Count > 64 || cfg.IPv6Count > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if !cfg.AssignIPv4 && len(cfg.PublicIPv4s) == 0 {
|
||||
cfg.IPv4Count = 0
|
||||
}
|
||||
if !cfg.AssignIPv6 && len(cfg.IPv6Addresses) == 0 {
|
||||
cfg.IPv6Count = 0
|
||||
}
|
||||
if !hasRequestedNetwork(cfg) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: noNetworkSelectedMessage})
|
||||
return
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
}
|
||||
@@ -242,6 +288,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg.ExpiresAt != "" {
|
||||
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
|
||||
if !ok {
|
||||
@@ -349,10 +399,14 @@ func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
|
||||
func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req struct {
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
IOMBps int `json:"io_speed_mbps"`
|
||||
BWMbps int `json:"network_bw_mbps"`
|
||||
VCPU *float64 `json:"vcpu"`
|
||||
RAMMB *int `json:"ram_mb"`
|
||||
IOMBps *int `json:"io_speed_mbps"`
|
||||
IOReadMBps *int `json:"io_read_mbps"`
|
||||
IOWriteMBps *int `json:"io_write_mbps"`
|
||||
BWMbps *int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps *int `json:"network_down_mbps"`
|
||||
NetworkUpMbps *int `json:"network_up_mbps"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
|
||||
@@ -367,21 +421,35 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
// Update config
|
||||
nextVCPU := c.VCPU
|
||||
nextRAMMB := c.RAMMB
|
||||
if req.VCPU > 0 {
|
||||
nextVCPU = req.VCPU
|
||||
if req.VCPU != nil {
|
||||
nextVCPU = *req.VCPU
|
||||
}
|
||||
if req.RAMMB > 0 {
|
||||
nextRAMMB = req.RAMMB
|
||||
if req.RAMMB != nil {
|
||||
nextRAMMB = *req.RAMMB
|
||||
}
|
||||
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
for name, value := range map[string]*int{
|
||||
"network_bw_mbps": req.BWMbps,
|
||||
"network_down_mbps": req.NetworkDownMbps,
|
||||
"network_up_mbps": req.NetworkUpMbps,
|
||||
"io_speed_mbps": req.IOMBps,
|
||||
"io_read_mbps": req.IOReadMBps,
|
||||
"io_write_mbps": req.IOWriteMBps,
|
||||
} {
|
||||
if err := rejectNegativeLimit(name, value); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.VCPU = nextVCPU
|
||||
c.RAMMB = nextRAMMB
|
||||
c.IOSpeedMBps = req.IOMBps
|
||||
c.NetworkBWMbps = req.BWMbps
|
||||
applyNetworkLimitPatch(c, req.BWMbps, req.NetworkDownMbps, req.NetworkUpMbps)
|
||||
applyIOLimitPatch(c, req.IOMBps, req.IOReadMBps, req.IOWriteMBps)
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
config.SaveConfig()
|
||||
|
||||
// Re-apply resource limits to running container
|
||||
@@ -399,30 +467,130 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg})
|
||||
}
|
||||
|
||||
func normalizeCreateResourceLimits(cfg *lxc.ContainerConfig, fields map[string]json.RawMessage) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if err := rejectNegativeCreateLimits(*cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
bwSet := hasJSONField(fields, "network_bw_mbps")
|
||||
downSet := hasJSONField(fields, "network_down_mbps")
|
||||
upSet := hasJSONField(fields, "network_up_mbps")
|
||||
if bwSet {
|
||||
if !downSet {
|
||||
cfg.NetworkDownMbps = cfg.NetworkBWMbps
|
||||
}
|
||||
if !upSet {
|
||||
cfg.NetworkUpMbps = cfg.NetworkBWMbps
|
||||
}
|
||||
}
|
||||
ioSet := hasJSONField(fields, "io_speed_mbps")
|
||||
readSet := hasJSONField(fields, "io_read_mbps")
|
||||
writeSet := hasJSONField(fields, "io_write_mbps")
|
||||
if ioSet {
|
||||
if !readSet {
|
||||
cfg.IOReadMBps = cfg.IOSpeedMBps
|
||||
}
|
||||
if !writeSet {
|
||||
cfg.IOWriteMBps = cfg.IOSpeedMBps
|
||||
}
|
||||
}
|
||||
cfg.NormalizeResourceAliases()
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectNegativeCreateLimits(cfg lxc.ContainerConfig) error {
|
||||
for name, value := range map[string]int{
|
||||
"network_bw_mbps": cfg.NetworkBWMbps,
|
||||
"network_down_mbps": cfg.NetworkDownMbps,
|
||||
"network_up_mbps": cfg.NetworkUpMbps,
|
||||
"io_speed_mbps": cfg.IOSpeedMBps,
|
||||
"io_read_mbps": cfg.IOReadMBps,
|
||||
"io_write_mbps": cfg.IOWriteMBps,
|
||||
} {
|
||||
if value < 0 {
|
||||
return fmt.Errorf("%s cannot be negative", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasJSONField(fields map[string]json.RawMessage, name string) bool {
|
||||
if fields == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := fields[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func rejectNegativeLimit(name string, value *int) error {
|
||||
if value != nil && *value < 0 {
|
||||
return fmt.Errorf("%s cannot be negative", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyNetworkLimitPatch(c *config.Container, legacy *int, down *int, up *int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
nextDown := c.NetworkDownMbps
|
||||
nextUp := c.NetworkUpMbps
|
||||
if legacy != nil {
|
||||
nextDown = *legacy
|
||||
nextUp = *legacy
|
||||
}
|
||||
if down != nil {
|
||||
nextDown = *down
|
||||
}
|
||||
if up != nil {
|
||||
nextUp = *up
|
||||
}
|
||||
c.NetworkDownMbps = nextDown
|
||||
c.NetworkUpMbps = nextUp
|
||||
c.NetworkBWMbps = config.LegacySymmetricLimit(nextDown, nextUp)
|
||||
}
|
||||
|
||||
func applyIOLimitPatch(c *config.Container, legacy *int, read *int, write *int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
nextRead := c.IOReadMBps
|
||||
nextWrite := c.IOWriteMBps
|
||||
if legacy != nil {
|
||||
nextRead = *legacy
|
||||
nextWrite = *legacy
|
||||
}
|
||||
if read != nil {
|
||||
nextRead = *read
|
||||
}
|
||||
if write != nil {
|
||||
nextWrite = *write
|
||||
}
|
||||
c.IOReadMBps = nextRead
|
||||
c.IOWriteMBps = nextWrite
|
||||
c.IOSpeedMBps = config.LegacySymmetricLimit(nextRead, nextWrite)
|
||||
}
|
||||
|
||||
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||
return
|
||||
}
|
||||
// Find a random unused port between 10000-65535
|
||||
used := map[int]bool{}
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
||||
start, end := config.NATPortRange()
|
||||
capacity := end - start + 1
|
||||
offset := 0
|
||||
if capacity > 0 {
|
||||
offset = int(time.Now().UnixNano() % int64(capacity))
|
||||
}
|
||||
// Also check all containers
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == id {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
}
|
||||
}
|
||||
// Try random ports
|
||||
for tries := 0; tries < 100; tries++ {
|
||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
||||
if !used[port] {
|
||||
for tries := 0; tries < capacity; tries++ {
|
||||
port := start + ((offset + tries) % capacity)
|
||||
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||
return
|
||||
}
|
||||
@@ -524,26 +692,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
|
||||
func validateSSHPassword(password string) error {
|
||||
if len(password) < 8 || len(password) > 64 {
|
||||
return fmt.Errorf("密码长度必须为 8-64 位")
|
||||
}
|
||||
hasLetter := false
|
||||
hasDigit := false
|
||||
for _, r := range password {
|
||||
if unicode.IsSpace(r) {
|
||||
return fmt.Errorf("密码不能包含空白字符")
|
||||
}
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
if !hasLetter || !hasDigit {
|
||||
return fmt.Errorf("密码至少需要包含字母和数字")
|
||||
}
|
||||
return nil
|
||||
return lxc.ValidateCustomSSHPassword(password)
|
||||
}
|
||||
|
||||
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
|
||||
|
||||
+469
-23
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -85,6 +86,7 @@ type HostDiskProbe struct {
|
||||
Serial string `json:"serial"`
|
||||
SizeBytes uint64 `json:"size_bytes"`
|
||||
Type string `json:"type"`
|
||||
Virtual bool `json:"virtual"`
|
||||
Rotational bool `json:"rotational"`
|
||||
Mountpoints []string `json:"mountpoints"`
|
||||
Health string `json:"health"`
|
||||
@@ -201,6 +203,7 @@ type NetworkInfo struct {
|
||||
TXBps float64 `json:"tx_bps"`
|
||||
PublicIPv4 string `json:"public_ipv4"`
|
||||
PublicIPv4Interface string `json:"public_ipv4_interface"`
|
||||
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
||||
PublicIPv6 string `json:"public_ipv6"`
|
||||
PublicIPv6Interface string `json:"public_ipv6_interface"`
|
||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||
@@ -217,6 +220,9 @@ var hostCPUMu sync.Mutex
|
||||
var lastHostCPU cpuTimes
|
||||
var hostIOMu sync.Mutex
|
||||
var lastHostIO hostIOSample
|
||||
var egressIPv4Mu sync.Mutex
|
||||
var cachedEgressIPv4 lxc.PublicIPInfo
|
||||
var cachedEgressIPv4At time.Time
|
||||
|
||||
type cpuTimes struct {
|
||||
Total uint64
|
||||
@@ -395,10 +401,11 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
|
||||
now := unixNano()
|
||||
|
||||
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
||||
publicIPv4 := lxc.DetectPublicIPv4()
|
||||
publicIPv4 := detectDisplayPublicIPv4()
|
||||
network.PublicIPv4 = publicIPv4.Address
|
||||
network.PublicIPv4Interface = publicIPv4.Interface
|
||||
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
|
||||
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
||||
if len(network.IPv6Prefixes) > 0 {
|
||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||
@@ -434,23 +441,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
|
||||
}
|
||||
|
||||
func readHostNetworkBytes() (uint64, uint64) {
|
||||
entries, err := os.ReadDir("/sys/class/net")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
ifaces := detectHostTrafficInterfaces()
|
||||
if len(ifaces) == 0 {
|
||||
ifaces = fallbackHostTrafficInterfaces()
|
||||
}
|
||||
|
||||
var rx, tx uint64
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if name == "lo" {
|
||||
continue
|
||||
}
|
||||
for _, name := range ifaces {
|
||||
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
|
||||
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
|
||||
}
|
||||
return rx, tx
|
||||
}
|
||||
|
||||
func detectHostTrafficInterfaces() []string {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, 2)
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if !isHostTrafficInterface(name) || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
result = append(result, name)
|
||||
}
|
||||
|
||||
if iface, _ := detectDefaultIPv4Route(); iface != "" {
|
||||
add(iface)
|
||||
}
|
||||
if iface, _ := detectDefaultIPv6Route(); iface != "" {
|
||||
add(iface)
|
||||
}
|
||||
if pub := lxc.DetectPublicIPv4(); pub.Interface != "" {
|
||||
add(pub.Interface)
|
||||
}
|
||||
for _, prefix := range lxc.DetectHostPublicIPv6Prefixes() {
|
||||
add(prefix.Interface)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fallbackHostTrafficInterfaces() []string {
|
||||
entries, err := os.ReadDir("/sys/class/net")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, 0)
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !isHostTrafficInterface(name) {
|
||||
continue
|
||||
}
|
||||
state := strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "operstate")))
|
||||
if state == "down" {
|
||||
continue
|
||||
}
|
||||
result = append(result, name)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func isHostTrafficInterface(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "lo" {
|
||||
return false
|
||||
}
|
||||
if isContainerLikeInterfaceName(name) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func readHostDiskBytes() (uint64, uint64) {
|
||||
f, err := os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
@@ -540,7 +603,7 @@ func getHostProbeReport() HostProbeReport {
|
||||
Disks: detectHostDisks(),
|
||||
NetworkInterfaces: detectHostNICs(),
|
||||
PublicIPv4: detectAllPublicIPv4(),
|
||||
IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(),
|
||||
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
|
||||
Gateways: detectGateways(),
|
||||
GPUs: detectGPUs(),
|
||||
System: detectSystemProbe(),
|
||||
@@ -571,6 +634,8 @@ func trimOSReleaseValue(value string) string {
|
||||
|
||||
func detectHostCPUProbe() HostCPUProbe {
|
||||
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
|
||||
armImplementer := ""
|
||||
armPart := ""
|
||||
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||
seenFlags := map[string]bool{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
@@ -579,19 +644,28 @@ func detectHostCPUProbe() HostCPUProbe {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(fields[0])
|
||||
keyLower := strings.ToLower(key)
|
||||
value := strings.TrimSpace(fields[1])
|
||||
switch key {
|
||||
case "model name", "Hardware", "Processor":
|
||||
if probe.Model == "" {
|
||||
switch keyLower {
|
||||
case "model name", "hardware", "processor":
|
||||
if probe.Model == "" && meaningfulCPUModel(value) {
|
||||
probe.Model = value
|
||||
}
|
||||
case "cpu cores":
|
||||
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
|
||||
probe.Cores = cores
|
||||
}
|
||||
case "flags", "Features":
|
||||
case "cpu implementer":
|
||||
if armImplementer == "" {
|
||||
armImplementer = strings.ToLower(value)
|
||||
}
|
||||
case "cpu part":
|
||||
if armPart == "" {
|
||||
armPart = strings.ToLower(value)
|
||||
}
|
||||
case "flags", "features":
|
||||
for _, flag := range strings.Fields(value) {
|
||||
if flag == "vmx" || flag == "svm" {
|
||||
if flag == "vmx" || flag == "svm" || flag == "virt" {
|
||||
probe.Virtualization = true
|
||||
probe.VirtualizationKey = flag
|
||||
}
|
||||
@@ -604,12 +678,132 @@ func detectHostCPUProbe() HostCPUProbe {
|
||||
}
|
||||
sort.Strings(probe.Flags)
|
||||
}
|
||||
enrichCPUProbeFromLscpu(&probe, &armImplementer, &armPart)
|
||||
if probe.Model == "" {
|
||||
probe.Model = armCPUModelName(armImplementer, armPart)
|
||||
}
|
||||
if probe.Model == "" && runtime.GOARCH == "arm64" {
|
||||
probe.Model = "ARM64 CPU"
|
||||
}
|
||||
if probe.Model == "" {
|
||||
probe.Model = "Unknown"
|
||||
}
|
||||
return probe
|
||||
}
|
||||
|
||||
func meaningfulCPUModel(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
if _, err := strconv.Atoi(value); err == nil {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
return lower != "unknown" && lower != "not specified"
|
||||
}
|
||||
|
||||
func enrichCPUProbeFromLscpu(probe *HostCPUProbe, armImplementer *string, armPart *string) {
|
||||
out := runCommandOutput(2*time.Second, "lscpu")
|
||||
if out == "" {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.SplitN(line, ":", 2)
|
||||
if len(fields) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(fields[0]))
|
||||
value := strings.TrimSpace(fields[1])
|
||||
switch key {
|
||||
case "model name":
|
||||
if probe.Model == "" && meaningfulCPUModel(value) {
|
||||
probe.Model = value
|
||||
}
|
||||
case "cpu(s)":
|
||||
if threads, err := strconv.Atoi(value); err == nil && threads > probe.Threads {
|
||||
probe.Threads = threads
|
||||
}
|
||||
case "core(s) per socket":
|
||||
if cores, err := strconv.Atoi(value); err == nil && cores > 0 {
|
||||
probe.Cores = cores
|
||||
}
|
||||
case "socket(s)":
|
||||
if sockets, err := strconv.Atoi(value); err == nil && sockets > 1 && probe.Cores > 0 {
|
||||
probe.Cores *= sockets
|
||||
}
|
||||
case "virtualization":
|
||||
lower := strings.ToLower(value)
|
||||
if value != "" && lower != "none" && lower != "n/a" {
|
||||
probe.Virtualization = true
|
||||
probe.VirtualizationKey = value
|
||||
}
|
||||
case "flags":
|
||||
seen := map[string]bool{}
|
||||
for _, flag := range probe.Flags {
|
||||
seen[flag] = true
|
||||
}
|
||||
for _, flag := range strings.Fields(value) {
|
||||
if flag == "vmx" || flag == "svm" || flag == "virt" {
|
||||
probe.Virtualization = true
|
||||
probe.VirtualizationKey = flag
|
||||
}
|
||||
if !seen[flag] {
|
||||
probe.Flags = append(probe.Flags, flag)
|
||||
seen[flag] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(probe.Flags)
|
||||
case "cpu implementer":
|
||||
if *armImplementer == "" {
|
||||
*armImplementer = strings.ToLower(value)
|
||||
}
|
||||
case "cpu part":
|
||||
if *armPart == "" {
|
||||
*armPart = strings.ToLower(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func armCPUModelName(implementer, part string) string {
|
||||
implementer = normalizeHexID(implementer)
|
||||
part = normalizeHexID(part)
|
||||
if implementer == "" || part == "" {
|
||||
return ""
|
||||
}
|
||||
armParts := map[string]string{
|
||||
"0x41:0xd03": "ARM Cortex-A53",
|
||||
"0x41:0xd05": "ARM Cortex-A55",
|
||||
"0x41:0xd07": "ARM Cortex-A57",
|
||||
"0x41:0xd08": "ARM Cortex-A72",
|
||||
"0x41:0xd09": "ARM Cortex-A73",
|
||||
"0x41:0xd0a": "ARM Cortex-A75",
|
||||
"0x41:0xd0b": "ARM Cortex-A76",
|
||||
"0x41:0xd0c": "ARM Neoverse N1",
|
||||
"0x41:0xd0d": "ARM Cortex-A77",
|
||||
"0x41:0xd40": "ARM Neoverse V1",
|
||||
"0x41:0xd41": "ARM Cortex-A78",
|
||||
"0x41:0xd49": "ARM Neoverse N2",
|
||||
"0x41:0xd4f": "ARM Neoverse V2",
|
||||
}
|
||||
if model := armParts[implementer+":"+part]; model != "" {
|
||||
return model
|
||||
}
|
||||
return strings.ToUpper(strings.TrimPrefix(implementer, "0x")) + " ARM CPU part " + part
|
||||
}
|
||||
|
||||
func normalizeHexID(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(value, "0x") {
|
||||
return value
|
||||
}
|
||||
return "0x" + value
|
||||
}
|
||||
|
||||
func detectMemoryModules() []HostMemoryModule {
|
||||
if !commandExists("dmidecode") {
|
||||
return nil
|
||||
@@ -676,17 +870,23 @@ func detectHostDisks() []HostDiskProbe {
|
||||
}
|
||||
base := filepath.Join("/sys/block", name)
|
||||
path := "/dev/" + name
|
||||
model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name")))
|
||||
vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor")))
|
||||
virtual := isVirtualBlockDevice(name, model, vendor)
|
||||
disk := HostDiskProbe{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Model: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))),
|
||||
Model: model,
|
||||
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
|
||||
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
|
||||
Type: detectDiskType(base, name),
|
||||
Type: detectDiskType(base, name, virtual),
|
||||
Virtual: virtual,
|
||||
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
|
||||
Mountpoints: mounts[name],
|
||||
}
|
||||
disk.SMART = detectDiskSMART(path)
|
||||
if !virtual {
|
||||
disk.SMART = detectDiskSMART(path)
|
||||
}
|
||||
disk.Health = disk.SMARTHealth()
|
||||
disk.HealthDetail = disk.SMARTDetail()
|
||||
disks = append(disks, disk)
|
||||
@@ -695,7 +895,10 @@ func detectHostDisks() []HostDiskProbe {
|
||||
return disks
|
||||
}
|
||||
|
||||
func detectDiskType(base, name string) string {
|
||||
func detectDiskType(base, name string, virtual bool) string {
|
||||
if virtual {
|
||||
return "Virtual"
|
||||
}
|
||||
if strings.HasPrefix(name, "nvme") {
|
||||
return "NVMe"
|
||||
}
|
||||
@@ -705,7 +908,26 @@ func detectDiskType(base, name string) string {
|
||||
return "SSD"
|
||||
}
|
||||
|
||||
func isVirtualBlockDevice(name, model, vendor string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor))
|
||||
if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") {
|
||||
return true
|
||||
}
|
||||
for _, token := range []string{
|
||||
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
|
||||
"amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume",
|
||||
} {
|
||||
if strings.Contains(lower, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (disk HostDiskProbe) SMARTHealth() string {
|
||||
if disk.Virtual {
|
||||
return "virtual"
|
||||
}
|
||||
if disk.SMART.Available && disk.Health != "" {
|
||||
return disk.Health
|
||||
}
|
||||
@@ -713,6 +935,9 @@ func (disk HostDiskProbe) SMARTHealth() string {
|
||||
}
|
||||
|
||||
func (disk HostDiskProbe) SMARTDetail() string {
|
||||
if disk.Virtual {
|
||||
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
|
||||
}
|
||||
return disk.SMART.Detail()
|
||||
}
|
||||
|
||||
@@ -1119,9 +1344,126 @@ func detectAllPublicIPv4() []string {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if egress := detectEgressPublicIPv4(); egress.Address != "" {
|
||||
if !seen[egress.Address] {
|
||||
seen[egress.Address] = true
|
||||
result = append(result, egress.Address)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func detectDisplayPublicIPv4() lxc.PublicIPInfo {
|
||||
if pub := lxc.DetectPublicIPv4(); pub.Address != "" {
|
||||
return pub
|
||||
}
|
||||
return detectEgressPublicIPv4()
|
||||
}
|
||||
|
||||
func detectEgressPublicIPv4() lxc.PublicIPInfo {
|
||||
egressIPv4Mu.Lock()
|
||||
defer egressIPv4Mu.Unlock()
|
||||
|
||||
if cachedEgressIPv4.Address != "" && time.Since(cachedEgressIPv4At) < 5*time.Minute {
|
||||
return cachedEgressIPv4
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 1200 * time.Millisecond}
|
||||
for _, endpoint := range []string{
|
||||
"https://api.ipify.org",
|
||||
"https://ifconfig.me/ip",
|
||||
"https://icanhazip.com",
|
||||
} {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 128))
|
||||
_ = resp.Body.Close()
|
||||
cancel()
|
||||
if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
continue
|
||||
}
|
||||
address := strings.TrimSpace(string(body))
|
||||
ip := net.ParseIP(address)
|
||||
if !isPublicIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
iface, gateway := detectDefaultIPv4Route()
|
||||
cachedEgressIPv4 = lxc.PublicIPInfo{
|
||||
Address: ip.String(),
|
||||
Interface: iface,
|
||||
Prefix: ip.String() + "/32",
|
||||
PrefixLen: 32,
|
||||
SubnetMask: "255.255.255.255",
|
||||
Gateway: gateway,
|
||||
IsTunnel: isTunnelLikeInterfaceName(iface),
|
||||
Source: "egress",
|
||||
}
|
||||
cachedEgressIPv4At = time.Now()
|
||||
return cachedEgressIPv4
|
||||
}
|
||||
|
||||
cachedEgressIPv4 = lxc.PublicIPInfo{}
|
||||
cachedEgressIPv4At = time.Now()
|
||||
return cachedEgressIPv4
|
||||
}
|
||||
|
||||
func detectDefaultIPv4Route() (string, string) {
|
||||
out := runCommandOutput(2*time.Second, "ip", "-4", "route", "show", "default")
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
iface := ""
|
||||
gateway := ""
|
||||
for i, field := range fields {
|
||||
if field == "dev" && i+1 < len(fields) {
|
||||
iface = fields[i+1]
|
||||
}
|
||||
if field == "via" && i+1 < len(fields) {
|
||||
gateway = fields[i+1]
|
||||
}
|
||||
}
|
||||
if iface != "" || gateway != "" {
|
||||
return iface, gateway
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func detectDefaultIPv6Route() (string, string) {
|
||||
out := runCommandOutput(2*time.Second, "ip", "-6", "route", "show", "default")
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
iface := ""
|
||||
gateway := ""
|
||||
for i, field := range fields {
|
||||
if field == "dev" && i+1 < len(fields) {
|
||||
iface = fields[i+1]
|
||||
}
|
||||
if field == "via" && i+1 < len(fields) {
|
||||
gateway = fields[i+1]
|
||||
}
|
||||
}
|
||||
if iface != "" || gateway != "" {
|
||||
return iface, gateway
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
|
||||
result := make([]HostIPProbe, 0)
|
||||
for _, nic := range nics {
|
||||
@@ -1267,6 +1609,16 @@ func isContainerLikeInterfaceName(iface string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isTunnelLikeInterfaceName(iface string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(iface))
|
||||
for _, prefix := range []string{"tun", "tap", "wg", "gre", "gretap", "sit", "ip6tnl", "he-", "zt", "tailscale"} {
|
||||
if lower == prefix || strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
|
||||
result := make([]HostIPProbe, 0)
|
||||
for _, nic := range nics {
|
||||
@@ -1334,6 +1686,8 @@ func detectGPUVendor(value string) string {
|
||||
return "NVIDIA"
|
||||
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
|
||||
return "AMD"
|
||||
case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"):
|
||||
return "Virtio"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -1341,6 +1695,9 @@ func detectGPUVendor(value string) string {
|
||||
|
||||
func detectGPUType(value string) string {
|
||||
lower := strings.ToLower(value)
|
||||
if strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu") {
|
||||
return "virtual"
|
||||
}
|
||||
if strings.Contains(lower, "intel") {
|
||||
return "integrated"
|
||||
}
|
||||
@@ -1360,9 +1717,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
|
||||
devKVM := fileExists("/dev/kvm")
|
||||
nested, detail := detectNestedVirtualization()
|
||||
lxcOK := envCheckOK(env, "lxc-create")
|
||||
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
|
||||
probe := HostRuntimeProbe{
|
||||
LXCAvailable: lxcOK,
|
||||
KVMAvailable: devKVM && envCheckOK(env, "virsh"),
|
||||
KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
|
||||
DevKVM: devKVM,
|
||||
NestedVirtualization: nested,
|
||||
NestedDetail: detail,
|
||||
@@ -1412,6 +1770,7 @@ func detectSystemProbe() HostSystemProbe {
|
||||
}
|
||||
|
||||
func detectHostEnvironment() []HostEnvCheck {
|
||||
qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "")
|
||||
checks := []HostEnvCheck{
|
||||
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
|
||||
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
|
||||
@@ -1420,10 +1779,11 @@ func detectHostEnvironment() []HostEnvCheck {
|
||||
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
|
||||
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
|
||||
commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
|
||||
commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""),
|
||||
qemuCheck,
|
||||
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
|
||||
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
|
||||
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
|
||||
certbotCheck(),
|
||||
}
|
||||
checks = append(checks, HostEnvCheck{Key: "dev-kvm", Label: "/dev/kvm 硬件虚拟化", OK: fileExists("/dev/kvm"), Required: false, Detail: boolDetail(fileExists("/dev/kvm"))})
|
||||
checks = append(checks, HostEnvCheck{Key: "ipv4-forward", Label: "IPv4 转发", OK: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward")) == "1", Required: true, Detail: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward"))})
|
||||
@@ -1432,11 +1792,24 @@ func detectHostEnvironment() []HostEnvCheck {
|
||||
return checks
|
||||
}
|
||||
|
||||
func kvmQEMUCheckKey() string {
|
||||
switch runtime.GOARCH {
|
||||
case "arm64":
|
||||
return "qemu-system-aarch64"
|
||||
default:
|
||||
return "qemu-system-x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
func kvmQEMUCommand() string {
|
||||
return kvmQEMUCheckKey()
|
||||
}
|
||||
|
||||
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
|
||||
ok := commandExists(cmd)
|
||||
detail := "missing"
|
||||
if ok {
|
||||
detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
|
||||
detail = commandVersionDetail(cmd)
|
||||
if detail == "" {
|
||||
detail = "installed"
|
||||
}
|
||||
@@ -1446,6 +1819,79 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
|
||||
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
|
||||
}
|
||||
|
||||
func commandVersionDetail(cmd string) string {
|
||||
switch cmd {
|
||||
case "ip":
|
||||
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1"))
|
||||
default:
|
||||
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
|
||||
}
|
||||
}
|
||||
|
||||
func certbotCheck() HostEnvCheck {
|
||||
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
|
||||
if !commandExists("certbot") {
|
||||
return check
|
||||
}
|
||||
detail := strings.TrimSpace(runCommandOutput(3*time.Second, "certbot", "--version"))
|
||||
if detail == "" {
|
||||
detail = strings.TrimSpace(runCommandOutput(3*time.Second, "sh", "-c", "certbot --version 2>&1 | head -n 1"))
|
||||
}
|
||||
if detail == "" {
|
||||
detail = "installed, version unknown"
|
||||
}
|
||||
check.Detail = detail
|
||||
version := extractCertbotVersion(detail)
|
||||
check.OK = certbotVersionAtLeast(version, 5, 4)
|
||||
if version == "" {
|
||||
check.Detail = detail + " (version unknown, need >= 5.4)"
|
||||
} else if !check.OK {
|
||||
check.Detail = detail + " (need >= 5.4)"
|
||||
}
|
||||
return check
|
||||
}
|
||||
|
||||
func extractCertbotVersion(output string) string {
|
||||
for _, field := range strings.Fields(output) {
|
||||
field = strings.Trim(field, "vV,;:()[]{}")
|
||||
if field == "" || field[0] < '0' || field[0] > '9' {
|
||||
continue
|
||||
}
|
||||
return field
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func certbotVersionAtLeast(version string, minMajor, minMinor int) bool {
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
major, err := strconv.Atoi(numericPrefix(parts[0]))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
minor, err := strconv.Atoi(numericPrefix(parts[1]))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if major != minMajor {
|
||||
return major > minMajor
|
||||
}
|
||||
return minor >= minMinor
|
||||
}
|
||||
|
||||
func numericPrefix(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func envCheckOK(checks []HostEnvCheck, key string) bool {
|
||||
for _, check := range checks {
|
||||
if check.Key == key {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractCertbotVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
output string
|
||||
want string
|
||||
}{
|
||||
{"certbot 5.4.0", "5.4.0"},
|
||||
{"certbot v5.10.1", "5.10.1"},
|
||||
{"certbot, version 4.9", "4.9"},
|
||||
{"installed", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := extractCertbotVersion(tt.output); got != tt.want {
|
||||
t.Fatalf("extractCertbotVersion(%q) = %q, want %q", tt.output, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertbotVersionAtLeast54(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"5.4", true},
|
||||
{"5.4.0", true},
|
||||
{"5.10", true},
|
||||
{"6.0.0", true},
|
||||
{"5.3.9", false},
|
||||
{"4.99", false},
|
||||
{"5", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := certbotVersionAtLeast(tt.version, 5, 4); got != tt.want {
|
||||
t.Fatalf("certbotVersionAtLeast(%q, 5, 4) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARMCPUModelName(t *testing.T) {
|
||||
if got := armCPUModelName("0x41", "0xd0c"); got != "ARM Neoverse N1" {
|
||||
t.Fatalf("armCPUModelName() = %q, want ARM Neoverse N1", got)
|
||||
}
|
||||
if got := armCPUModelName("41", "d0c"); got != "ARM Neoverse N1" {
|
||||
t.Fatalf("armCPUModelName() without hex prefix = %q, want ARM Neoverse N1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeaningfulCPUModel(t *testing.T) {
|
||||
if meaningfulCPUModel("0") {
|
||||
t.Fatal("numeric ARM processor index should not be treated as a CPU model")
|
||||
}
|
||||
if !meaningfulCPUModel("Neoverse-N1") {
|
||||
t.Fatal("expected Neoverse-N1 to be treated as a CPU model")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostTrafficInterfaceFilter(t *testing.T) {
|
||||
accepted := []string{"eth0", "ens3", "enp0s6", "bond0", "wg0"}
|
||||
for _, name := range accepted {
|
||||
if !isHostTrafficInterface(name) {
|
||||
t.Fatalf("expected %s to be accepted as a host traffic interface", name)
|
||||
}
|
||||
}
|
||||
|
||||
rejected := []string{"", "lo", "docker0", "br-3024b78640ee", "lxcbr0", "virbr0", "vethaaa9e44", "cni0"}
|
||||
for _, name := range rejected {
|
||||
if isHostTrafficInterface(name) {
|
||||
t.Fatalf("expected %s to be rejected as an internal/container interface", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -227,9 +228,14 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
enabledSet := getEnabledImageSet()
|
||||
cleanupOldImageDownloadErrors()
|
||||
kvmAvailable := hostKVMAvailable()
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
|
||||
kvmImages := []kvm.Image{}
|
||||
if kvmAvailable {
|
||||
kvmImages = kvm.GetImages()
|
||||
}
|
||||
images := make([]ImageInfo, 0, len(templates)+len(kvmImages))
|
||||
for _, t := range templates {
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||
@@ -252,7 +258,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
||||
SizeBytes: size,
|
||||
})
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
for _, t := range kvmImages {
|
||||
dl := imageDownloadInfo(t.ID)
|
||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||
manualPath := ""
|
||||
@@ -309,6 +315,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||
return
|
||||
}
|
||||
if !hostKVMAvailable() {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
|
||||
return
|
||||
}
|
||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||
ensureImageEnabled(image.ID)
|
||||
clearImageDownload(image.ID)
|
||||
@@ -534,6 +544,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result := make([]map[string]string, 0)
|
||||
if runtime == config.VirtualizationKVM {
|
||||
if !hostKVMAvailable() {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||
return
|
||||
}
|
||||
for _, t := range kvm.GetImages() {
|
||||
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
||||
result = append(result, map[string]string{
|
||||
@@ -563,6 +577,9 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
|
||||
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||
runtime = runtimeFromRequest(runtime)
|
||||
if runtime == config.VirtualizationKVM {
|
||||
if !hostKVMAvailable() {
|
||||
return false
|
||||
}
|
||||
image := kvm.FindImage(templateID)
|
||||
if image == nil {
|
||||
return false
|
||||
@@ -579,6 +596,13 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
}
|
||||
|
||||
func hostKVMAvailable() bool {
|
||||
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
|
||||
return false
|
||||
}
|
||||
return fileExists("/dev/kvm") && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
|
||||
}
|
||||
|
||||
func ensureImageEnabled(id string) {
|
||||
// If the enabled list is empty, all templates are currently enabled by default.
|
||||
// We must populate the list with all template IDs first so that explicit toggles stick.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type webSSHOriginSettingsRequest struct {
|
||||
Origins []string `json:"origins"`
|
||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||
}
|
||||
|
||||
type webSSHOriginSettingsResponse struct {
|
||||
Origins []string `json:"origins"`
|
||||
CurrentOrigin string `json:"current_origin,omitempty"`
|
||||
}
|
||||
|
||||
func HandleWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: webSSHOriginSettingsStatus(r)})
|
||||
case http.MethodPut:
|
||||
updateWebSSHOriginSettings(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func updateWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req webSSHOriginSettingsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
origins := req.Origins
|
||||
if len(origins) == 0 && len(req.WebSSHAllowedOrigins) > 0 {
|
||||
origins = req.WebSSHAllowedOrigins
|
||||
}
|
||||
normalized, err := config.NormalizeAllowedOrigins(origins)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AppConfig.WebSSHAllowedOrigins = normalized
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save Origin allowlist failed"})
|
||||
return
|
||||
}
|
||||
auditRequest(r, "settings.webssh_origins", "WebSSH Origin", "origins="+strings.Join(normalized, ","), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Origin allowlist saved", Data: webSSHOriginSettingsStatus(r)})
|
||||
}
|
||||
|
||||
func webSSHOriginSettingsStatus(r *http.Request) webSSHOriginSettingsResponse {
|
||||
origins := config.AppConfig.WebSSHAllowedOrigins
|
||||
if origins == nil {
|
||||
origins = []string{}
|
||||
}
|
||||
return webSSHOriginSettingsResponse{
|
||||
Origins: origins,
|
||||
CurrentOrigin: requestOrigin(r),
|
||||
}
|
||||
}
|
||||
|
||||
func requestOrigin(r *http.Request) string {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
||||
scheme = strings.ToLower(strings.Split(forwarded, ",")[0])
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
+265
-24
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
@@ -15,18 +17,35 @@ type routeCapacity struct {
|
||||
Total string `json:"total"`
|
||||
}
|
||||
|
||||
type nat4PortRange struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
type nat4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
HostIP string `json:"host_ip"`
|
||||
HostPort int `json:"host_port"`
|
||||
ContainerPort int `json:"container_port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ipv4Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
Address string `json:"address"`
|
||||
Interface string `json:"interface"`
|
||||
PrefixLen int `json:"prefix_len,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
}
|
||||
|
||||
type ipv6Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -38,32 +57,82 @@ type ipv6Route struct {
|
||||
}
|
||||
|
||||
type routingResponse struct {
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||
IPv4 routeCapacity `json:"ipv4"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
|
||||
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
||||
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
|
||||
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
type routingPoolsRequest struct {
|
||||
Addresses *[]string `json:"addresses"`
|
||||
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||
NAT4PortRange *nat4PortRange `json:"nat4_port_range"`
|
||||
}
|
||||
|
||||
type publicIPv4ScanRequest struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Interface string `json:"interface"`
|
||||
Gateway string `json:"gateway"`
|
||||
Verify bool `json:"verify"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleRoutingGet(w, r)
|
||||
case http.MethodPut:
|
||||
handleRoutingPoolsUpdate(w, r)
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRoutingIPv4Scan(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "routing:read") {
|
||||
if !requireScope(w, r, "routing:write") {
|
||||
return
|
||||
}
|
||||
var req publicIPv4ScanRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
results, err := lxc.ScanPublicIPv4Segment(req.CIDR, req.Interface, req.Gateway, req.Verify, req.Limit)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
|
||||
}
|
||||
|
||||
func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !hasAnyScope(r, "routing:read", "routing:write") {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
|
||||
return
|
||||
}
|
||||
|
||||
nat4Mappings := make([]nat4Route, 0)
|
||||
usedPorts := map[int]bool{}
|
||||
ipv4Assignments := make([]ipv4Route, 0)
|
||||
ipv6Assignments := make([]ipv6Route, 0)
|
||||
|
||||
const nat4StartPort = 20000
|
||||
const nat4EndPort = 65535
|
||||
nat4StartPort, nat4EndPort := config.NATPortRange()
|
||||
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
for _, pm := range c.PortMappings {
|
||||
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
||||
if config.NATPortInRange(pm.HostPort) {
|
||||
usedPorts[pm.HostPort] = true
|
||||
}
|
||||
nat4Mappings = append(nat4Mappings, nat4Route{
|
||||
@@ -72,35 +141,61 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
IP: c.IP,
|
||||
HostIP: pm.HostIP,
|
||||
HostPort: pm.HostPort,
|
||||
ContainerPort: pm.ContainerPort,
|
||||
Protocol: pm.Protocol,
|
||||
Description: pm.Description,
|
||||
})
|
||||
}
|
||||
if c.IPv6 != "" {
|
||||
for _, ip := range c.PublicIPv4s {
|
||||
if ip.Address == "" {
|
||||
continue
|
||||
}
|
||||
ipv4Assignments = append(ipv4Assignments, ipv4Route{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
Address: ip.Address,
|
||||
Interface: ip.Interface,
|
||||
PrefixLen: ip.PrefixLen,
|
||||
Gateway: ip.Gateway,
|
||||
})
|
||||
}
|
||||
c.NormalizeNetworkAssignments()
|
||||
for _, ip := range c.IPv6Addresses {
|
||||
if ip.Address == "" {
|
||||
continue
|
||||
}
|
||||
ipv6Assignments = append(ipv6Assignments, ipv6Route{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
Address: c.IPv6,
|
||||
PrefixLen: c.IPv6PrefixLen,
|
||||
Interface: c.IPv6Interface,
|
||||
Address: ip.Address,
|
||||
PrefixLen: ip.PrefixLen,
|
||||
Interface: ip.Interface,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(nat4Mappings, func(i, j int) bool {
|
||||
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
|
||||
if nat4Mappings[i].HostIP != nat4Mappings[j].HostIP {
|
||||
return nat4Mappings[i].HostIP < nat4Mappings[j].HostIP
|
||||
}
|
||||
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
|
||||
}
|
||||
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
|
||||
})
|
||||
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
|
||||
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
|
||||
})
|
||||
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||
})
|
||||
|
||||
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
|
||||
totalNAT4Ports := config.NATPortCapacity()
|
||||
nat4Used := len(usedPorts)
|
||||
nat4Remaining := totalNAT4Ports - nat4Used
|
||||
if nat4Remaining < 0 {
|
||||
@@ -108,12 +203,16 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||
ipv6Total := "0"
|
||||
ipv6Remaining := "0"
|
||||
if len(prefixes) > 0 {
|
||||
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
|
||||
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
|
||||
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
||||
publicIPv4s := lxc.DetectPublicIPv4Candidates()
|
||||
ipv4Total := len(publicIPv4s)
|
||||
ipv4Used := len(ipv4Assignments)
|
||||
ipv4Remaining := ipv4Total - ipv4Used
|
||||
if ipv4Remaining < 0 {
|
||||
ipv4Remaining = 0
|
||||
}
|
||||
ipv6Total := totalIPv6Capacity(prefixes)
|
||||
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
@@ -123,18 +222,160 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
|
||||
Remaining: strconv.Itoa(nat4Remaining),
|
||||
Total: strconv.Itoa(totalNAT4Ports),
|
||||
},
|
||||
NAT4PortRange: nat4PortRange{
|
||||
Start: nat4StartPort,
|
||||
End: nat4EndPort,
|
||||
},
|
||||
IPv4: routeCapacity{
|
||||
Used: ipv4Used,
|
||||
Remaining: strconv.Itoa(ipv4Remaining),
|
||||
Total: strconv.Itoa(ipv4Total),
|
||||
},
|
||||
IPv6: routeCapacity{
|
||||
Used: len(ipv6Assignments),
|
||||
Remaining: ipv6Remaining,
|
||||
Total: ipv6Total,
|
||||
},
|
||||
NAT4Mappings: nat4Mappings,
|
||||
IPv6Assignments: ipv6Assignments,
|
||||
IPv6Prefixes: prefixes,
|
||||
HostPublicIPv4: hostPublicIPv4,
|
||||
PublicIPv4Addresses: publicIPv4s,
|
||||
IPv4Assignments: ipv4Assignments,
|
||||
NAT4Mappings: nat4Mappings,
|
||||
IPv6Assignments: ipv6Assignments,
|
||||
IPv6Prefixes: prefixes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScope(w, r, "routing:write") {
|
||||
return
|
||||
}
|
||||
var req routingPoolsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.NAT4PortRange != nil {
|
||||
start, end, err := config.NormalizeNATPortRange(req.NAT4PortRange.Start, req.NAT4PortRange.End)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
config.AppConfig.NATPortStart = start
|
||||
config.AppConfig.NATPortEnd = end
|
||||
if config.AppConfig.NextSSHPort < start || config.AppConfig.NextSSHPort > end {
|
||||
config.AppConfig.NextSSHPort = start
|
||||
}
|
||||
}
|
||||
|
||||
if req.Items != nil || req.Addresses != nil {
|
||||
items := []config.PublicIPv4Assignment{}
|
||||
if req.Items != nil {
|
||||
items = *req.Items
|
||||
} else if req.Addresses != nil {
|
||||
items = make([]config.PublicIPv4Assignment, 0, len(*req.Addresses))
|
||||
for _, address := range *req.Addresses {
|
||||
items = append(items, config.PublicIPv4Assignment{Address: address})
|
||||
}
|
||||
}
|
||||
normalized, err := lxc.NormalizePublicIPv4Pool(items)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, item := range normalized {
|
||||
allowed[item.Address] = true
|
||||
}
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if item.Address != "" && !allowed[item.Address] {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: "IPv4 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
config.AppConfig.PublicIPv4Pool = normalized
|
||||
}
|
||||
|
||||
if req.IPv6Prefixes != nil {
|
||||
normalized, err := lxc.NormalizePublicIPv6Prefixes(*req.IPv6Prefixes)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
parsedPrefixes := make([]netip.Prefix, 0, len(normalized))
|
||||
for _, item := range normalized {
|
||||
prefix, err := netip.ParsePrefix(item.Prefix)
|
||||
if err == nil {
|
||||
parsedPrefixes = append(parsedPrefixes, prefix)
|
||||
}
|
||||
}
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
c.NormalizeNetworkAssignments()
|
||||
for _, item := range c.IPv6Addresses {
|
||||
if item.Address == "" {
|
||||
continue
|
||||
}
|
||||
addr, err := netip.ParseAddr(item.Address)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
contained := false
|
||||
for _, prefix := range parsedPrefixes {
|
||||
if prefix.Contains(addr) {
|
||||
contained = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !contained {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: "IPv6 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
config.AppConfig.PublicIPv6Prefixes = normalized
|
||||
}
|
||||
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
|
||||
return
|
||||
}
|
||||
handleRoutingGet(w, r)
|
||||
}
|
||||
|
||||
func totalIPv6Capacity(prefixes []lxc.IPv6PrefixInfo) string {
|
||||
if len(prefixes) == 0 {
|
||||
return "0"
|
||||
}
|
||||
var total uint64
|
||||
for _, prefix := range prefixes {
|
||||
capacity := lxc.IPv6PrefixCapacity(prefix.PrefixLen)
|
||||
if capacity == "large" {
|
||||
return "large"
|
||||
}
|
||||
parsed, err := strconv.ParseUint(capacity, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ^uint64(0)-total < parsed {
|
||||
return "large"
|
||||
}
|
||||
total += parsed
|
||||
}
|
||||
if total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return strconv.FormatUint(total, 10)
|
||||
}
|
||||
|
||||
func subtractCapacity(total string, used int) string {
|
||||
if total == "" || total == "0" {
|
||||
return "0"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestHandleRoutingGetAllowsRoutingWriteScope(t *testing.T) {
|
||||
config.AppConfig = &config.ClicdConfig{}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil)
|
||||
req = withAuthContext(req, AuthContext{
|
||||
Type: authTypeAPIKey,
|
||||
Scopes: []string{"routing:write"},
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handleRoutingGet(rec, req)
|
||||
|
||||
if rec.Code == http.StatusForbidden {
|
||||
t.Fatal("routing:write scope should be able to receive the routing response after updates")
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,16 @@ import (
|
||||
|
||||
var kvmManager = kvm.NewManager()
|
||||
|
||||
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
|
||||
|
||||
func runtimeFromRequest(value string) string {
|
||||
return config.NormalizeVirtualization(value)
|
||||
}
|
||||
|
||||
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
|
||||
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
|
||||
}
|
||||
|
||||
func runtimeFromTemplateID(templateID string) string {
|
||||
if kvm.FindImage(templateID) != nil {
|
||||
return config.VirtualizationKVM
|
||||
@@ -26,12 +32,33 @@ func runtimeFromTemplateID(templateID string) string {
|
||||
|
||||
func createByRuntime(cfg lxc.ContainerConfig) error {
|
||||
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||
cfg.NormalizeResourceAliases()
|
||||
if cfg.Virtualization == config.VirtualizationKVM {
|
||||
return kvmManager.CreateContainer(cfg)
|
||||
}
|
||||
return lxcManager.CreateContainer(cfg)
|
||||
}
|
||||
|
||||
func validateCreateSSHAuth(cfg lxc.ContainerConfig) error {
|
||||
if cfg.Virtualization == config.VirtualizationKVM && kvm.IsWindowsImage(cfg.TemplateID) {
|
||||
return nil
|
||||
}
|
||||
_, err := lxc.ResolveCreateSSHAccess(cfg)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateReinstallSSHAuth(c *config.Container, templateID string, cfg lxc.ContainerConfig) error {
|
||||
if c != nil && c.IsKVM() && kvm.IsWindowsImage(templateID) {
|
||||
return nil
|
||||
}
|
||||
currentPassword := ""
|
||||
if c != nil {
|
||||
currentPassword = c.SSHPassword
|
||||
}
|
||||
_, err := lxc.ResolveReinstallSSHAccess(currentPassword, cfg)
|
||||
return err
|
||||
}
|
||||
|
||||
func startByRuntime(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
@@ -64,12 +91,12 @@ func destroyByRuntime(id int) error {
|
||||
return lxcManager.DestroyContainer(id)
|
||||
}
|
||||
|
||||
func reinstallByRuntime(id int, templateID string) error {
|
||||
func reinstallByRuntime(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.ReinstallContainer(id, templateID)
|
||||
return kvmManager.ReinstallContainer(id, templateID, authConfig...)
|
||||
}
|
||||
return lxcManager.ReinstallContainer(id, templateID)
|
||||
return lxcManager.ReinstallContainer(id, templateID, authConfig...)
|
||||
}
|
||||
|
||||
func resetPasswordByRuntime(id int, password string) (string, error) {
|
||||
|
||||
@@ -49,15 +49,19 @@ type connEntry struct {
|
||||
}
|
||||
|
||||
type trafficStats struct {
|
||||
total int
|
||||
totalSynSent int
|
||||
destCounts map[string]int
|
||||
destPorts map[string]map[int]int
|
||||
portDestCounts map[int]map[string]int
|
||||
portTotalCounts map[int]int
|
||||
udpDestCounts map[int]map[string]int
|
||||
udpTotalCounts map[int]int
|
||||
synSentByDst map[string]int
|
||||
total int
|
||||
totalSynSent int
|
||||
destCounts map[string]int
|
||||
destPorts map[string]map[int]int
|
||||
portDestCounts map[int]map[string]int
|
||||
portTotalCounts map[int]int
|
||||
udpDestCounts map[int]map[string]int
|
||||
udpTotalCounts map[int]int
|
||||
udpDestTotalCounts map[string]int
|
||||
synSentByDst map[string]int
|
||||
tcpSynDestPorts map[string]map[int]int
|
||||
tcpSynPortDestCounts map[int]map[string]int
|
||||
tcpSynPortTotalCounts map[int]int
|
||||
}
|
||||
|
||||
var scanner *SecurityScanner
|
||||
@@ -180,6 +184,12 @@ func (ss *SecurityScanner) monitorLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) alertCount() int {
|
||||
ss.mu.Lock()
|
||||
defer ss.mu.Unlock()
|
||||
return len(ss.alerts)
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) checkAllContainers() {
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
if c.Status != "running" || c.IP == "" {
|
||||
@@ -208,6 +218,7 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
|
||||
return
|
||||
}
|
||||
|
||||
alertBefore := ss.alertCount()
|
||||
ss.detectPortScans(name, ip, stats)
|
||||
ss.detectBruteForce(name, ip, stats)
|
||||
ss.detectSpam(name, ip, stats)
|
||||
@@ -216,17 +227,26 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
|
||||
ss.detectMining(name, ip, stats)
|
||||
ss.detectProxyAndTor(name, ip, stats)
|
||||
ss.detectMalware(name, ip, stats)
|
||||
|
||||
// If new alerts were generated, snapshot the conntrack data for later retrieval.
|
||||
if ss.alertCount() > alertBefore {
|
||||
config.SaveConntrackSnapshot(ip, lines)
|
||||
}
|
||||
}
|
||||
|
||||
func newTrafficStats() *trafficStats {
|
||||
return &trafficStats{
|
||||
destCounts: make(map[string]int),
|
||||
destPorts: make(map[string]map[int]int),
|
||||
portDestCounts: make(map[int]map[string]int),
|
||||
portTotalCounts: make(map[int]int),
|
||||
udpDestCounts: make(map[int]map[string]int),
|
||||
udpTotalCounts: make(map[int]int),
|
||||
synSentByDst: make(map[string]int),
|
||||
destCounts: make(map[string]int),
|
||||
destPorts: make(map[string]map[int]int),
|
||||
portDestCounts: make(map[int]map[string]int),
|
||||
portTotalCounts: make(map[int]int),
|
||||
udpDestCounts: make(map[int]map[string]int),
|
||||
udpTotalCounts: make(map[int]int),
|
||||
synSentByDst: make(map[string]int),
|
||||
udpDestTotalCounts: make(map[string]int),
|
||||
tcpSynDestPorts: make(map[string]map[int]int),
|
||||
tcpSynPortDestCounts: make(map[int]map[string]int),
|
||||
tcpSynPortTotalCounts: make(map[int]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,52 +272,64 @@ func (ts *trafficStats) add(conn connEntry) {
|
||||
}
|
||||
ts.udpDestCounts[conn.dstPort][conn.dstIP]++
|
||||
ts.udpTotalCounts[conn.dstPort]++
|
||||
ts.udpDestTotalCounts[conn.dstIP]++
|
||||
}
|
||||
}
|
||||
|
||||
if conn.state == "SYN_SENT" {
|
||||
if conn.proto == "tcp" && conn.state == "SYN_SENT" {
|
||||
ts.totalSynSent++
|
||||
ts.synSentByDst[conn.dstIP]++
|
||||
if conn.dstPort > 0 {
|
||||
if ts.tcpSynDestPorts[conn.dstIP] == nil {
|
||||
ts.tcpSynDestPorts[conn.dstIP] = make(map[int]int)
|
||||
}
|
||||
ts.tcpSynDestPorts[conn.dstIP][conn.dstPort]++
|
||||
if ts.tcpSynPortDestCounts[conn.dstPort] == nil {
|
||||
ts.tcpSynPortDestCounts[conn.dstPort] = make(map[string]int)
|
||||
}
|
||||
ts.tcpSynPortDestCounts[conn.dstPort][conn.dstIP]++
|
||||
ts.tcpSynPortTotalCounts[conn.dstPort]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
|
||||
for dstIP, portCounts := range stats.destPorts {
|
||||
for dstIP, portCounts := range stats.tcpSynDestPorts {
|
||||
uniquePorts := len(portCounts)
|
||||
switch {
|
||||
case uniquePorts >= 20:
|
||||
case uniquePorts >= 25:
|
||||
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
|
||||
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
|
||||
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
|
||||
"")
|
||||
case uniquePorts >= 8:
|
||||
case uniquePorts >= 12:
|
||||
ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0,
|
||||
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
|
||||
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
|
||||
"")
|
||||
}
|
||||
}
|
||||
|
||||
for port, targets := range stats.portDestCounts {
|
||||
for port, targets := range stats.tcpSynPortDestCounts {
|
||||
uniqueTargets := len(targets)
|
||||
if service, ok := bruteForcePorts[port]; ok {
|
||||
if uniqueTargets >= 30 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
|
||||
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
"")
|
||||
} else if uniqueTargets >= 10 {
|
||||
} else if uniqueTargets >= 12 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, "*", port,
|
||||
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
"")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if uniqueTargets >= 40 {
|
||||
if uniqueTargets >= 50 {
|
||||
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
|
||||
fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
fmt.Sprintf("横向扫描: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
"")
|
||||
} else if uniqueTargets >= 15 {
|
||||
} else if uniqueTargets >= 20 {
|
||||
ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port,
|
||||
fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
fmt.Sprintf("可疑横向探测: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
"")
|
||||
}
|
||||
}
|
||||
@@ -311,13 +343,25 @@ func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats
|
||||
continue
|
||||
}
|
||||
|
||||
if count >= 20 {
|
||||
synCount := 0
|
||||
if ports := stats.tcpSynDestPorts[dstIP]; ports != nil {
|
||||
synCount = ports[port]
|
||||
}
|
||||
if synCount >= 25 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
|
||||
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d", service, port, count),
|
||||
fmt.Sprintf("暴力破解: %s(%d) 当前 TCP 半开连接 %d 条", service, port, synCount),
|
||||
"")
|
||||
} else if count >= 10 {
|
||||
} else if synCount >= 12 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
|
||||
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d", service, port, count),
|
||||
fmt.Sprintf("疑似暴力破解: %s(%d) 当前 TCP 半开连接 %d 条", service, port, synCount),
|
||||
"")
|
||||
} else if count >= 60 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
|
||||
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
|
||||
"")
|
||||
} else if count >= 30 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
|
||||
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
|
||||
"")
|
||||
}
|
||||
}
|
||||
@@ -344,30 +388,41 @@ func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) {
|
||||
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
|
||||
targets := len(stats.destCounts)
|
||||
switch {
|
||||
case targets >= 100:
|
||||
case targets >= 120 && stats.total >= 600:
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets),
|
||||
fmt.Sprintf("大规模对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
|
||||
"")
|
||||
case targets >= 35:
|
||||
case targets >= 60 && stats.total >= 300:
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
|
||||
fmt.Sprintf("大量对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
|
||||
"")
|
||||
}
|
||||
|
||||
synTargets := len(stats.synSentByDst)
|
||||
switch {
|
||||
case stats.total >= 500:
|
||||
case stats.totalSynSent >= 250 || (synTargets >= 80 && stats.totalSynSent >= 160):
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total),
|
||||
fmt.Sprintf("大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
|
||||
"")
|
||||
case stats.total >= 200:
|
||||
case stats.totalSynSent >= 100 || (synTargets >= 35 && stats.totalSynSent >= 70):
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("高连接数: 当前 conntrack 出站记录 %d 条", stats.total),
|
||||
fmt.Sprintf("可疑大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
|
||||
"")
|
||||
}
|
||||
|
||||
if stats.totalSynSent >= 100 {
|
||||
udpTargets := len(stats.udpDestTotalCounts)
|
||||
udpTotal := 0
|
||||
for _, count := range stats.udpTotalCounts {
|
||||
udpTotal += count
|
||||
}
|
||||
switch {
|
||||
case udpTargets >= 120 && udpTotal >= 300:
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent),
|
||||
fmt.Sprintf("UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
|
||||
"")
|
||||
case udpTargets >= 50 && udpTotal >= 120:
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("可疑 UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
|
||||
"")
|
||||
}
|
||||
|
||||
@@ -392,11 +447,18 @@ func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *traffic
|
||||
continue
|
||||
}
|
||||
|
||||
if targets >= 30 || total >= 100 {
|
||||
criticalTargets, criticalTotal := 40, 120
|
||||
highTargets, highTotal := 15, 45
|
||||
if port == 53 {
|
||||
criticalTargets, criticalTotal = 75, 300
|
||||
highTargets, highTotal = 25, 100
|
||||
}
|
||||
|
||||
if targets >= criticalTargets && total >= criticalTotal {
|
||||
ss.addAlert(name, "reflection", "critical", ip, "*", port,
|
||||
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
||||
"")
|
||||
} else if targets >= 10 || total >= 30 {
|
||||
} else if targets >= highTargets && total >= highTotal {
|
||||
ss.addAlert(name, "reflection", "high", ip, "*", port,
|
||||
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
||||
"")
|
||||
@@ -633,6 +695,9 @@ func severityRank(severity string) int {
|
||||
}
|
||||
|
||||
func autoShutdownAlertContainer(containerName, alertType, severity string) {
|
||||
if !config.AppConfig.SecurityAutoShutdown {
|
||||
return
|
||||
}
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil || c.Status != "running" {
|
||||
return
|
||||
@@ -648,6 +713,24 @@ func autoShutdownAlertContainer(containerName, alertType, severity string) {
|
||||
}
|
||||
}
|
||||
|
||||
func clearSecurityPolicyBlocks() int {
|
||||
cleared := 0
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if !c.PolicyBlocked || !isSecurityPolicyBlockReason(c.PolicyBlockedReason) {
|
||||
continue
|
||||
}
|
||||
config.SetContainerPolicyBlock(c.ID, false, "")
|
||||
config.AddAuditLog("security_policy_unblock", c.Name, "关闭安全告警自动关机后解除策略临时封禁", "system")
|
||||
cleared++
|
||||
}
|
||||
return cleared
|
||||
}
|
||||
|
||||
func isSecurityPolicyBlockReason(reason string) bool {
|
||||
return strings.Contains(reason, "告警触发策略临时封禁")
|
||||
}
|
||||
|
||||
// HandleSecurityAlerts returns all security alerts.
|
||||
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -687,9 +770,17 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
cancelledTasks := 0
|
||||
clearedBlocks := 0
|
||||
if !req.AutoShutdown {
|
||||
cancelledTasks = globalQueue.CancelPendingSecurityStops()
|
||||
clearedBlocks = clearSecurityPolicyBlocks()
|
||||
}
|
||||
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
"cancelled_tasks": cancelledTasks,
|
||||
"cleared_blocks": clearedBlocks,
|
||||
}})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
@@ -759,28 +850,49 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func getConnectionLogs(ip string) []map[string]interface{} {
|
||||
logs := make([]map[string]interface{}, 0)
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, line := range readConntrackLines(ip) {
|
||||
parseLine := func(line string) map[string]interface{} {
|
||||
srcIP := extractField(line, "src=")
|
||||
dstIP := extractField(line, "dst=")
|
||||
srcPort := extractField(line, "sport=")
|
||||
dstPort := extractField(line, "dport=")
|
||||
|
||||
sPort, _ := strconv.Atoi(srcPort)
|
||||
dPort, _ := strconv.Atoi(dstPort)
|
||||
|
||||
logs = append(logs, map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"src_ip": srcIP,
|
||||
"dst_ip": dstIP,
|
||||
"src_port": sPort,
|
||||
"dst_port": dPort,
|
||||
"protocol": extractProtocol(line),
|
||||
"state": extractConnState(line),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// First, load stored snapshots from database (persisted at alert time).
|
||||
for _, line := range config.GetConntrackSnapshotLines(ip) {
|
||||
if len(logs) >= 100 {
|
||||
break
|
||||
}
|
||||
key := strings.TrimSpace(line)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
logs = append(logs, parseLine(line))
|
||||
}
|
||||
|
||||
// Then, merge live conntrack data (deduplicated).
|
||||
for _, line := range readConntrackLines(ip) {
|
||||
if len(logs) >= 100 {
|
||||
break
|
||||
}
|
||||
key := strings.TrimSpace(line)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
logs = append(logs, parseLine(line))
|
||||
}
|
||||
|
||||
return logs
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestDetectReflectionAbuseIgnoresSingleDNSResolver(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
stats := newTrafficStats()
|
||||
for i := 0; i < 180; i++ {
|
||||
stats.add(connEntry{
|
||||
dstIP: "1.1.1.1",
|
||||
dstPort: 53,
|
||||
proto: "udp",
|
||||
state: "UNREPLIED",
|
||||
})
|
||||
}
|
||||
|
||||
ss := newSecurityScanner()
|
||||
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
|
||||
|
||||
if len(ss.alerts) != 0 {
|
||||
t.Fatalf("normal DNS queries to one resolver should not trigger reflection alert: %+v", ss.alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReflectionAbuseFlagsWideDNSFanout(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
stats := newTrafficStats()
|
||||
for i := 0; i < 120; i++ {
|
||||
stats.add(connEntry{
|
||||
dstIP: fmt.Sprintf("203.0.113.%d", i),
|
||||
dstPort: 53,
|
||||
proto: "udp",
|
||||
state: "UNREPLIED",
|
||||
})
|
||||
}
|
||||
|
||||
ss := newSecurityScanner()
|
||||
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
|
||||
|
||||
if len(ss.alerts) != 1 {
|
||||
t.Fatalf("expected one reflection alert, got %+v", ss.alerts)
|
||||
}
|
||||
if got := ss.alerts[0].Type; got != "reflection" {
|
||||
t.Fatalf("expected reflection alert, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPortScansUsesHalfOpenConnections(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
established := newTrafficStats()
|
||||
for port := 8000; port < 8020; port++ {
|
||||
established.add(connEntry{
|
||||
dstIP: "198.51.100.10",
|
||||
dstPort: port,
|
||||
proto: "tcp",
|
||||
state: "ESTABLISHED",
|
||||
})
|
||||
}
|
||||
|
||||
ss := newSecurityScanner()
|
||||
ss.detectPortScans("ct-web", "10.0.0.3", established)
|
||||
if len(ss.alerts) != 0 {
|
||||
t.Fatalf("established multi-port connections should not trigger port scan alert: %+v", ss.alerts)
|
||||
}
|
||||
|
||||
halfOpen := newTrafficStats()
|
||||
for port := 8000; port < 8012; port++ {
|
||||
halfOpen.add(connEntry{
|
||||
dstIP: "198.51.100.10",
|
||||
dstPort: port,
|
||||
proto: "tcp",
|
||||
state: "SYN_SENT",
|
||||
})
|
||||
}
|
||||
|
||||
ss.detectPortScans("ct-web", "10.0.0.3", halfOpen)
|
||||
if len(ss.alerts) != 1 {
|
||||
t.Fatalf("expected one port scan alert, got %+v", ss.alerts)
|
||||
}
|
||||
if got := ss.alerts[0].Type; got != "port_scan" {
|
||||
t.Fatalf("expected port_scan alert, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPendingSecurityStops(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
q := &TaskQueue{
|
||||
tasks: map[string]*Task{},
|
||||
}
|
||||
securityTask := &Task{
|
||||
ID: "task-1",
|
||||
Type: TaskStop,
|
||||
ContainerID: 1,
|
||||
Status: "pending",
|
||||
User: "system:security",
|
||||
}
|
||||
userTask := &Task{
|
||||
ID: "task-2",
|
||||
Type: TaskStop,
|
||||
ContainerID: 2,
|
||||
Status: "pending",
|
||||
User: "admin",
|
||||
}
|
||||
runningSecurityTask := &Task{
|
||||
ID: "task-3",
|
||||
Type: TaskStop,
|
||||
ContainerID: 3,
|
||||
Status: "running",
|
||||
User: "system:security",
|
||||
}
|
||||
q.tasks[securityTask.ID] = securityTask
|
||||
q.tasks[userTask.ID] = userTask
|
||||
q.tasks[runningSecurityTask.ID] = runningSecurityTask
|
||||
q.opQueue = []*Task{securityTask, userTask, runningSecurityTask}
|
||||
|
||||
if got := q.CancelPendingSecurityStops(); got != 1 {
|
||||
t.Fatalf("expected one pending security stop to be cancelled, got %d", got)
|
||||
}
|
||||
if _, ok := q.tasks[securityTask.ID]; ok {
|
||||
t.Fatal("pending security stop task was not removed")
|
||||
}
|
||||
if _, ok := q.tasks[userTask.ID]; !ok {
|
||||
t.Fatal("user stop task should not be removed")
|
||||
}
|
||||
if _, ok := q.tasks[runningSecurityTask.ID]; !ok {
|
||||
t.Fatal("running security stop task should be left for worker-side skip")
|
||||
}
|
||||
if len(q.opQueue) != 2 {
|
||||
t.Fatalf("expected op queue to keep two tasks, got %d", len(q.opQueue))
|
||||
}
|
||||
}
|
||||
|
||||
func resetSecurityTestConfig() {
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
Containers: []config.Container{},
|
||||
AuditLogs: []config.AuditLog{},
|
||||
Tasks: []config.SavedTask{},
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,34 @@ type LoginLog struct {
|
||||
|
||||
var loginLogs = make([]LoginLog, 0)
|
||||
|
||||
// HandleLanguage returns or updates the global panel language.
|
||||
func HandleLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"language": config.NormalizeLanguage(config.AppConfig.Language),
|
||||
}})
|
||||
case http.MethodPost, http.MethodPut:
|
||||
var req struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
config.AppConfig.Language = config.NormalizeLanguage(req.Language)
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save language"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"language": config.AppConfig.Language,
|
||||
}})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||
config.AddLoginLog(username, ip, userAgent, success)
|
||||
|
||||
+37
-30
@@ -17,7 +17,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -92,10 +91,12 @@ func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if target == "" {
|
||||
target = detectedRequestHost(r)
|
||||
}
|
||||
if target == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"})
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
target = normalizedTarget
|
||||
|
||||
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
|
||||
if err != nil {
|
||||
@@ -220,12 +221,10 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
|
||||
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
|
||||
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
|
||||
}
|
||||
dir := sslStorageDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
certPath, keyPath, err := config.UploadedSSLPaths()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
certPath := filepath.Join(dir, "uploaded-fullchain.pem")
|
||||
keyPath := filepath.Join(dir, "uploaded-privkey.pem")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -237,9 +236,11 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
|
||||
|
||||
func generateSelfSignedCertificate(target string) (string, string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", "", fmt.Errorf("self-signed certificate target is required")
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target = normalizedTarget
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -273,12 +274,10 @@ func generateSelfSignedCertificate(target string) (string, string, error) {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
dir := sslStorageDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
certPath, keyPath, err := config.SelfSignedSSLPaths()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
certPath := filepath.Join(dir, "self-signed-fullchain.pem")
|
||||
keyPath := filepath.Join(dir, "self-signed-privkey.pem")
|
||||
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
|
||||
@@ -295,9 +294,11 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
|
||||
return "", "", fmt.Errorf("certbot is not installed on this server")
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", "", fmt.Errorf("Let's Encrypt target is required")
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target = normalizedTarget
|
||||
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
|
||||
if email != "" {
|
||||
args = append(args, "--email", email)
|
||||
@@ -317,12 +318,14 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem")
|
||||
keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem")
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
certPath, keyPath, err := config.LetsEncryptSSLPaths(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := config.ReadableFileStat(certPath); err != nil {
|
||||
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
if _, err := config.ReadableFileStat(keyPath); err != nil {
|
||||
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
|
||||
}
|
||||
return certPath, keyPath, nil
|
||||
@@ -347,11 +350,19 @@ func ensureCertbotSupportsIPCertificates() error {
|
||||
}
|
||||
|
||||
func validateCertificatePair(certPath, keyPath string) error {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certPEM, err := os.ReadFile(safeCertPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(safeKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -443,7 +454,11 @@ func readLeafCertificate(certPath string) (*x509.Certificate, error) {
|
||||
if certPath == "" {
|
||||
return nil, errors.New("certificate path is empty")
|
||||
}
|
||||
data, err := os.ReadFile(certPath)
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(safeCertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -494,14 +509,6 @@ func firstPublicInterfaceIP() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func sslStorageDir() string {
|
||||
dataDir := config.AppConfig.DataDir
|
||||
if dataDir == "" {
|
||||
dataDir = "/root/.clicd"
|
||||
}
|
||||
return filepath.Join(dataDir, "ssl")
|
||||
}
|
||||
|
||||
func maskExistingPath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
|
||||
@@ -373,6 +373,15 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
imagesEnabledPath := "/api/images/enabled"
|
||||
if strings.HasPrefix(path, "/api/v1/") {
|
||||
imagesEnabledPath = "/api/v1/images/enabled"
|
||||
}
|
||||
if path == imagesEnabledPath && r.Method == http.MethodGet {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if path == containerListPath {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
|
||||
@@ -503,7 +512,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "snapshots/"):
|
||||
return method == http.MethodDelete || method == http.MethodPost
|
||||
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
|
||||
case action == "start" || action == "stop" || action == "restart" || action == "reinstall" || action == "reset-password":
|
||||
return method == http.MethodPost
|
||||
case strings.HasPrefix(action, "port-mappings/"):
|
||||
return method == http.MethodPut
|
||||
|
||||
@@ -75,6 +75,10 @@ func (q *TaskQueue) enqueueTask(task *Task) {
|
||||
}
|
||||
|
||||
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
|
||||
return q.EnqueueWithAudit(containerID, containerName, taskType, templateID, cfg, "admin", "", "")
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig, user string, ip string, userAgent string) []string {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
@@ -88,9 +92,13 @@ func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType Task
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
if cfg != nil {
|
||||
task.Config = *cfg
|
||||
task.Config.NormalizeResourceAliases()
|
||||
}
|
||||
q.enqueueTask(task)
|
||||
q.persistTasks()
|
||||
@@ -155,6 +163,7 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
|
||||
var result []string
|
||||
for _, cfg := range configs {
|
||||
cfgCopy := cfg
|
||||
cfgCopy.NormalizeResourceAliases()
|
||||
id := q.nextID
|
||||
q.nextID++
|
||||
task := &Task{
|
||||
@@ -204,6 +213,10 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
|
||||
if !config.AppConfig.SecurityAutoShutdown {
|
||||
return "", false
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
@@ -221,6 +234,34 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
|
||||
return taskID, true
|
||||
}
|
||||
|
||||
func (q *TaskQueue) CancelPendingSecurityStops() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
cancelled := 0
|
||||
newOpQueue := make([]*Task, 0, len(q.opQueue))
|
||||
for _, task := range q.opQueue {
|
||||
if isSecurityStopTask(task) && task.Status == "pending" {
|
||||
delete(q.tasks, task.ID)
|
||||
cancelled++
|
||||
continue
|
||||
}
|
||||
newOpQueue = append(newOpQueue, task)
|
||||
}
|
||||
q.opQueue = newOpQueue
|
||||
|
||||
for id, task := range q.tasks {
|
||||
if isSecurityStopTask(task) && task.Status == "pending" {
|
||||
delete(q.tasks, id)
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
if cancelled > 0 {
|
||||
q.persistTasks()
|
||||
}
|
||||
return cancelled
|
||||
}
|
||||
|
||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
||||
// If a restored task already has a same-name container in config, it resumes
|
||||
// initialization instead of creating another ct-{id}.
|
||||
@@ -239,6 +280,7 @@ func (q *TaskQueue) createWorker() {
|
||||
if task.Config.Name == "" {
|
||||
task.Config.Name = task.ContainerName
|
||||
}
|
||||
task.Config.NormalizeResourceAliases()
|
||||
if task.Config.Name == "" {
|
||||
task.Status = "failed"
|
||||
task.Error = "container name is required"
|
||||
@@ -314,6 +356,7 @@ func (q *TaskQueue) opWorker() {
|
||||
q.mu.Unlock()
|
||||
|
||||
var err error
|
||||
skipped := false
|
||||
err = resolveTaskContainer(task)
|
||||
// Block operations on expired or traffic-exceeded containers (except stop/delete)
|
||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||
@@ -326,24 +369,33 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||
skipped = true
|
||||
}
|
||||
if err == nil {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||
if !skipped {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
if lxc.HasSSHAuthOptions(task.Config) {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||
} else {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +408,9 @@ func (q *TaskQueue) opWorker() {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||
} else if skipped {
|
||||
task.Status = "done"
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||
@@ -377,6 +432,10 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
func isSecurityStopTask(task *Task) bool {
|
||||
return task != nil && task.Type == TaskStop && task.User == "system:security"
|
||||
}
|
||||
|
||||
func clearPolicyBlockAfterAdminRecovery(task *Task) {
|
||||
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
|
||||
return
|
||||
@@ -472,6 +531,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
|
||||
var taskType TaskType
|
||||
var templateID string
|
||||
var taskConfig *lxc.ContainerConfig
|
||||
switch action {
|
||||
case "start":
|
||||
taskType = TaskStart
|
||||
@@ -483,7 +543,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
taskType = TaskDelete
|
||||
case "reinstall":
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
templateID = req.TemplateID
|
||||
@@ -501,13 +564,26 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
authCfg := lxc.ContainerConfig{
|
||||
TemplateID: templateID,
|
||||
SSHAuthMode: req.SSHAuthMode,
|
||||
SSHPassword: req.SSHPassword,
|
||||
SSHPublicKey: req.SSHPublicKey,
|
||||
}
|
||||
if lxc.HasSSHAuthOptions(authCfg) {
|
||||
if err := validateReinstallSSHAuth(c, templateID, authCfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
taskConfig = &authCfg
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
default:
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
|
||||
return
|
||||
}
|
||||
|
||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
|
||||
ids := globalQueue.EnqueueWithAudit(id, name, taskType, templateID, taskConfig, user, ip, userAgent)
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{
|
||||
Success: true,
|
||||
Message: "Task queued",
|
||||
@@ -564,6 +640,11 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Containers[i].VCPU <= 0 {
|
||||
req.Containers[i].VCPU = 1
|
||||
}
|
||||
if err := rejectNegativeCreateLimits(req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.Containers[i].NormalizeResourceAliases()
|
||||
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
|
||||
if req.Containers[i].RAMMB < 128 {
|
||||
req.Containers[i].RAMMB = 512
|
||||
@@ -575,8 +656,37 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].PortMappingCount < 2 {
|
||||
if req.Containers[i].PortMappingCount < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
|
||||
req.Containers[i].PortMappingCount = 2
|
||||
} else if !req.Containers[i].WantsNAT() {
|
||||
req.Containers[i].PortMappingCount = 0
|
||||
req.Containers[i].ExtraPorts = nil
|
||||
}
|
||||
if req.Containers[i].PortMappingCount > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].IPv4Count < 0 || req.Containers[i].IPv6Count < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot be negative"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].IPv4Count > 64 || req.Containers[i].IPv6Count > 64 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot exceed 64"})
|
||||
return
|
||||
}
|
||||
if !req.Containers[i].AssignIPv4 && len(req.Containers[i].PublicIPv4s) == 0 {
|
||||
req.Containers[i].IPv4Count = 0
|
||||
}
|
||||
if !req.Containers[i].AssignIPv6 && len(req.Containers[i].IPv6Addresses) == 0 {
|
||||
req.Containers[i].IPv6Count = 0
|
||||
}
|
||||
if !hasRequestedNetwork(req.Containers[i]) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + noNetworkSelectedMessage})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].SnapshotLimit <= 0 {
|
||||
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
||||
@@ -585,6 +695,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateSSHAuth(req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
requestNames[name] = true
|
||||
}
|
||||
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
||||
@@ -602,9 +716,12 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Containers []int `json:"containers"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Containers []int `json:"containers"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
@@ -613,6 +730,7 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var taskType TaskType
|
||||
var requiredScope string
|
||||
var taskConfig *lxc.ContainerConfig
|
||||
switch req.Action {
|
||||
case "start":
|
||||
taskType = TaskStart
|
||||
@@ -635,6 +753,15 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
authCfg := lxc.ContainerConfig{
|
||||
TemplateID: req.TemplateID,
|
||||
SSHAuthMode: req.SSHAuthMode,
|
||||
SSHPassword: req.SSHPassword,
|
||||
SSHPublicKey: req.SSHPublicKey,
|
||||
}
|
||||
if lxc.HasSSHAuthOptions(authCfg) {
|
||||
taskConfig = &authCfg
|
||||
}
|
||||
taskType = TaskReinstall
|
||||
requiredScope = "container:reinstall"
|
||||
default:
|
||||
@@ -650,9 +777,28 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||
return
|
||||
}
|
||||
if taskConfig != nil {
|
||||
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||
var ids []string
|
||||
if taskConfig != nil {
|
||||
for _, id := range req.Containers {
|
||||
c := config.FindContainer(id)
|
||||
name := ""
|
||||
if c != nil {
|
||||
name = c.Name
|
||||
}
|
||||
queued := globalQueue.EnqueueWithAudit(id, name, taskType, req.TemplateID, taskConfig, requestActor(r), clientIP(r), r.UserAgent())
|
||||
ids = append(ids, queued...)
|
||||
}
|
||||
} else {
|
||||
ids = globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
|
||||
}
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||
}
|
||||
|
||||
@@ -716,6 +862,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
// RestoreTasks restores task queue from config
|
||||
func RestoreTasks() {
|
||||
for _, st := range config.AppConfig.Tasks {
|
||||
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
|
||||
continue
|
||||
}
|
||||
var cfg lxc.ContainerConfig
|
||||
if st.Config != "" {
|
||||
json.Unmarshal([]byte(st.Config), &cfg)
|
||||
@@ -727,6 +876,7 @@ func RestoreTasks() {
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = containerName
|
||||
}
|
||||
cfg.NormalizeResourceAliases()
|
||||
containerID := st.ContainerID
|
||||
if containerID <= 0 && containerName != "" {
|
||||
if c := config.FindContainerByName(containerName); c != nil {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
originURL, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
originHost := strings.ToLower(stripPort(originURL.Host))
|
||||
requestHost := strings.ToLower(stripPort(r.Host))
|
||||
return originHost != "" && originHost == requestHost
|
||||
return config.IsOriginAllowed(origin, r.Host)
|
||||
},
|
||||
}
|
||||
|
||||
func stripPort(host string) string {
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
return parsedHost
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
+424
-127
@@ -9,6 +9,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,18 +28,172 @@ const (
|
||||
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||
)
|
||||
|
||||
var cliEnglish = detectCLIEnglish()
|
||||
|
||||
var cliTranslations = map[string]string{
|
||||
"重新加载配置失败": "Failed to reload config",
|
||||
"请选择操作": "Select an action",
|
||||
"再见": "Goodbye",
|
||||
"无效选择": "Invalid choice",
|
||||
"CLICD - LXC 容器管理器": "CLICD - Container Manager",
|
||||
"Web 面板": "Web panel",
|
||||
"端口": "port",
|
||||
"运行中": "running",
|
||||
"已停止": "stopped",
|
||||
"当前版本": "Current version",
|
||||
"查看容器列表": "List containers",
|
||||
"创建容器": "Create container",
|
||||
"开机容器": "Start container",
|
||||
"关机容器": "Stop container",
|
||||
"重启容器": "Restart container",
|
||||
"删除容器": "Delete container",
|
||||
"重装容器系统": "Reinstall container OS",
|
||||
"重置 Web 管理员密码": "Reset web admin password",
|
||||
"启动": "Start",
|
||||
"停止": "Stop",
|
||||
"导入现有 LXC 容器": "Import existing LXC containers",
|
||||
"检查并升级 CLICD": "Check and upgrade CLICD",
|
||||
"卸载 CLICD": "Uninstall CLICD",
|
||||
"系统信息": "System info",
|
||||
"退出": "Exit",
|
||||
"获取容器列表失败": "Failed to get container list",
|
||||
"暂无容器": "No containers",
|
||||
"容器": "Container",
|
||||
"名称": "Name",
|
||||
"状态": "Status",
|
||||
"镜像": "Image",
|
||||
"内存(MB)": "Memory(MB)",
|
||||
"磁盘(GB)": "Disk(GB)",
|
||||
"容器名称": "Container name",
|
||||
"容器名称不能为空": "Container name cannot be empty",
|
||||
"可用镜像": "Available images",
|
||||
"镜像选择无效": "Invalid image selection",
|
||||
"内存 (MB)": "Memory (MB)",
|
||||
"磁盘 (GB)": "Disk (GB)",
|
||||
"网络带宽 (Mbps)": "Network bandwidth (Mbps)",
|
||||
"月流量 (GB)": "Monthly traffic (GB)",
|
||||
"IO 速度 (MB/s)": "IO speed (MB/s)",
|
||||
"额外 NAT 端口,多个用逗号分隔": "Extra NAT ports, comma-separated",
|
||||
"正在创建容器": "Creating container",
|
||||
"创建失败": "Create failed",
|
||||
"创建成功": "created successfully",
|
||||
"端口未分配": "port not assigned",
|
||||
"密码已保存,请在 Web 面板中查看或重置": "Password saved. View or reset it in the web panel",
|
||||
"开机失败": "Start failed",
|
||||
"已开机": "started",
|
||||
"关机失败": "Stop failed",
|
||||
"已关机": "stopped",
|
||||
"重启失败": "Restart failed",
|
||||
"已重启": "restarted",
|
||||
"开机": "start",
|
||||
"关机": "stop",
|
||||
"重启": "restart",
|
||||
"删除": "delete",
|
||||
"重装": "reinstall",
|
||||
"确认删除容器": "Delete container",
|
||||
"输入 yes 继续": "type yes to continue",
|
||||
"已取消": "Cancelled",
|
||||
"删除失败": "Delete failed",
|
||||
"已删除": "deleted",
|
||||
"确认重装容器": "Reinstall container",
|
||||
"重装失败": "Reinstall failed",
|
||||
"已重装": "reinstalled",
|
||||
"新的管理员密码(至少 6 位)": "New admin password (at least 6 characters)",
|
||||
"密码至少需要 6 位": "Password must be at least 6 characters",
|
||||
"确认密码": "Confirm password",
|
||||
"两次输入的密码不一致": "Passwords do not match",
|
||||
"管理员密码已重置。": "Admin password has been reset.",
|
||||
"按 Enter 返回菜单": "Press Enter to return to menu",
|
||||
"选择要": "Select a container to ",
|
||||
"的容器": "",
|
||||
"选择无效": "Invalid selection",
|
||||
"主机名": "Hostname",
|
||||
"管理员用户": "Admin user",
|
||||
"容器总数": "Total containers",
|
||||
"切换语言": "Switch language",
|
||||
"当前语言": "Current language",
|
||||
"请选择语言": "Select language",
|
||||
"语言已切换为": "Language switched to",
|
||||
"保存语言失败": "Failed to save language",
|
||||
"简体中文": "Simplified Chinese",
|
||||
"重置失败": "Reset failed",
|
||||
"停止 Web 面板失败": "Failed to stop web panel",
|
||||
"Web 面板已停止,LXC 容器不会受影响。": "Web panel stopped. LXC containers are not affected.",
|
||||
"启动 Web 面板失败": "Failed to start web panel",
|
||||
"Web 面板已启动": "Web panel started",
|
||||
"升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。": "The upgrade only replaces /usr/local/bin/clicd and keeps configuration, container data, and task records under /root/.clicd.",
|
||||
"升级需要 root 权限。请使用: sudo clicd cli": "Upgrade requires root privileges. Use: sudo clicd cli",
|
||||
"检查仓库": "Checking repository",
|
||||
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
|
||||
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
|
||||
"最新版本": "Latest version",
|
||||
"发布页面": "Release page",
|
||||
"当前架构不支持自动升级": "Automatic upgrade is not supported on the current architecture",
|
||||
"最新 Release 没有找到": "The latest release does not contain",
|
||||
"无法自动升级。": "automatic upgrade is unavailable.",
|
||||
"当前已经是最新版本。": "The current version is already the latest.",
|
||||
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
||||
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
||||
"已取消。": "Cancelled.",
|
||||
"升级失败": "Upgrade failed",
|
||||
"升级完成": "Upgrade completed",
|
||||
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
|
||||
"GitHub API 返回": "GitHub API returned",
|
||||
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
|
||||
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
|
||||
"GitHub releases/latest 返回": "GitHub releases/latest returned",
|
||||
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
|
||||
"正在下载升级包...": "Downloading upgrade package...",
|
||||
"正在解压升级包...": "Extracting upgrade package...",
|
||||
"解压失败": "Extraction failed",
|
||||
"备份旧二进制失败": "Failed to back up old binary",
|
||||
"旧版本已备份": "Old version backed up",
|
||||
"正在替换二进制...": "Replacing binary...",
|
||||
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
|
||||
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
|
||||
"下载失败,HTTP": "Download failed, HTTP",
|
||||
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
|
||||
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
|
||||
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
|
||||
"导入失败": "Import failed",
|
||||
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
|
||||
"已导入": "Imported",
|
||||
"个容器": "containers",
|
||||
"将删除 CLICD 服务和 /usr/local/bin/clicd。": "This will remove the CLICD service and /usr/local/bin/clicd.",
|
||||
"同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。": "It will also remove /root/.clicd, /var/lib/lxc, /var/lib/clicd, image caches, backups, temporary files, /swapfile, and CLICD network rules.",
|
||||
"卸载需要 root 权限。": "Uninstall requires root privileges.",
|
||||
"请运行: sudo clicd cli --no-web": "Run: sudo clicd cli --no-web",
|
||||
"输入 uninstall 继续卸载": "Type uninstall to continue uninstalling",
|
||||
"CLICD 已卸载。": "CLICD has been uninstalled.",
|
||||
"服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。": "Service, binary, configuration, containers/VMs, local images, cache, backups, temporary files, and CLICD network rules have been removed.",
|
||||
"检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。": "Non-CLICD VMs are still using the libvirt default network, so default/virbr0 has been kept.",
|
||||
"Web 面板重载跳过": "Web panel reload skipped",
|
||||
"Web 面板已重载并应用配置变更。": "Web panel reloaded and configuration changes applied.",
|
||||
"读取容器状态失败": "Failed to read container status",
|
||||
"CLICD 版本": "CLICD version",
|
||||
"Web 端口": "Web port",
|
||||
"LXC 版本": "LXC version",
|
||||
"暂无可用容器": "No available containers",
|
||||
"忽略无效端口": "Ignoring invalid port",
|
||||
"?": "? ",
|
||||
"。": ". ",
|
||||
",": ", ",
|
||||
":": ": ",
|
||||
}
|
||||
|
||||
// Run starts the CLI interface.
|
||||
func Run() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
if _, err := config.InitConfig(); err != nil {
|
||||
fmt.Printf("重新加载配置失败: %v\n", err)
|
||||
cliPrintf("重新加载配置失败: %v\n", err)
|
||||
waitEnter(reader)
|
||||
}
|
||||
refreshCLILanguage()
|
||||
clearScreen()
|
||||
printMenu()
|
||||
fmt.Print("\n请选择操作 [1-12,0/q]: ")
|
||||
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
@@ -94,11 +250,15 @@ func Run() {
|
||||
clearScreen()
|
||||
cliShowInfo()
|
||||
waitEnter(reader)
|
||||
case "l", "lang", "language":
|
||||
clearScreen()
|
||||
cliSwitchLanguage(reader)
|
||||
waitEnter(reader)
|
||||
case "q", "exit", "quit":
|
||||
fmt.Println("再见")
|
||||
cliPrintln("再见")
|
||||
return
|
||||
default:
|
||||
fmt.Println("无效选择")
|
||||
cliPrintln("无效选择")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,49 +268,86 @@ func printMenu() {
|
||||
if isWebPanelRunning() {
|
||||
webStatus = "停止"
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" ==========================================")
|
||||
fmt.Println(" CLICD - LXC 容器管理器")
|
||||
fmt.Println(" ==========================================")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string {
|
||||
cliPrintln("")
|
||||
cliPrintln(" ==========================================")
|
||||
cliPrintln(" CLICD - LXC 容器管理器")
|
||||
cliPrintln(" ==========================================")
|
||||
cliPrintln("")
|
||||
cliPrintf(" Web 面板: %s (端口 %d)\n", func() string {
|
||||
if isWebPanelRunning() {
|
||||
return "运行中"
|
||||
}
|
||||
return "已停止"
|
||||
}(), config.AppConfig.Port)
|
||||
fmt.Printf(" 当前版本: %s\n", version.Current())
|
||||
fmt.Println()
|
||||
fmt.Println(" 1. 查看容器列表")
|
||||
fmt.Println(" 2. 创建容器")
|
||||
fmt.Println(" 3. 开机容器")
|
||||
fmt.Println(" 4. 关机容器")
|
||||
fmt.Println(" 5. 重启容器")
|
||||
fmt.Println(" 6. 删除容器")
|
||||
fmt.Println(" 7. 重装容器系统")
|
||||
fmt.Println(" 8. 重置 Web 管理员密码")
|
||||
fmt.Printf(" 9. %s Web 面板\n", webStatus)
|
||||
fmt.Println(" 10. 导入现有 LXC 容器")
|
||||
fmt.Println(" 11. 检查并升级 CLICD")
|
||||
fmt.Println(" 12. 卸载 CLICD")
|
||||
fmt.Println(" 0. 系统信息")
|
||||
fmt.Println(" q. 退出")
|
||||
cliPrintf(" 当前版本: %s\n", version.Current())
|
||||
cliPrintln("")
|
||||
cliPrintln(" 1. 查看容器列表")
|
||||
cliPrintln(" 2. 创建容器")
|
||||
cliPrintln(" 3. 开机容器")
|
||||
cliPrintln(" 4. 关机容器")
|
||||
cliPrintln(" 5. 重启容器")
|
||||
cliPrintln(" 6. 删除容器")
|
||||
cliPrintln(" 7. 重装容器系统")
|
||||
cliPrintln(" 8. 重置 Web 管理员密码")
|
||||
cliPrintf(" 9. %s Web 面板\n", webStatus)
|
||||
cliPrintln(" 10. 导入现有 LXC 容器")
|
||||
cliPrintln(" 11. 检查并升级 CLICD")
|
||||
cliPrintln(" 12. 卸载 CLICD")
|
||||
cliPrintln(" 0. 系统信息")
|
||||
cliPrintln(" l. 切换语言")
|
||||
cliPrintln(" q. 退出")
|
||||
}
|
||||
|
||||
func cliSwitchLanguage(reader *bufio.Reader) {
|
||||
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
|
||||
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
|
||||
cliPrintln(" 1. 简体中文")
|
||||
cliPrintln(" 2. English")
|
||||
choice := promptString(reader, "请选择语言 [1/2]", func() string {
|
||||
if config.NormalizeLanguage(config.AppConfig.Language) == "en" {
|
||||
return "2"
|
||||
}
|
||||
return "1"
|
||||
}())
|
||||
|
||||
next := "zh"
|
||||
switch strings.ToLower(strings.TrimSpace(choice)) {
|
||||
case "2", "en", "english":
|
||||
next = "en"
|
||||
case "1", "zh", "cn", "chinese":
|
||||
next = "zh"
|
||||
default:
|
||||
cliPrintln("无效选择")
|
||||
return
|
||||
}
|
||||
|
||||
config.AppConfig.Language = next
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
cliPrintf("保存语言失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
_ = os.Setenv("CLICD_LANG", next)
|
||||
refreshCLILanguage()
|
||||
cliPrintf("%s: %s\n", cliT("语言已切换为"), cliLanguageLabel(next))
|
||||
if isWebPanelRunning() {
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
}
|
||||
|
||||
func cliListContainers() {
|
||||
containers, err := manager.ListContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("获取容器列表失败: %v\n", err)
|
||||
cliPrintf("获取容器列表失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
fmt.Println("\n暂无容器")
|
||||
cliPrintln("\n暂无容器")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "名称", "状态", "镜像", "vCPU", "内存(MB)", "磁盘(GB)", "SSH")
|
||||
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", cliT("名称"), cliT("状态"), cliT("镜像"), "vCPU", cliT("内存(MB)"), cliT("磁盘(GB)"), "SSH")
|
||||
fmt.Println(strings.Repeat("-", 94))
|
||||
for _, c := range containers {
|
||||
ssh := "-"
|
||||
@@ -163,23 +360,23 @@ func cliListContainers() {
|
||||
}
|
||||
|
||||
func cliCreateContainer(reader *bufio.Reader) {
|
||||
fmt.Println("\n--- 创建容器 ---")
|
||||
cliPrintln("\n--- 创建容器 ---")
|
||||
|
||||
name := promptString(reader, "容器名称", "")
|
||||
if name == "" {
|
||||
fmt.Println("容器名称不能为空")
|
||||
cliPrintln("容器名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
fmt.Println("\n可用镜像:")
|
||||
cliPrintln("\n可用镜像:")
|
||||
for i, template := range templates {
|
||||
fmt.Printf(" %d. %s\n", i+1, template.Name)
|
||||
}
|
||||
|
||||
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
|
||||
if tmplIdx < 1 || tmplIdx > len(templates) {
|
||||
fmt.Println("镜像选择无效")
|
||||
cliPrintln("镜像选择无效")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -194,17 +391,18 @@ func cliCreateContainer(reader *bufio.Reader) {
|
||||
IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500),
|
||||
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
|
||||
}
|
||||
cfg.NormalizeResourceAliases()
|
||||
|
||||
fmt.Printf("\n正在创建容器 %s ...\n", name)
|
||||
cliPrintf("\n正在创建容器 %s ...\n", name)
|
||||
if err := manager.CreateContainer(cfg); err != nil {
|
||||
fmt.Printf("创建失败: %v\n", err)
|
||||
cliPrintf("创建失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
container := config.FindContainerByName(name)
|
||||
fmt.Printf("容器 %s 创建成功\n", name)
|
||||
cliPrintf("容器 %s 创建成功\n", name)
|
||||
if container != nil {
|
||||
fmt.Print(formatSSHAccess(container.SSHPort))
|
||||
cliPrint(formatSSHAccess(container.SSHPort))
|
||||
}
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
@@ -222,10 +420,10 @@ func cliStartContainer(reader *bufio.Reader) {
|
||||
return
|
||||
}
|
||||
if err := manager.StartContainer(id); err != nil {
|
||||
fmt.Printf("开机失败: %v\n", err)
|
||||
cliPrintf("开机失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已开机\n", name)
|
||||
cliPrintf("容器 %s 已开机\n", name)
|
||||
}
|
||||
|
||||
func cliStopContainer(reader *bufio.Reader) {
|
||||
@@ -234,10 +432,10 @@ func cliStopContainer(reader *bufio.Reader) {
|
||||
return
|
||||
}
|
||||
if err := manager.StopContainer(id); err != nil {
|
||||
fmt.Printf("关机失败: %v\n", err)
|
||||
cliPrintf("关机失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已关机\n", name)
|
||||
cliPrintf("容器 %s 已关机\n", name)
|
||||
}
|
||||
|
||||
func cliRestartContainer(reader *bufio.Reader) {
|
||||
@@ -246,10 +444,10 @@ func cliRestartContainer(reader *bufio.Reader) {
|
||||
return
|
||||
}
|
||||
if err := manager.RestartContainer(id); err != nil {
|
||||
fmt.Printf("重启失败: %v\n", err)
|
||||
cliPrintf("重启失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已重启\n", name)
|
||||
cliPrintf("容器 %s 已重启\n", name)
|
||||
}
|
||||
|
||||
func cliDeleteContainer(reader *bufio.Reader) {
|
||||
@@ -259,14 +457,14 @@ func cliDeleteContainer(reader *bufio.Reader) {
|
||||
}
|
||||
confirm := promptString(reader, fmt.Sprintf("确认删除容器 %s?输入 yes 继续", name), "no")
|
||||
if strings.ToLower(confirm) != "yes" {
|
||||
fmt.Println("已取消")
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
}
|
||||
if err := manager.DestroyContainer(id); err != nil {
|
||||
fmt.Printf("删除失败: %v\n", err)
|
||||
cliPrintf("删除失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已删除\n", name)
|
||||
cliPrintf("容器 %s 已删除\n", name)
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
|
||||
@@ -277,66 +475,66 @@ func cliReinstallContainer(reader *bufio.Reader) {
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
fmt.Println("\n可用镜像:")
|
||||
cliPrintln("\n可用镜像:")
|
||||
for i, template := range templates {
|
||||
fmt.Printf(" %d. %s\n", i+1, template.Name)
|
||||
}
|
||||
|
||||
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
|
||||
if tmplIdx < 1 || tmplIdx > len(templates) {
|
||||
fmt.Println("镜像选择无效")
|
||||
cliPrintln("镜像选择无效")
|
||||
return
|
||||
}
|
||||
|
||||
confirm := promptString(reader, fmt.Sprintf("确认重装容器 %s?输入 yes 继续", name), "no")
|
||||
if strings.ToLower(confirm) != "yes" {
|
||||
fmt.Println("已取消")
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
|
||||
fmt.Printf("重装失败: %v\n", err)
|
||||
cliPrintf("重装失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已重装\n", name)
|
||||
cliPrintf("容器 %s 已重装\n", name)
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
|
||||
func cliResetPassword(reader *bufio.Reader) {
|
||||
newPass := promptString(reader, "新的管理员密码(至少 6 位)", "")
|
||||
if len(newPass) < 6 {
|
||||
fmt.Println("密码至少需要 6 位")
|
||||
cliPrintln("密码至少需要 6 位")
|
||||
return
|
||||
}
|
||||
confirm := promptString(reader, "确认密码", "")
|
||||
if newPass != confirm {
|
||||
fmt.Println("两次输入的密码不一致")
|
||||
cliPrintln("两次输入的密码不一致")
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.ResetAdminPassword(newPass); err != nil {
|
||||
fmt.Printf("重置失败: %v\n", err)
|
||||
cliPrintf("重置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("管理员密码已重置。")
|
||||
cliPrintln("管理员密码已重置。")
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
|
||||
func cliToggleWebPanel() {
|
||||
if isWebPanelRunning() {
|
||||
if err := stopService("clicd"); err != nil {
|
||||
fmt.Printf("停止 Web 面板失败: %v\n", err)
|
||||
cliPrintf("停止 Web 面板失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
|
||||
cliPrintln("Web 面板已停止,LXC 容器不会受影响。")
|
||||
return
|
||||
}
|
||||
|
||||
if err := startService("clicd"); err != nil {
|
||||
fmt.Printf("启动 Web 面板失败: %v\n", err)
|
||||
cliPrintf("启动 Web 面板失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Web 面板已启动")
|
||||
cliPrintln("Web 面板已启动")
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
@@ -350,11 +548,11 @@ type githubRelease struct {
|
||||
}
|
||||
|
||||
func cliUpgradeSystem(reader *bufio.Reader) {
|
||||
fmt.Println("\n--- 检查并升级 CLICD ---")
|
||||
fmt.Println("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
|
||||
cliPrintln("\n--- 检查并升级 CLICD ---")
|
||||
cliPrintln("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("升级需要 root 权限。请使用: sudo clicd cli")
|
||||
cliPrintln("升级需要 root 权限。请使用: sudo clicd cli")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -362,55 +560,60 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
||||
if repo == "" {
|
||||
repo = version.Repo
|
||||
}
|
||||
current := version.Current()
|
||||
fmt.Printf("当前版本: %s\n", current)
|
||||
fmt.Printf("检查仓库: https://github.com/%s\n", repo)
|
||||
|
||||
release, err := fetchLatestRelease(repo)
|
||||
assetName, err := releaseArchiveAssetName(runtime.GOARCH)
|
||||
if err != nil {
|
||||
fmt.Printf("检查 GitHub 最新版本失败: %v\n", err)
|
||||
cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
|
||||
return
|
||||
}
|
||||
current := version.Current()
|
||||
cliPrintf("当前版本: %s\n", current)
|
||||
cliPrintf("检查仓库: https://github.com/%s\n", repo)
|
||||
|
||||
release, err := fetchLatestRelease(repo, assetName)
|
||||
if err != nil {
|
||||
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
latest := strings.TrimSpace(release.TagName)
|
||||
if latest == "" {
|
||||
fmt.Println("GitHub Release 没有 tag_name,无法判断最新版本。")
|
||||
cliPrintln("GitHub Release 没有 tag_name,无法判断最新版本。")
|
||||
return
|
||||
}
|
||||
fmt.Printf("最新版本: %s\n", latest)
|
||||
cliPrintf("最新版本: %s\n", latest)
|
||||
if release.HTMLURL != "" {
|
||||
fmt.Printf("发布页面: %s\n", release.HTMLURL)
|
||||
cliPrintf("发布页面: %s\n", release.HTMLURL)
|
||||
}
|
||||
|
||||
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
|
||||
assetURL := findReleaseAsset(release, assetName)
|
||||
if assetURL == "" {
|
||||
fmt.Println("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
|
||||
cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
|
||||
return
|
||||
}
|
||||
|
||||
if sameVersion(current, latest) {
|
||||
fmt.Println("当前已经是最新版本。")
|
||||
cliPrintln("当前已经是最新版本。")
|
||||
confirm := promptString(reader, "是否仍然重新安装最新版本?输入 reinstall 继续", "no")
|
||||
if strings.ToLower(confirm) != "reinstall" {
|
||||
fmt.Println("已取消。")
|
||||
cliPrintln("已取消。")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
confirm := promptString(reader, "输入 upgrade 开始升级", "no")
|
||||
if strings.ToLower(confirm) != "upgrade" {
|
||||
fmt.Println("已取消。")
|
||||
cliPrintln("已取消。")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
|
||||
fmt.Printf("升级失败: %v\n", err)
|
||||
if err := upgradeFromReleaseAsset(assetURL, latest, assetName); err != nil {
|
||||
cliPrintf("升级失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("升级完成: %s -> %s\n", current, latest)
|
||||
fmt.Println("原有数据已保留,Web 服务已重启。")
|
||||
cliPrintf("升级完成: %s -> %s\n", current, latest)
|
||||
cliPrintln("原有数据已保留,Web 服务已重启。")
|
||||
}
|
||||
|
||||
func fetchLatestRelease(repo string) (*githubRelease, error) {
|
||||
func fetchLatestRelease(repo, assetName string) (*githubRelease, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -422,7 +625,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
|
||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
return nil, err
|
||||
@@ -432,11 +635,11 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
|
||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
fmt.Println("GitHub API 被限流,已切换到备用检查方式。")
|
||||
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
|
||||
} else {
|
||||
fmt.Println("GitHub API 不可用,已切换到备用检查方式。")
|
||||
cliPrintln("GitHub API 不可用,已切换到备用检查方式。")
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
@@ -450,7 +653,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
|
||||
func fetchLatestReleaseFallback(repo, assetName string) (*githubRelease, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -472,7 +675,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
|
||||
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
|
||||
}
|
||||
|
||||
const assetName = "clicd-linux-amd64.tar.gz"
|
||||
return &githubRelease{
|
||||
TagName: tag,
|
||||
Name: tag,
|
||||
@@ -513,6 +715,15 @@ func setGitHubRequestHeaders(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func releaseArchiveAssetName(goarch string) (string, error) {
|
||||
switch goarch {
|
||||
case "amd64", "arm64":
|
||||
return fmt.Sprintf("clicd-linux-%s.tar.gz", goarch), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported architecture: %s", goarch)
|
||||
}
|
||||
}
|
||||
|
||||
func findReleaseAsset(release *githubRelease, name string) string {
|
||||
for _, asset := range release.Assets {
|
||||
if asset.Name == name && asset.BrowserDownloadURL != "" {
|
||||
@@ -522,20 +733,20 @@ func findReleaseAsset(release *githubRelease, name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func upgradeFromReleaseAsset(assetURL, latest string) error {
|
||||
func upgradeFromReleaseAsset(assetURL, latest, assetName string) error {
|
||||
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
|
||||
fmt.Println("正在下载升级包...")
|
||||
archivePath := filepath.Join(tmpDir, assetName)
|
||||
cliPrintln("正在下载升级包...")
|
||||
if err := downloadFile(assetURL, archivePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("正在解压升级包...")
|
||||
cliPrintln("正在解压升级包...")
|
||||
if out, err := exec.Command("tar", "-xzf", archivePath, "-C", tmpDir).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("解压失败: %v, output: %s", err, string(out))
|
||||
}
|
||||
@@ -555,12 +766,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("备份旧二进制失败: %w", err)
|
||||
}
|
||||
fmt.Printf("旧版本已备份: %s\n", backupPath)
|
||||
cliPrintf("旧版本已备份: %s\n", backupPath)
|
||||
}
|
||||
|
||||
fmt.Println("正在替换二进制...")
|
||||
cliPrintln("正在替换二进制...")
|
||||
if err := stopService("clicd"); err != nil {
|
||||
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
|
||||
cliPrintf("停止 Web 服务失败,继续尝试替换: %v\n", err)
|
||||
}
|
||||
tmpBin := clicdNewBinaryPath
|
||||
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
|
||||
@@ -715,21 +926,21 @@ func isWebPanelRunning() bool {
|
||||
}
|
||||
|
||||
func cliImportExistingContainers() {
|
||||
fmt.Println("\n--- 导入现有 LXC 容器 ---")
|
||||
fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
|
||||
fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
|
||||
cliPrintln("\n--- 导入现有 LXC 容器 ---")
|
||||
cliPrintln("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
|
||||
cliPrintln("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
|
||||
|
||||
imported, err := manager.ImportExistingClicdContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("导入失败: %v\n", err)
|
||||
cliPrintf("导入失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
if len(imported) == 0 {
|
||||
fmt.Println("没有发现新的 ct-* 容器。")
|
||||
cliPrintln("没有发现新的 ct-* 容器。")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("已导入 %d 个容器:\n", len(imported))
|
||||
cliPrintf("已导入 %d 个容器:\n", len(imported))
|
||||
for _, c := range imported {
|
||||
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
|
||||
}
|
||||
@@ -737,19 +948,19 @@ func cliImportExistingContainers() {
|
||||
}
|
||||
|
||||
func cliUninstall(reader *bufio.Reader) {
|
||||
fmt.Println("\n--- 卸载 CLICD ---")
|
||||
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
|
||||
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
|
||||
cliPrintln("\n--- 卸载 CLICD ---")
|
||||
cliPrintln("将删除 CLICD 服务和 /usr/local/bin/clicd。")
|
||||
cliPrintln("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("卸载需要 root 权限。")
|
||||
fmt.Println("请运行: sudo clicd cli --no-web")
|
||||
cliPrintln("卸载需要 root 权限。")
|
||||
cliPrintln("请运行: sudo clicd cli --no-web")
|
||||
return
|
||||
}
|
||||
|
||||
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
|
||||
if strings.ToLower(confirm) != "uninstall" {
|
||||
fmt.Println("已取消")
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -776,8 +987,8 @@ func cliUninstall(reader *bufio.Reader) {
|
||||
reloadSysctl()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("CLICD 已卸载。")
|
||||
fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
|
||||
cliPrintln("CLICD 已卸载。")
|
||||
cliPrintln("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
|
||||
}
|
||||
|
||||
func destroyAllLXCContainers() {
|
||||
@@ -847,7 +1058,7 @@ func removeCLICDLibvirtDefaultNetwork() {
|
||||
return
|
||||
}
|
||||
if libvirtDefaultUsedByNonCLICDDomain() {
|
||||
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
|
||||
cliPrintln("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
|
||||
return
|
||||
}
|
||||
fmt.Println("Removing CLICD-created libvirt default network...")
|
||||
@@ -1242,10 +1453,10 @@ func shellQuote(value string) string {
|
||||
|
||||
func restartWebPanelForConfigChange() {
|
||||
if err := restartService("clicd"); err != nil {
|
||||
fmt.Printf("Web 面板重载跳过: %v\n", err)
|
||||
cliPrintf("Web 面板重载跳过: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Web 面板已重载并应用配置变更。")
|
||||
cliPrintln("Web 面板已重载并应用配置变更。")
|
||||
}
|
||||
|
||||
func stopService(name string) error {
|
||||
@@ -1281,7 +1492,7 @@ func restartService(name string) error {
|
||||
func cliShowInfo() {
|
||||
containers, err := manager.ListContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("读取容器状态失败: %v\n", err)
|
||||
cliPrintf("读取容器状态失败: %v\n", err)
|
||||
}
|
||||
|
||||
total := len(containers)
|
||||
@@ -1292,44 +1503,44 @@ func cliShowInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n--- 系统信息 ---")
|
||||
fmt.Printf("CLICD 版本: %s\n", version.Current())
|
||||
fmt.Printf("Web 端口: %d\n", config.AppConfig.Port)
|
||||
fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser)
|
||||
fmt.Printf("容器总数: %d\n", total)
|
||||
fmt.Printf("运行中: %d\n", running)
|
||||
fmt.Printf("已停止: %d\n", total-running)
|
||||
cliPrintln("\n--- 系统信息 ---")
|
||||
cliPrintf("CLICD 版本: %s\n", version.Current())
|
||||
cliPrintf("Web 端口: %d\n", config.AppConfig.Port)
|
||||
cliPrintf("管理员用户: %s\n", config.AppConfig.AdminUser)
|
||||
cliPrintf("容器总数: %d\n", total)
|
||||
cliPrintf("运行中: %d\n", running)
|
||||
cliPrintf("已停止: %d\n", total-running)
|
||||
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
fmt.Printf("主机名: %s\n", hostname)
|
||||
cliPrintf("主机名: %s\n", hostname)
|
||||
}
|
||||
|
||||
cmd := exec.Command("lxc-info", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
fmt.Printf("LXC 版本: %s", string(output))
|
||||
cliPrintf("LXC 版本: %s", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func selectContainer(reader *bufio.Reader, action string) (int, string) {
|
||||
containers, err := manager.ListContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("获取容器列表失败: %v\n", err)
|
||||
cliPrintf("获取容器列表失败: %v\n", err)
|
||||
return 0, ""
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
fmt.Println("暂无可用容器")
|
||||
cliPrintln("暂无可用容器")
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
fmt.Printf("\n--- 选择要%s的容器 ---\n", action)
|
||||
cliPrintf("\n--- 选择要%s的容器 ---\n", cliT(action))
|
||||
for i, container := range containers {
|
||||
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status)
|
||||
}
|
||||
|
||||
idx := promptInt(reader, "容器", 0)
|
||||
if idx < 1 || idx > len(containers) {
|
||||
fmt.Println("选择无效")
|
||||
cliPrintln("选择无效")
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
@@ -1338,6 +1549,7 @@ func selectContainer(reader *bufio.Reader, action string) (int, string) {
|
||||
}
|
||||
|
||||
func promptString(reader *bufio.Reader, label string, fallback string) string {
|
||||
label = cliT(label)
|
||||
if fallback == "" {
|
||||
fmt.Printf("%s: ", label)
|
||||
} else {
|
||||
@@ -1375,7 +1587,7 @@ func clearScreen() {
|
||||
}
|
||||
|
||||
func waitEnter(reader *bufio.Reader) {
|
||||
fmt.Print("\n按 Enter 返回菜单...")
|
||||
cliPrint("\n按 Enter 返回菜单...")
|
||||
reader.ReadString('\n')
|
||||
}
|
||||
|
||||
@@ -1389,10 +1601,95 @@ func promptPortList(reader *bufio.Reader, label string) []int {
|
||||
for _, part := range strings.Split(input, ",") {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil || value <= 0 || value > 65535 {
|
||||
fmt.Printf("忽略无效端口: %s\n", strings.TrimSpace(part))
|
||||
cliPrintf("忽略无效端口: %s\n", strings.TrimSpace(part))
|
||||
continue
|
||||
}
|
||||
ports = append(ports, value)
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func detectCLIEnglish() bool {
|
||||
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
|
||||
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
|
||||
return true
|
||||
}
|
||||
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
|
||||
return false
|
||||
}
|
||||
if config.AppConfig != nil {
|
||||
return config.NormalizeLanguage(config.AppConfig.Language) == "en"
|
||||
}
|
||||
env := strings.ToLower(os.Getenv("LC_ALL") + " " + os.Getenv("LC_MESSAGES") + " " + os.Getenv("LANG"))
|
||||
return strings.Contains(env, "en_") || strings.Contains(env, "en-") || strings.Contains(env, "english")
|
||||
}
|
||||
|
||||
func refreshCLILanguage() {
|
||||
cliEnglish = detectCLIEnglish()
|
||||
}
|
||||
|
||||
func cliLanguageLabel(language string) string {
|
||||
if config.NormalizeLanguage(language) == "en" {
|
||||
return "English"
|
||||
}
|
||||
return cliT("简体中文")
|
||||
}
|
||||
|
||||
func cliT(text string) string {
|
||||
if !cliEnglish {
|
||||
return text
|
||||
}
|
||||
translated := text
|
||||
keys := make([]string, 0, len(cliTranslations))
|
||||
for zh := range cliTranslations {
|
||||
keys = append(keys, zh)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if len(keys[i]) == len(keys[j]) {
|
||||
return keys[i] < keys[j]
|
||||
}
|
||||
return len(keys[i]) > len(keys[j])
|
||||
})
|
||||
for _, zh := range keys {
|
||||
en := cliTranslations[zh]
|
||||
translated = strings.ReplaceAll(translated, zh, en)
|
||||
}
|
||||
return translated
|
||||
}
|
||||
|
||||
func cliPrint(args ...interface{}) {
|
||||
if cliEnglish {
|
||||
for i, arg := range args {
|
||||
if s, ok := arg.(string); ok {
|
||||
args[i] = cliT(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Print(args...)
|
||||
}
|
||||
|
||||
func cliPrintln(args ...interface{}) {
|
||||
if cliEnglish {
|
||||
for i, arg := range args {
|
||||
if s, ok := arg.(string); ok {
|
||||
args[i] = cliT(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println(args...)
|
||||
}
|
||||
|
||||
func cliPrintf(format string, args ...interface{}) {
|
||||
if cliEnglish {
|
||||
for i, arg := range args {
|
||||
if s, ok := arg.(string); ok {
|
||||
args[i] = cliT(s)
|
||||
continue
|
||||
}
|
||||
if err, ok := arg.(error); ok {
|
||||
args[i] = cliT(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(cliT(format), args...)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,26 @@ func TestSafeReleaseBackupComponent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseArchiveAssetName(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"amd64": "clicd-linux-amd64.tar.gz",
|
||||
"arm64": "clicd-linux-arm64.tar.gz",
|
||||
}
|
||||
for goarch, want := range tests {
|
||||
got, err := releaseArchiveAssetName(goarch)
|
||||
if err != nil {
|
||||
t.Fatalf("releaseArchiveAssetName(%q) error = %v", goarch, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("releaseArchiveAssetName(%q) = %q, want %q", goarch, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := releaseArchiveAssetName("386"); err == nil {
|
||||
t.Fatal("releaseArchiveAssetName(386) error = nil, want unsupported architecture")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
|
||||
unsafeNames := []string{
|
||||
"../clicd",
|
||||
|
||||
@@ -17,10 +17,44 @@ import (
|
||||
type PortMapping struct {
|
||||
ContainerPort int `json:"container_port"`
|
||||
HostPort int `json:"host_port"`
|
||||
HostIP string `json:"host_ip,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type FirewallRule struct {
|
||||
ID string `json:"id"`
|
||||
Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4"
|
||||
Direction string `json:"direction"` // "in" or "out"
|
||||
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
|
||||
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
|
||||
SourceIP string `json:"source_ip"` // "" = any
|
||||
Action string `json:"action"` // "ACCEPT" or "DROP"
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type PublicIPv4Assignment struct {
|
||||
Address string `json:"address"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
PrefixLen int `json:"prefix_len,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
}
|
||||
|
||||
type IPv6Assignment struct {
|
||||
Address string `json:"address"`
|
||||
PrefixLen int `json:"prefix_len"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
}
|
||||
|
||||
type PublicIPv6Prefix struct {
|
||||
Address string `json:"address"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
PrefixLen int `json:"prefix_len"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
}
|
||||
|
||||
// SavedTask for persisting task queue across restarts
|
||||
type SavedTask struct {
|
||||
ID string `json:"id"`
|
||||
@@ -68,50 +102,59 @@ type VMReadinessCheck struct {
|
||||
|
||||
// Container represents an LXC container configuration
|
||||
type Container struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
LXCName string `json:"lxc_name,omitempty"`
|
||||
KVMName string `json:"kvm_name,omitempty"`
|
||||
DiskImage string `json:"disk_image,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
Template string `json:"template"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
|
||||
TrafficUsedRX int64 `json:"traffic_used_rx"`
|
||||
TrafficUsedTX int64 `json:"traffic_used_tx"`
|
||||
TrafficResetDate string `json:"traffic_reset_date"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||
IPv6Interface string `json:"ipv6_interface"`
|
||||
VNCPort int `json:"vnc_port"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHPassword string `json:"ssh_password"`
|
||||
SSHHostKey string `json:"ssh_host_key,omitempty"`
|
||||
PortMappings []PortMapping `json:"port_mappings"`
|
||||
PortMappingLimit int `json:"port_mapping_limit"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
|
||||
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
|
||||
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
|
||||
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
||||
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
||||
PolicyBlocked bool `json:"policy_blocked"`
|
||||
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
|
||||
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
LXCName string `json:"lxc_name,omitempty"`
|
||||
KVMName string `json:"kvm_name,omitempty"`
|
||||
DiskImage string `json:"disk_image,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
Template string `json:"template"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
|
||||
TrafficUsedRX int64 `json:"traffic_used_rx"`
|
||||
TrafficUsedTX int64 `json:"traffic_used_tx"`
|
||||
TrafficResetDate string `json:"traffic_reset_date"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
Status string `json:"status"`
|
||||
IP string `json:"ip"`
|
||||
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||
IPv6Interface string `json:"ipv6_interface"`
|
||||
IPv6Addresses []IPv6Assignment `json:"ipv6_addresses,omitempty"`
|
||||
VNCPort int `json:"vnc_port"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHPassword string `json:"ssh_password"`
|
||||
SSHHostKey string `json:"ssh_host_key,omitempty"`
|
||||
PortMappings []PortMapping `json:"port_mappings"`
|
||||
PortMappingLimit int `json:"port_mapping_limit"`
|
||||
FirewallEnabled bool `json:"firewall_enabled"`
|
||||
FirewallDefaultAction string `json:"firewall_default_action"`
|
||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
|
||||
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
|
||||
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
|
||||
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
|
||||
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
|
||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
||||
PolicyBlocked bool `json:"policy_blocked"`
|
||||
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
|
||||
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -136,6 +179,101 @@ func (c *Container) IsKVM() bool {
|
||||
return c.Runtime() == VirtualizationKVM
|
||||
}
|
||||
|
||||
func (c *Container) NormalizeNetworkAssignments() bool {
|
||||
changed := false
|
||||
seenIPv4 := map[string]bool{}
|
||||
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
|
||||
for _, item := range c.PublicIPv4s {
|
||||
item.Address = strings.TrimSpace(item.Address)
|
||||
item.Interface = strings.TrimSpace(item.Interface)
|
||||
item.Gateway = strings.TrimSpace(item.Gateway)
|
||||
if item.Address == "" || seenIPv4[item.Address] {
|
||||
if item.Address != "" {
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
seenIPv4[item.Address] = true
|
||||
filteredIPv4 = append(filteredIPv4, item)
|
||||
}
|
||||
if len(filteredIPv4) != len(c.PublicIPv4s) {
|
||||
changed = true
|
||||
}
|
||||
c.PublicIPv4s = filteredIPv4
|
||||
|
||||
seenIPv6 := map[string]bool{}
|
||||
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1)
|
||||
for _, item := range c.IPv6Addresses {
|
||||
item.Address = strings.TrimSpace(item.Address)
|
||||
item.Interface = strings.TrimSpace(item.Interface)
|
||||
if item.Address == "" || seenIPv6[item.Address] {
|
||||
if item.Address != "" {
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
seenIPv6[item.Address] = true
|
||||
filteredIPv6 = append(filteredIPv6, item)
|
||||
}
|
||||
if strings.TrimSpace(c.IPv6) != "" && !seenIPv6[c.IPv6] {
|
||||
filteredIPv6 = append([]IPv6Assignment{{
|
||||
Address: c.IPv6,
|
||||
PrefixLen: c.IPv6PrefixLen,
|
||||
Interface: c.IPv6Interface,
|
||||
}}, filteredIPv6...)
|
||||
changed = true
|
||||
}
|
||||
if len(filteredIPv6) != len(c.IPv6Addresses) {
|
||||
changed = true
|
||||
}
|
||||
c.IPv6Addresses = filteredIPv6
|
||||
if len(c.IPv6Addresses) > 0 {
|
||||
first := c.IPv6Addresses[0]
|
||||
if c.IPv6 != first.Address || c.IPv6PrefixLen != first.PrefixLen || c.IPv6Interface != first.Interface {
|
||||
c.IPv6 = first.Address
|
||||
c.IPv6PrefixLen = first.PrefixLen
|
||||
c.IPv6Interface = first.Interface
|
||||
changed = true
|
||||
}
|
||||
} else if c.IPv6 != "" || c.IPv6PrefixLen != 0 || c.IPv6Interface != "" {
|
||||
c.IPv6 = ""
|
||||
c.IPv6PrefixLen = 0
|
||||
c.IPv6Interface = ""
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (c *Container) PublicIPv4Addresses() []string {
|
||||
values := make([]string, 0, len(c.PublicIPv4s))
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if item.Address != "" {
|
||||
values = append(values, item.Address)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (c *Container) PrimaryPublicIPv4() string {
|
||||
if len(c.PublicIPv4s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return c.PublicIPv4s[0].Address
|
||||
}
|
||||
|
||||
func (c *Container) IPv6AddressStrings() []string {
|
||||
values := make([]string, 0, len(c.IPv6Addresses))
|
||||
for _, item := range c.IPv6Addresses {
|
||||
if item.Address != "" {
|
||||
values = append(values, item.Address)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 && c.IPv6 != "" {
|
||||
values = append(values, c.IPv6)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// LxcName returns the internal LXC container name (ct-{id})
|
||||
func (c *Container) LxcName() string {
|
||||
if c.LXCName != "" {
|
||||
@@ -225,26 +363,32 @@ type SSLConfig struct {
|
||||
|
||||
// ClicdConfig is the main configuration structure
|
||||
type ClicdConfig struct {
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPassHash string `json:"admin_pass_hash"`
|
||||
JWTSecret string `json:"jwt_secret"`
|
||||
Port int `json:"port"`
|
||||
DataDir string `json:"data_dir"`
|
||||
Containers []Container `json:"containers"`
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
NATPortStart int `json:"nat_port_start"`
|
||||
NATPortEnd int `json:"nat_port_end"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
AuditLogs []AuditLog `json:"audit_logs"`
|
||||
Tasks []SavedTask `json:"tasks"`
|
||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
||||
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
Language string `json:"language"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||
}
|
||||
|
||||
var configPath string
|
||||
@@ -252,6 +396,11 @@ var AppConfig *ClicdConfig
|
||||
|
||||
const DefaultSnapshotLimit = 3
|
||||
|
||||
const (
|
||||
DefaultNATPortStart = 20000
|
||||
DefaultNATPortEnd = 65535
|
||||
)
|
||||
|
||||
func getConfigPath() string {
|
||||
if configPath != "" {
|
||||
return configPath
|
||||
@@ -358,21 +507,26 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
}
|
||||
|
||||
AppConfig = &ClicdConfig{
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Snapshots: []Snapshot{},
|
||||
AdminUser: adminUser,
|
||||
AdminPassHash: string(hash),
|
||||
JWTSecret: jwtSecret,
|
||||
Port: 8999,
|
||||
DataDir: dataDir,
|
||||
Containers: []Container{},
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
NATPortStart: DefaultNATPortStart,
|
||||
NATPortEnd: DefaultNATPortEnd,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
Tasks: []SavedTask{},
|
||||
LoginLogs: []SavedLoginLog{},
|
||||
Snapshots: []Snapshot{},
|
||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||
WebSSHAllowedOrigins: []string{},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
@@ -407,6 +561,9 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.NextSSHPort = 22000
|
||||
changed = true
|
||||
}
|
||||
if normalizeNATPortRangeDefaults() {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextContainerID == 0 {
|
||||
AppConfig.NextContainerID = 1
|
||||
changed = true
|
||||
@@ -423,6 +580,21 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.PublicIPv4Pool == nil {
|
||||
AppConfig.PublicIPv4Pool = make([]PublicIPv4Assignment, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.PublicIPv6Prefixes == nil {
|
||||
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.WebSSHAllowedOrigins == nil {
|
||||
AppConfig.WebSSHAllowedOrigins = make([]string, 0)
|
||||
changed = true
|
||||
} else if normalized, err := NormalizeAllowedOrigins(AppConfig.WebSSHAllowedOrigins); err == nil && strings.Join(normalized, "\n") != strings.Join(AppConfig.WebSSHAllowedOrigins, "\n") {
|
||||
AppConfig.WebSSHAllowedOrigins = normalized
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
changed = true
|
||||
@@ -454,12 +626,29 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.EnabledImages = make([]string, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Language == "" {
|
||||
AppConfig.Language = "zh"
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Language != "zh" && AppConfig.Language != "en" {
|
||||
AppConfig.Language = "zh"
|
||||
changed = true
|
||||
}
|
||||
if normalizeSSLDefaults() {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeLanguage(language string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||
case "en", "en-us", "en_us", "english":
|
||||
return "en"
|
||||
default:
|
||||
return "zh"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSSLDefaults() bool {
|
||||
changed := false
|
||||
previousMode := AppConfig.SSL.Mode
|
||||
@@ -528,6 +717,12 @@ func migrateLoadedConfig() bool {
|
||||
if ensureContainerSnapshotLimits() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerNetworkAssignments() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerResourceAliases() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerSnapshotScheduleDefaults() {
|
||||
changed = true
|
||||
}
|
||||
@@ -590,13 +785,16 @@ func ensureContainerUUIDs() bool {
|
||||
func ensureContainerPortMappingLimits() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
if AppConfig.Containers[i].PortMappingLimit <= 0 {
|
||||
if AppConfig.Containers[i].PortMappingLimit < 0 {
|
||||
limit := len(AppConfig.Containers[i].PortMappings)
|
||||
if limit < 2 {
|
||||
limit = 2
|
||||
}
|
||||
AppConfig.Containers[i].PortMappingLimit = limit
|
||||
changed = true
|
||||
} else if AppConfig.Containers[i].PortMappingLimit == 0 && len(AppConfig.Containers[i].PortMappings) > 0 {
|
||||
AppConfig.Containers[i].PortMappingLimit = len(AppConfig.Containers[i].PortMappings)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
@@ -613,6 +811,101 @@ func ensureContainerSnapshotLimits() bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerNetworkAssignments() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
if AppConfig.Containers[i].NormalizeNetworkAssignments() {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerResourceAliases() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
if NormalizeContainerResourceAliases(&AppConfig.Containers[i]) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeContainerResourceAliases(c *Container) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if c.NetworkBWMbps < 0 {
|
||||
c.NetworkBWMbps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.NetworkDownMbps < 0 {
|
||||
c.NetworkDownMbps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.NetworkUpMbps < 0 {
|
||||
c.NetworkUpMbps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.NetworkDownMbps == 0 && c.NetworkUpMbps == 0 && c.NetworkBWMbps > 0 {
|
||||
c.NetworkDownMbps = c.NetworkBWMbps
|
||||
c.NetworkUpMbps = c.NetworkBWMbps
|
||||
changed = true
|
||||
}
|
||||
nextNetworkBW := LegacySymmetricLimit(c.NetworkDownMbps, c.NetworkUpMbps)
|
||||
if c.NetworkBWMbps != nextNetworkBW {
|
||||
c.NetworkBWMbps = nextNetworkBW
|
||||
changed = true
|
||||
}
|
||||
|
||||
if c.IOSpeedMBps < 0 {
|
||||
c.IOSpeedMBps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.IOReadMBps < 0 {
|
||||
c.IOReadMBps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.IOWriteMBps < 0 {
|
||||
c.IOWriteMBps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.IOReadMBps == 0 && c.IOWriteMBps == 0 && c.IOSpeedMBps > 0 {
|
||||
c.IOReadMBps = c.IOSpeedMBps
|
||||
c.IOWriteMBps = c.IOSpeedMBps
|
||||
changed = true
|
||||
}
|
||||
nextIO := LegacySymmetricLimit(c.IOReadMBps, c.IOWriteMBps)
|
||||
if c.IOSpeedMBps != nextIO {
|
||||
c.IOSpeedMBps = nextIO
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func LegacySymmetricLimit(a, b int) int {
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
if a == b {
|
||||
return a
|
||||
}
|
||||
if a == 0 {
|
||||
return b
|
||||
}
|
||||
if b == 0 {
|
||||
return a
|
||||
}
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func migrateSubUsers() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.SubUsers {
|
||||
@@ -697,6 +990,7 @@ func AddContainer(c Container) {
|
||||
c.UUID = NewContainerUUID()
|
||||
}
|
||||
c.Virtualization = NormalizeVirtualization(c.Virtualization)
|
||||
NormalizeContainerResourceAliases(&c)
|
||||
AppConfig.Containers = append(AppConfig.Containers, c)
|
||||
SaveConfig()
|
||||
}
|
||||
@@ -886,16 +1180,102 @@ func UpdateVNC(containers []Container) {
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() int {
|
||||
used := collectAllHostPorts()
|
||||
port := AppConfig.NextSSHPort
|
||||
for used[port] {
|
||||
port++
|
||||
func NormalizeNATPortRange(start, end int) (int, int, error) {
|
||||
if start == 0 && end == 0 {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd, nil
|
||||
}
|
||||
AppConfig.NextSSHPort = port + 1
|
||||
SaveConfig()
|
||||
return port
|
||||
if start == 0 {
|
||||
start = DefaultNATPortStart
|
||||
}
|
||||
if end == 0 {
|
||||
end = DefaultNATPortEnd
|
||||
}
|
||||
if start < 1 || start > 65535 {
|
||||
return 0, 0, fmt.Errorf("NAT port start must be 1-65535")
|
||||
}
|
||||
if end < 1 || end > 65535 {
|
||||
return 0, 0, fmt.Errorf("NAT port end must be 1-65535")
|
||||
}
|
||||
if start > end {
|
||||
return 0, 0, fmt.Errorf("NAT port start cannot be greater than end")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func NATPortRange() (int, int) {
|
||||
if AppConfig == nil {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
|
||||
if err != nil {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func NATPortCapacity() int {
|
||||
start, end := NATPortRange()
|
||||
return end - start + 1
|
||||
}
|
||||
|
||||
func NATPortInRange(port int) bool {
|
||||
start, end := NATPortRange()
|
||||
return port >= start && port <= end
|
||||
}
|
||||
|
||||
func SetNATPortRange(start, end int) error {
|
||||
start, end, err := NormalizeNATPortRange(start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
AppConfig.NATPortStart = start
|
||||
AppConfig.NATPortEnd = end
|
||||
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
return SaveConfig()
|
||||
}
|
||||
|
||||
func normalizeNATPortRangeDefaults() bool {
|
||||
if AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
|
||||
if err != nil {
|
||||
start, end = DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
changed := AppConfig.NATPortStart != start || AppConfig.NATPortEnd != end
|
||||
AppConfig.NATPortStart = start
|
||||
AppConfig.NATPortEnd = end
|
||||
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() (int, error) {
|
||||
used := collectAllHostPorts()
|
||||
start, end := NATPortRange()
|
||||
port := AppConfig.NextSSHPort
|
||||
if port < start || port > end {
|
||||
port = start
|
||||
}
|
||||
capacity := end - start + 1
|
||||
for i := 0; i < capacity; i++ {
|
||||
candidate := start + ((port - start + i) % capacity)
|
||||
if used[candidate] {
|
||||
continue
|
||||
}
|
||||
AppConfig.NextSSHPort = candidate + 1
|
||||
if AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
SaveConfig()
|
||||
return candidate, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end)
|
||||
}
|
||||
|
||||
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllocateSSHPortUsesConfiguredNATRange(t *testing.T) {
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 30002,
|
||||
NextSSHPort: 22000,
|
||||
Containers: []Container{{
|
||||
PortMappings: []PortMapping{
|
||||
{HostPort: 30000},
|
||||
{HostPort: 30001},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
port, err := AllocateSSHPort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != 30002 {
|
||||
t.Fatalf("expected port 30002, got %d", port)
|
||||
}
|
||||
if AppConfig.NextSSHPort != 30000 {
|
||||
t.Fatalf("expected next port to wrap to 30000, got %d", AppConfig.NextSSHPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) {
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 31000,
|
||||
NATPortEnd: 31001,
|
||||
NextSSHPort: 31000,
|
||||
Containers: []Container{{
|
||||
PortMappings: []PortMapping{
|
||||
{HostPort: 31000},
|
||||
{HostPort: 31001},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
if port, err := AllocateSSHPort(); err == nil {
|
||||
t.Fatalf("expected exhausted NAT range error, got port %d", port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeAllowedOrigin accepts a browser Origin value such as
|
||||
// https://www.example.com and returns a canonical form for exact matching.
|
||||
func NormalizeAllowedOrigin(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", fmt.Errorf("Origin must include scheme and host: %s", value)
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", fmt.Errorf("Origin scheme must be http or https: %s", value)
|
||||
}
|
||||
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", fmt.Errorf("Origin must not include path, query, or fragment: %s", value)
|
||||
}
|
||||
host := normalizeOriginHostPort(u.Host, scheme)
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("Origin host is required: %s", value)
|
||||
}
|
||||
return scheme + "://" + host, nil
|
||||
}
|
||||
|
||||
func NormalizeAllowedOrigins(values []string) ([]string, error) {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
origin, err := NormalizeAllowedOrigin(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if origin == "" || seen[origin] {
|
||||
continue
|
||||
}
|
||||
seen[origin] = true
|
||||
result = append(result, origin)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func IsOriginAllowed(origin string, requestHost string) bool {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
if isSameRequestOrigin(origin, requestHost) {
|
||||
return true
|
||||
}
|
||||
normalized, err := NormalizeAllowedOrigin(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
for _, allowed := range AppConfig.WebSSHAllowedOrigins {
|
||||
allowed, err := NormalizeAllowedOrigin(allowed)
|
||||
if err == nil && normalized == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSameRequestOrigin(origin string, requestHost string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
originHost := normalizeHostOnly(u.Hostname())
|
||||
host := normalizeHostOnly(requestHost)
|
||||
if originHost == "" || host == "" {
|
||||
return false
|
||||
}
|
||||
if originHost == host {
|
||||
return true
|
||||
}
|
||||
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func normalizeOriginHostPort(raw string, scheme string) string {
|
||||
host := raw
|
||||
port := ""
|
||||
if h, p, err := net.SplitHostPort(raw); err == nil {
|
||||
host = h
|
||||
port = p
|
||||
}
|
||||
host = normalizeHostOnly(host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
|
||||
port = ""
|
||||
}
|
||||
if port != "" {
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
if strings.Contains(host, ":") && net.ParseIP(host) != nil {
|
||||
return "[" + host + "]"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func normalizeHostOnly(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(raw); err == nil {
|
||||
raw = h
|
||||
}
|
||||
raw = strings.Trim(raw, "[]")
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
return strings.ToLower(ip.String())
|
||||
}
|
||||
return strings.TrimSuffix(strings.ToLower(raw), ".")
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const letsEncryptLiveDir = "/etc/letsencrypt/live"
|
||||
|
||||
var dnsNamePattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||
|
||||
func SSLStorageDir() string {
|
||||
dataDir := ""
|
||||
if AppConfig != nil {
|
||||
dataDir = AppConfig.DataDir
|
||||
}
|
||||
if dataDir == "" {
|
||||
dataDir = getDataDir()
|
||||
}
|
||||
return filepath.Join(dataDir, "ssl")
|
||||
}
|
||||
|
||||
func UploadedSSLPaths() (string, string, error) {
|
||||
dir, err := safeSSLStorageDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(dir, "uploaded-fullchain.pem"), filepath.Join(dir, "uploaded-privkey.pem"), nil
|
||||
}
|
||||
|
||||
func SelfSignedSSLPaths() (string, string, error) {
|
||||
dir, err := safeSSLStorageDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(dir, "self-signed-fullchain.pem"), filepath.Join(dir, "self-signed-privkey.pem"), nil
|
||||
}
|
||||
|
||||
func LetsEncryptSSLPaths(target string) (string, string, error) {
|
||||
name, err := NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
base := filepath.Join(letsEncryptLiveDir, name)
|
||||
return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem"), nil
|
||||
}
|
||||
|
||||
func ResolveSSLConfigPaths(ssl SSLConfig) (string, string, error) {
|
||||
mode := NormalizeSSLMode(ssl.Mode)
|
||||
switch mode {
|
||||
case SSLModeUploaded:
|
||||
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return UploadedSSLPaths()
|
||||
case SSLModeSelfSigned:
|
||||
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return SelfSignedSSLPaths()
|
||||
case SSLModeLetsEncrypt:
|
||||
if strings.TrimSpace(ssl.Target) == "" && ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return LetsEncryptSSLPaths(ssl.Target)
|
||||
default:
|
||||
return "", "", fmt.Errorf("SSL is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveSSLPathPair(certPath, keyPath string) (string, string, error) {
|
||||
safeCertPath, err := ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
safeKeyPath, err := ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return safeCertPath, safeKeyPath, nil
|
||||
}
|
||||
|
||||
func ResolveSSLPath(path string) (string, error) {
|
||||
cleaned, err := cleanAbsolutePath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isPathUnder(cleaned, SSLStorageDir()) || isPathUnder(cleaned, letsEncryptLiveDir) || isPathUnder(cleaned, "/etc/letsencrypt/archive") {
|
||||
return cleaned, nil
|
||||
}
|
||||
return "", fmt.Errorf("SSL path is outside allowed certificate directories")
|
||||
}
|
||||
|
||||
func ReadableFileStat(path string) (os.FileInfo, error) {
|
||||
safePath, err := ResolveSSLPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Stat(safePath)
|
||||
}
|
||||
|
||||
func NormalizeSSLCertificateTarget(target string) (string, error) {
|
||||
target = strings.TrimSpace(strings.Trim(target, "[]"))
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("SSL target is required")
|
||||
}
|
||||
if strings.Contains(target, "/") || strings.Contains(target, "\\") || strings.Contains(target, "..") {
|
||||
return "", fmt.Errorf("SSL target contains invalid path characters")
|
||||
}
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
if len(target) > 253 || !dnsNamePattern.MatchString(target) {
|
||||
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||
}
|
||||
labels := strings.Split(target, ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||
}
|
||||
}
|
||||
return strings.ToLower(target), nil
|
||||
}
|
||||
|
||||
func safeSSLStorageDir() (string, error) {
|
||||
dir, err := cleanAbsolutePath(SSLStorageDir())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataDir := ""
|
||||
if AppConfig != nil {
|
||||
dataDir = AppConfig.DataDir
|
||||
}
|
||||
if dataDir == "" {
|
||||
dataDir = getDataDir()
|
||||
}
|
||||
if !isPathUnder(dir, dataDir) {
|
||||
return "", fmt.Errorf("SSL storage directory is outside the data directory")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func cleanAbsolutePath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
func isPathUnder(path, root string) bool {
|
||||
cleanPath, err := cleanAbsolutePath(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cleanRoot, err := cleanAbsolutePath(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(cleanRoot, cleanPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
@@ -20,24 +20,37 @@ var (
|
||||
)
|
||||
|
||||
type savedTaskConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
@@ -46,10 +59,12 @@ func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
}
|
||||
var cfg savedTaskConfig
|
||||
_ = json.Unmarshal([]byte(raw), &cfg)
|
||||
normalizeSavedTaskConfigLimits(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
normalizeSavedTaskConfigLimits(&cfg)
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -57,6 +72,41 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func normalizeSavedTaskConfigLimits(cfg *savedTaskConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.NetworkBWMbps < 0 {
|
||||
cfg.NetworkBWMbps = 0
|
||||
}
|
||||
if cfg.NetworkDownMbps < 0 {
|
||||
cfg.NetworkDownMbps = 0
|
||||
}
|
||||
if cfg.NetworkUpMbps < 0 {
|
||||
cfg.NetworkUpMbps = 0
|
||||
}
|
||||
if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 {
|
||||
cfg.NetworkDownMbps = cfg.NetworkBWMbps
|
||||
cfg.NetworkUpMbps = cfg.NetworkBWMbps
|
||||
}
|
||||
cfg.NetworkBWMbps = LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||
|
||||
if cfg.IOSpeedMBps < 0 {
|
||||
cfg.IOSpeedMBps = 0
|
||||
}
|
||||
if cfg.IOReadMBps < 0 {
|
||||
cfg.IOReadMBps = 0
|
||||
}
|
||||
if cfg.IOWriteMBps < 0 {
|
||||
cfg.IOWriteMBps = 0
|
||||
}
|
||||
if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 {
|
||||
cfg.IOReadMBps = cfg.IOSpeedMBps
|
||||
cfg.IOWriteMBps = cfg.IOSpeedMBps
|
||||
}
|
||||
cfg.IOSpeedMBps = LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps)
|
||||
}
|
||||
|
||||
func encodeStringSlice(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
@@ -139,6 +189,8 @@ func ensureSchema() error {
|
||||
ram_mb INTEGER,
|
||||
disk_gb INTEGER,
|
||||
network_bw_mbps INTEGER,
|
||||
network_down_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
network_up_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
monthly_traffic_gb INTEGER,
|
||||
traffic_mode TEXT,
|
||||
traffic_in_gb INTEGER,
|
||||
@@ -147,6 +199,8 @@ func ensureSchema() error {
|
||||
traffic_used_tx INTEGER,
|
||||
traffic_reset_date TEXT,
|
||||
io_speed_mbps INTEGER,
|
||||
io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT,
|
||||
ip TEXT,
|
||||
ipv6 TEXT,
|
||||
@@ -175,10 +229,28 @@ func ensureSchema() error {
|
||||
position INTEGER NOT NULL,
|
||||
container_port INTEGER NOT NULL,
|
||||
host_port INTEGER NOT NULL,
|
||||
host_ip TEXT,
|
||||
protocol TEXT,
|
||||
description TEXT,
|
||||
PRIMARY KEY (container_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS container_public_ipv4s (
|
||||
container_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
interface TEXT,
|
||||
prefix_len INTEGER,
|
||||
gateway TEXT,
|
||||
PRIMARY KEY (container_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS container_ipv6_addresses (
|
||||
container_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
prefix_len INTEGER,
|
||||
interface TEXT,
|
||||
PRIMARY KEY (container_id, position)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
@@ -227,6 +299,14 @@ func ensureSchema() error {
|
||||
success INTEGER,
|
||||
error TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS security_conntrack_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
container_ip TEXT NOT NULL,
|
||||
line TEXT NOT NULL,
|
||||
captured_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_conntrack_snapshots_ip_time
|
||||
ON security_conntrack_snapshots(container_ip, captured_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT,
|
||||
@@ -247,14 +327,27 @@ func ensureSchema() error {
|
||||
cfg_ram_mb INTEGER,
|
||||
cfg_disk_gb INTEGER,
|
||||
cfg_network_bw_mbps INTEGER,
|
||||
cfg_network_down_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_network_up_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_monthly_traffic_gb INTEGER,
|
||||
cfg_traffic_mode TEXT,
|
||||
cfg_traffic_in_gb INTEGER,
|
||||
cfg_traffic_out_gb INTEGER,
|
||||
cfg_io_speed_mbps INTEGER,
|
||||
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_port_mapping_count INTEGER,
|
||||
cfg_assign_nat INTEGER,
|
||||
cfg_snapshot_limit INTEGER,
|
||||
cfg_assign_ipv4 INTEGER,
|
||||
cfg_ipv4_count INTEGER,
|
||||
cfg_public_ipv4s TEXT,
|
||||
cfg_assign_ipv6 INTEGER,
|
||||
cfg_ipv6_count INTEGER,
|
||||
cfg_ipv6_addresses TEXT,
|
||||
cfg_ssh_auth_mode TEXT,
|
||||
cfg_ssh_password TEXT,
|
||||
cfg_ssh_public_key TEXT,
|
||||
cfg_expires_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||
@@ -296,6 +389,7 @@ func ensureSchema() error {
|
||||
}
|
||||
|
||||
func ensureSchemaMigrations() error {
|
||||
added := map[string]bool{}
|
||||
for _, column := range []struct {
|
||||
table string
|
||||
name string
|
||||
@@ -308,18 +402,77 @@ func ensureSchemaMigrations() error {
|
||||
{"api_keys", "last_used_ip", "TEXT"},
|
||||
{"tasks", "ip", "TEXT"},
|
||||
{"tasks", "user_agent", "TEXT"},
|
||||
{"tasks", "cfg_network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
||||
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
||||
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
||||
{"tasks", "cfg_assign_nat", "INTEGER"},
|
||||
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
||||
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
||||
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
||||
{"tasks", "cfg_ssh_password", "TEXT"},
|
||||
{"tasks", "cfg_ssh_public_key", "TEXT"},
|
||||
{"port_mappings", "host_ip", "TEXT"},
|
||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||
{"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
||||
{"containers", "firewall_rules", "TEXT"},
|
||||
} {
|
||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||
wasAdded, err := ensureColumn(column.table, column.name, column.def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wasAdded {
|
||||
added[column.table+"."+column.name] = true
|
||||
}
|
||||
}
|
||||
if added["containers.network_down_mbps"] || added["containers.network_up_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET network_down_mbps = COALESCE(NULLIF(network_down_mbps, 0), COALESCE(network_bw_mbps, 0)),
|
||||
network_up_mbps = COALESCE(NULLIF(network_up_mbps, 0), COALESCE(network_bw_mbps, 0))
|
||||
WHERE COALESCE(network_bw_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if added["containers.io_read_mbps"] || added["containers.io_write_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET io_read_mbps = COALESCE(NULLIF(io_read_mbps, 0), COALESCE(io_speed_mbps, 0)),
|
||||
io_write_mbps = COALESCE(NULLIF(io_write_mbps, 0), COALESCE(io_speed_mbps, 0))
|
||||
WHERE COALESCE(io_speed_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if added["tasks.cfg_network_down_mbps"] || added["tasks.cfg_network_up_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE tasks
|
||||
SET cfg_network_down_mbps = COALESCE(NULLIF(cfg_network_down_mbps, 0), COALESCE(cfg_network_bw_mbps, 0)),
|
||||
cfg_network_up_mbps = COALESCE(NULLIF(cfg_network_up_mbps, 0), COALESCE(cfg_network_bw_mbps, 0))
|
||||
WHERE COALESCE(cfg_network_bw_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if added["tasks.cfg_io_read_mbps"] || added["tasks.cfg_io_write_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE tasks
|
||||
SET cfg_io_read_mbps = COALESCE(NULLIF(cfg_io_read_mbps, 0), COALESCE(cfg_io_speed_mbps, 0)),
|
||||
cfg_io_write_mbps = COALESCE(NULLIF(cfg_io_write_mbps, 0), COALESCE(cfg_io_speed_mbps, 0))
|
||||
WHERE COALESCE(cfg_io_speed_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(table, name, def string) error {
|
||||
func ensureColumn(table, name, def string) (bool, error) {
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
@@ -328,17 +481,17 @@ func ensureColumn(table, name, def string) error {
|
||||
var notNull, pk int
|
||||
var defaultValue interface{}
|
||||
if err := rows.Scan(&cid, &columnName, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if columnName == name {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
|
||||
return err
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
@@ -371,8 +524,11 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
NextContainerID: atoi(meta["next_container_id"]),
|
||||
NextVNCPort: atoi(meta["next_vnc_port"]),
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
NATPortStart: atoi(meta["nat_port_start"]),
|
||||
NATPortEnd: atoi(meta["nat_port_end"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
Language: meta["language"],
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
|
||||
@@ -380,6 +536,15 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["public_ipv4_pool"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv4Pool)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
return nil, false, err
|
||||
@@ -423,6 +588,8 @@ func saveConfigToDB() error {
|
||||
|
||||
for _, table := range []string{
|
||||
"port_mappings",
|
||||
"container_public_ipv4s",
|
||||
"container_ipv6_addresses",
|
||||
"sub_user_container_names",
|
||||
"sub_user_container_uuids",
|
||||
"containers",
|
||||
@@ -474,6 +641,9 @@ func saveConfigToDB() error {
|
||||
func saveMeta(tx *sql.Tx) error {
|
||||
sslJSON, _ := json.Marshal(AppConfig.SSL)
|
||||
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
|
||||
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
@@ -483,10 +653,16 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
|
||||
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"nat_port_start": strconv.Itoa(AppConfig.NATPortStart),
|
||||
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"language": NormalizeLanguage(AppConfig.Language),
|
||||
"ssl": string(sslJSON),
|
||||
"ssl_certificates": string(sslCertificatesJSON),
|
||||
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -500,30 +676,49 @@ func saveMeta(tx *sql.Tx) error {
|
||||
|
||||
func saveContainers(tx *sql.Tx) error {
|
||||
for _, c := range AppConfig.Containers {
|
||||
NormalizeContainerResourceAliases(&c)
|
||||
if _, err := tx.Exec(`INSERT INTO containers (
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_default_action, firewall_rules
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
||||
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
|
||||
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
|
||||
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
||||
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, pm := range c.PortMappings {
|
||||
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
|
||||
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, host_ip, protocol, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.HostIP, pm.Protocol, pm.Description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, ip := range c.PublicIPv4s {
|
||||
if _, err := tx.Exec(`INSERT INTO container_public_ipv4s(container_id, position, address, interface, prefix_len, gateway)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.Interface, ip.PrefixLen, ip.Gateway); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, ip := range c.IPv6Addresses {
|
||||
if _, err := tx.Exec(`INSERT INTO container_ipv6_addresses(container_id, position, address, prefix_len, interface)
|
||||
VALUES (?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.PrefixLen, ip.Interface); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -563,6 +758,59 @@ func saveAPIKeys(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveConntrackSnapshot stores raw conntrack lines for a container IP.
|
||||
func SaveConntrackSnapshot(containerIP string, lines []string) {
|
||||
if db == nil || len(lines) == 0 || strings.TrimSpace(containerIP) == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
stmt, err := tx.Prepare(`INSERT INTO security_conntrack_snapshots (container_ip, line, captured_at) VALUES (?, ?, ?)`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
stmt.Exec(containerIP, line, now)
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
// Cleanup old snapshots (>1 hour)
|
||||
db.Exec(`DELETE FROM security_conntrack_snapshots WHERE captured_at < ?`,
|
||||
time.Now().Add(-1*time.Hour).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// GetConntrackSnapshotLines returns stored conntrack lines for a container IP.
|
||||
func GetConntrackSnapshotLines(containerIP string) []string {
|
||||
if db == nil || strings.TrimSpace(containerIP) == "" {
|
||||
return nil
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT line FROM security_conntrack_snapshots WHERE container_ip = ? ORDER BY captured_at DESC LIMIT 200`,
|
||||
containerIP,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var lines []string
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if rows.Scan(&line) == nil {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func saveAuditLogs(tx *sql.Tx) error {
|
||||
for _, log := range AppConfig.AuditLogs {
|
||||
successSet := 0
|
||||
@@ -587,15 +835,22 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
|
||||
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
|
||||
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
||||
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
|
||||
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
||||
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
||||
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -640,13 +895,16 @@ func saveSnapshots(tx *sql.Tx) error {
|
||||
func loadContainers() ([]Container, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_default_action, firewall_rules
|
||||
FROM containers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -656,21 +914,32 @@ func loadContainers() ([]Container, error) {
|
||||
result := []Container{}
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
var scheduleEnabled, policyBlocked int
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled int
|
||||
var firewallDefaultAction string
|
||||
var firewallRulesJSON sql.NullString
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
|
||||
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
||||
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
|
||||
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||
c.PolicyBlocked = policyBlocked != 0
|
||||
c.FirewallEnabled = firewallEnabled != 0
|
||||
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
||||
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
||||
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
||||
}
|
||||
NormalizeContainerResourceAliases(&c)
|
||||
result = append(result, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -684,12 +953,21 @@ func loadContainers() ([]Container, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].PublicIPv4s, err = loadContainerPublicIPv4s(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].IPv6Addresses, err = loadContainerIPv6Addresses(result[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i].NormalizeNetworkAssignments()
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadPortMappings(containerID int) ([]PortMapping, error) {
|
||||
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
||||
rows, err := db.Query(`SELECT container_port, host_port, host_ip, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -697,14 +975,64 @@ func loadPortMappings(containerID int) ([]PortMapping, error) {
|
||||
result := []PortMapping{}
|
||||
for rows.Next() {
|
||||
var pm PortMapping
|
||||
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
|
||||
var hostIP sql.NullString
|
||||
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &hostIP, &pm.Protocol, &pm.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pm.HostIP = hostIP.String
|
||||
result = append(result, pm)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadContainerPublicIPv4s(containerID int) ([]PublicIPv4Assignment, error) {
|
||||
rows, err := db.Query(`SELECT address, interface, prefix_len, gateway FROM container_public_ipv4s WHERE container_id = ? ORDER BY position`, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []PublicIPv4Assignment{}
|
||||
for rows.Next() {
|
||||
var item PublicIPv4Assignment
|
||||
var iface sql.NullString
|
||||
var prefixLen sql.NullInt64
|
||||
var gateway sql.NullString
|
||||
if err := rows.Scan(&item.Address, &iface, &prefixLen, &gateway); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Interface = iface.String
|
||||
if prefixLen.Valid {
|
||||
item.PrefixLen = int(prefixLen.Int64)
|
||||
}
|
||||
item.Gateway = gateway.String
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
|
||||
rows, err := db.Query(`SELECT address, prefix_len, interface FROM container_ipv6_addresses WHERE container_id = ? ORDER BY position`, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []IPv6Assignment{}
|
||||
for rows.Next() {
|
||||
var item IPv6Assignment
|
||||
var prefixLen sql.NullInt64
|
||||
var iface sql.NullString
|
||||
if err := rows.Scan(&item.Address, &prefixLen, &iface); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prefixLen.Valid {
|
||||
item.PrefixLen = int(prefixLen.Int64)
|
||||
}
|
||||
item.Interface = iface.String
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func loadSubUsers() ([]SubUser, error) {
|
||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
@@ -805,9 +1133,12 @@ func loadTasks() ([]SavedTask, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
|
||||
cfg_assign_ipv6, cfg_expires_at
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||
FROM tasks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -818,20 +1149,42 @@ func loadTasks() ([]SavedTask, error) {
|
||||
for rows.Next() {
|
||||
var t SavedTask
|
||||
var cfg savedTaskConfig
|
||||
var assignIPv6 int
|
||||
var ip, userAgent sql.NullString
|
||||
var assignIPv4, assignIPv6 int
|
||||
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
||||
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
|
||||
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
|
||||
&assignIPv6, &cfg.ExpiresAt,
|
||||
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
|
||||
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
||||
&cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
||||
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.IP = ip.String
|
||||
t.UserAgent = userAgent.String
|
||||
if assignNAT.Valid {
|
||||
value := assignNAT.Int64 != 0
|
||||
cfg.AssignNAT = &value
|
||||
}
|
||||
cfg.AssignIPv4 = assignIPv4 != 0
|
||||
if ipv4Count.Valid {
|
||||
cfg.IPv4Count = int(ipv4Count.Int64)
|
||||
}
|
||||
cfg.PublicIPv4s = decodeStringSlice(publicIPv4s.String)
|
||||
cfg.AssignIPv6 = assignIPv6 != 0
|
||||
if ipv6Count.Valid {
|
||||
cfg.IPv6Count = int(ipv6Count.Int64)
|
||||
}
|
||||
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
|
||||
cfg.SSHAuthMode = sshAuthMode.String
|
||||
cfg.SSHPassword = sshPassword.String
|
||||
cfg.SSHPublicKey = sshPublicKey.String
|
||||
normalizeSavedTaskConfigLimits(&cfg)
|
||||
result = append(result, t)
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
@@ -945,6 +1298,32 @@ func boolInt(value bool) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func marshalFirewallRules(rules []FirewallRule) interface{} {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" {
|
||||
return "ACCEPT"
|
||||
}
|
||||
return "DROP"
|
||||
}
|
||||
|
||||
func boolPtrInt(value *bool) interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return boolInt(*value)
|
||||
}
|
||||
|
||||
func btoa(value bool) string {
|
||||
if value {
|
||||
return "1"
|
||||
|
||||
+678
-152
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package kvm
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
@@ -16,6 +17,15 @@ type Image struct {
|
||||
}
|
||||
|
||||
func GetImages() []Image {
|
||||
switch runtime.GOARCH {
|
||||
case "arm64":
|
||||
return arm64Images()
|
||||
default:
|
||||
return amd64Images()
|
||||
}
|
||||
}
|
||||
|
||||
func amd64Images() []Image {
|
||||
return []Image{
|
||||
{
|
||||
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
||||
@@ -94,6 +104,53 @@ func GetImages() []Image {
|
||||
}
|
||||
}
|
||||
|
||||
func arm64Images() []Image {
|
||||
return []Image{
|
||||
{
|
||||
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
||||
Distro: "ubuntu", Release: "noble", Arch: "arm64",
|
||||
Description: "Ubuntu 24.04 LTS cloud image for ARM64 KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
|
||||
Distro: "ubuntu", Release: "jammy", Arch: "arm64",
|
||||
Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||
Distro: "debian", Release: "bookworm", Arch: "arm64",
|
||||
Description: "Debian 12 generic cloud image for ARM64 KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
|
||||
Distro: "debian", Release: "bullseye", Arch: "arm64",
|
||||
Description: "Debian 11 generic cloud image for ARM64 KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
|
||||
Distro: "centos", Release: "9-stream", Arch: "arm64",
|
||||
Description: "CentOS Stream 9 GenericCloud image for ARM64 KVM",
|
||||
URL: "https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
|
||||
Distro: "fedora", Release: "44", Arch: "arm64",
|
||||
Description: "Fedora 44 GenericCloud image for ARM64 KVM",
|
||||
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
|
||||
Distro: "rockylinux", Release: "9", Arch: "arm64",
|
||||
Description: "Rocky Linux 9 GenericCloud image for ARM64 KVM",
|
||||
URL: "https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base.latest.aarch64.qcow2",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func FindImage(id string) *Image {
|
||||
for _, image := range GetImages() {
|
||||
if image.ID == id {
|
||||
|
||||
+1167
-53
File diff suppressed because it is too large
Load Diff
+480
-126
@@ -218,34 +218,90 @@ func NewManager() *Manager {
|
||||
|
||||
// ContainerConfig defines container creation parameters
|
||||
type ContainerConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
|
||||
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (cfg *ContainerConfig) NormalizeResourceAliases() {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.NetworkBWMbps < 0 {
|
||||
cfg.NetworkBWMbps = 0
|
||||
}
|
||||
if cfg.NetworkDownMbps < 0 {
|
||||
cfg.NetworkDownMbps = 0
|
||||
}
|
||||
if cfg.NetworkUpMbps < 0 {
|
||||
cfg.NetworkUpMbps = 0
|
||||
}
|
||||
if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 {
|
||||
cfg.NetworkDownMbps = cfg.NetworkBWMbps
|
||||
cfg.NetworkUpMbps = cfg.NetworkBWMbps
|
||||
}
|
||||
cfg.NetworkBWMbps = config.LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||
|
||||
if cfg.IOSpeedMBps < 0 {
|
||||
cfg.IOSpeedMBps = 0
|
||||
}
|
||||
if cfg.IOReadMBps < 0 {
|
||||
cfg.IOReadMBps = 0
|
||||
}
|
||||
if cfg.IOWriteMBps < 0 {
|
||||
cfg.IOWriteMBps = 0
|
||||
}
|
||||
if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 {
|
||||
cfg.IOReadMBps = cfg.IOSpeedMBps
|
||||
cfg.IOWriteMBps = cfg.IOSpeedMBps
|
||||
}
|
||||
cfg.IOSpeedMBps = config.LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps)
|
||||
}
|
||||
|
||||
func (cfg ContainerConfig) WantsNAT() bool {
|
||||
return cfg.AssignNAT == nil || *cfg.AssignNAT
|
||||
}
|
||||
|
||||
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
||||
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
cfg.NormalizeResourceAliases()
|
||||
tmpl := FindTemplate(cfg.TemplateID)
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template not found: %s", cfg.TemplateID)
|
||||
}
|
||||
if cfg.PortMappingCount < 2 {
|
||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
||||
cfg.PortMappingCount = 2
|
||||
} else if !cfg.WantsNAT() {
|
||||
cfg.PortMappingCount = 0
|
||||
cfg.ExtraPorts = nil
|
||||
}
|
||||
if cfg.SnapshotLimit <= 0 {
|
||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||
@@ -257,6 +313,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
if config.FindContainerByName(cfg.Name) != nil {
|
||||
return fmt.Errorf("container name already exists: %s", cfg.Name)
|
||||
}
|
||||
sshAccess, err := ResolveCreateSSHAccess(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Allocate ID and build LXC name
|
||||
id := config.AllocateContainerID()
|
||||
@@ -296,50 +356,63 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ipv6 := ""
|
||||
ipv6PrefixLen := 0
|
||||
ipv6Interface := ""
|
||||
if cfg.AssignIPv6 {
|
||||
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
|
||||
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
|
||||
ipv6Assignments := []config.IPv6Assignment{}
|
||||
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
|
||||
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
ipv6 = assigned
|
||||
ipv6PrefixLen = prefixLen
|
||||
ipv6Interface = iface
|
||||
if err := m.applyIPv6Config(lxcName, ipv6); err != nil {
|
||||
ipv6Assignments = assigned
|
||||
if err := m.applyIPv6Config(lxcName, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sshPort := config.AllocateSSHPort()
|
||||
sshPassword := generateRandomString(16)
|
||||
sshPassword := sshAccess.Password
|
||||
|
||||
// Setup default port mappings (SSH only)
|
||||
portMappings := SetupDefaultPortMappings(sshPort)
|
||||
tempC := &config.Container{PortMappings: portMappings}
|
||||
|
||||
extraPorts := cfg.ExtraPorts
|
||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||
}
|
||||
for _, containerPort := range extraPorts {
|
||||
if containerPort <= 0 {
|
||||
continue
|
||||
}
|
||||
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
||||
ContainerPort: containerPort,
|
||||
HostPort: containerPort,
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", containerPort),
|
||||
})
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
if cfg.WantsNAT() {
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
continue
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup default port mappings (SSH only)
|
||||
portMappings = SetupDefaultPortMappings(sshPort)
|
||||
// NAT4 port mappings should bind to the host IP, not the container's independent public IPv4.
|
||||
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
|
||||
|
||||
extraPorts := cfg.ExtraPorts
|
||||
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
|
||||
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
|
||||
}
|
||||
for _, containerPort := range extraPorts {
|
||||
if containerPort <= 0 {
|
||||
continue
|
||||
}
|
||||
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
|
||||
ContainerPort: containerPort,
|
||||
HostPort: containerPort,
|
||||
HostIP: "",
|
||||
Protocol: "tcp",
|
||||
Description: fmt.Sprintf("Port-%d", containerPort),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tempC.PortMappings = append(tempC.PortMappings, pm)
|
||||
portMappings = tempC.PortMappings
|
||||
}
|
||||
tempC.PortMappings = append(tempC.PortMappings, pm)
|
||||
portMappings = tempC.PortMappings
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
@@ -360,17 +433,20 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
RAMMB: cfg.RAMMB,
|
||||
DiskGB: cfg.DiskGB,
|
||||
NetworkBWMbps: cfg.NetworkBWMbps,
|
||||
NetworkDownMbps: cfg.NetworkDownMbps,
|
||||
NetworkUpMbps: cfg.NetworkUpMbps,
|
||||
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
|
||||
TrafficMode: trafficMode,
|
||||
TrafficInGB: cfg.TrafficInGB,
|
||||
TrafficOutGB: cfg.TrafficOutGB,
|
||||
TrafficResetDate: trafficResetDate,
|
||||
IOSpeedMBps: cfg.IOSpeedMBps,
|
||||
IOReadMBps: cfg.IOReadMBps,
|
||||
IOWriteMBps: cfg.IOWriteMBps,
|
||||
Status: "stopped",
|
||||
IP: "",
|
||||
IPv6: ipv6,
|
||||
IPv6PrefixLen: ipv6PrefixLen,
|
||||
IPv6Interface: ipv6Interface,
|
||||
PublicIPv4s: publicIPv4s,
|
||||
IPv6Addresses: ipv6Assignments,
|
||||
VNCPort: 0,
|
||||
SSHPort: sshPort,
|
||||
SSHPassword: sshPassword,
|
||||
@@ -380,19 +456,27 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
CreatedAt: now,
|
||||
ExpiresAt: cfg.ExpiresAt,
|
||||
}
|
||||
container.NormalizeNetworkAssignments()
|
||||
config.AddContainer(container)
|
||||
|
||||
// Pre-configure network and SSH in the rootfs before first boot.
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
|
||||
if ipv6 != "" {
|
||||
if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil {
|
||||
if len(ipv6Assignments) > 0 {
|
||||
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
||||
}
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
|
||||
}
|
||||
if sshAccess.PublicKey != "" {
|
||||
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
config.RemoveContainer(id)
|
||||
return fmt.Errorf("failed to install SSH public key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
@@ -471,11 +555,13 @@ IPv6AcceptRA=no
|
||||
}
|
||||
|
||||
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
||||
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
|
||||
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
|
||||
_ = templateID
|
||||
// Disable pubkey auth when user chose password-only mode (password or auto_password)
|
||||
disablePubkey := sshAuthMode == SSHAuthPassword || sshAuthMode == SSHAuthAutoPassword
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
|
||||
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false, disablePubkey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -493,6 +579,7 @@ func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
|
||||
|
||||
// applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config.
|
||||
func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error {
|
||||
cfg.NormalizeResourceAliases()
|
||||
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
@@ -525,7 +612,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apparmorProfile, err := findAppArmorProfile()
|
||||
apparmorProfile, err := appArmorProfileForTemplate(cfg.TemplateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -561,12 +648,12 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
|
||||
ramBytes := int64(cfg.RAMMB) * 1024 * 1024
|
||||
newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.memory.max = %d", ramBytes))
|
||||
}
|
||||
if cfg.IOSpeedMBps > 0 {
|
||||
if cfg.IOReadMBps > 0 || cfg.IOWriteMBps > 0 {
|
||||
// Note: lxc.cgroup2.io.max is skipped for unprivileged containers because
|
||||
// LXC's cgfsng_setup_limits cannot resolve host device numbers (e.g. 8:1)
|
||||
// in the unprivileged namespace context.
|
||||
// IO limits are instead applied post-start via direct cgroup2 writes.
|
||||
fmt.Printf("Info: IO limit (%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOSpeedMBps, lxcName)
|
||||
fmt.Printf("Info: IO limit (read=%d MB/s write=%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOReadMBps, cfg.IOWriteMBps, lxcName)
|
||||
}
|
||||
|
||||
newContent := strings.Join(newLines, "\n")
|
||||
@@ -576,18 +663,28 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ioLimitLines(lxcName string, mbps int) ([]string, error) {
|
||||
if mbps <= 0 {
|
||||
return nil, nil
|
||||
func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) {
|
||||
if readMBps < 0 {
|
||||
readMBps = 0
|
||||
}
|
||||
if writeMBps < 0 {
|
||||
writeMBps = 0
|
||||
}
|
||||
devices, err := m.rootfsBlockDevices(lxcName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ioBytes := mbps * 1024 * 1024
|
||||
readValue := "max"
|
||||
if readMBps > 0 {
|
||||
readValue = strconv.Itoa(readMBps * 1024 * 1024)
|
||||
}
|
||||
writeValue := "max"
|
||||
if writeMBps > 0 {
|
||||
writeValue = strconv.Itoa(writeMBps * 1024 * 1024)
|
||||
}
|
||||
lines := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
lines = append(lines, fmt.Sprintf("%s rbps=%d wbps=%d", device, ioBytes, ioBytes))
|
||||
lines = append(lines, fmt.Sprintf("%s rbps=%s wbps=%s", device, readValue, writeValue))
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
@@ -920,12 +1017,99 @@ func findSeccompProfile() (string, error) {
|
||||
"/etc/lxc/common.seccomp",
|
||||
} {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
return ensureCVE202643499SeccompProfile(path)
|
||||
}
|
||||
}
|
||||
return "", errors.New("required LXC seccomp profile not found")
|
||||
}
|
||||
|
||||
const clicdSeccompProfileDir = "/var/lib/clicd/security/seccomp"
|
||||
const clicdCVE202643499SeccompProfile = clicdSeccompProfileDir + "/lxc-cve-2026-43499.profile"
|
||||
|
||||
var cve202643499FutexSeccompRules = []string{
|
||||
"# clicd managed: mitigate CVE-2026-43499 from LXC guests by blocking PI futex operations",
|
||||
"futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0x7,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0x8,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
}
|
||||
|
||||
func ensureCVE202643499SeccompProfile(basePath string) (string, error) {
|
||||
data, err := os.ReadFile(basePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read LXC seccomp profile: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !isLXCVDenylistSeccompProfile(content) {
|
||||
return "", fmt.Errorf("LXC seccomp profile %s is not a v2 denylist profile; cannot apply CVE-2026-43499 futex mitigation safely", basePath)
|
||||
}
|
||||
if err := os.MkdirAll(clicdSeccompProfileDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create CLICD seccomp directory: %v", err)
|
||||
}
|
||||
hardened := appendMissingSeccompRules(content, cve202643499FutexSeccompRules)
|
||||
if err := os.WriteFile(clicdCVE202643499SeccompProfile, []byte(hardened), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write CLICD seccomp profile: %v", err)
|
||||
}
|
||||
return clicdCVE202643499SeccompProfile, nil
|
||||
}
|
||||
|
||||
func isLXCVDenylistSeccompProfile(content string) bool {
|
||||
lines := nonCommentSeccompLines(content)
|
||||
return len(lines) >= 2 && lines[0] == "2" && isSeccompDenylistPolicy(lines[1])
|
||||
}
|
||||
|
||||
func isSeccompDenylistPolicy(line string) bool {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
return fields[0] == "denylist" || fields[0] == "blacklist"
|
||||
}
|
||||
|
||||
func appendMissingSeccompRules(content string, rules []string) string {
|
||||
trimmed := strings.TrimRight(content, "\r\n")
|
||||
existing := map[string]bool{}
|
||||
for _, line := range strings.Split(trimmed, "\n") {
|
||||
line = strings.TrimSpace(stripSeccompLineComment(line))
|
||||
if line != "" {
|
||||
existing[line] = true
|
||||
}
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(trimmed)
|
||||
for _, rule := range rules {
|
||||
key := strings.TrimSpace(stripSeccompLineComment(rule))
|
||||
if key != "" && existing[key] {
|
||||
continue
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(rule)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func nonCommentSeccompLines(content string) []string {
|
||||
lines := make([]string, 0)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(stripSeccompLineComment(line))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func stripSeccompLineComment(line string) string {
|
||||
if idx := strings.Index(line, "#"); idx >= 0 {
|
||||
return line[:idx]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func findAppArmorProfile() (string, error) {
|
||||
data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles")
|
||||
if err != nil {
|
||||
@@ -940,6 +1124,26 @@ func findAppArmorProfile() (string, error) {
|
||||
return "", errors.New("required LXC AppArmor profile not loaded")
|
||||
}
|
||||
|
||||
func appArmorProfileForTemplate(templateID string) (string, error) {
|
||||
if systemdTemplateNeedsUnconfinedAppArmor(templateID) {
|
||||
return "unconfined", nil
|
||||
}
|
||||
return findAppArmorProfile()
|
||||
}
|
||||
|
||||
func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool {
|
||||
id := strings.ToLower(strings.TrimSpace(templateID))
|
||||
if id == "" || strings.Contains(id, "alpine") {
|
||||
return false
|
||||
}
|
||||
for _, token := range []string{"ubuntu", "debian", "centos", "fedora", "rocky", "rockylinux", "archlinux"} {
|
||||
if strings.Contains(id, token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unprivilegedIDMap() (int, int, error) {
|
||||
if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil {
|
||||
return 0, 0, err
|
||||
@@ -1195,29 +1399,37 @@ func (m *Manager) StartContainer(id int) error {
|
||||
RAMMB: c.RAMMB,
|
||||
DiskGB: c.DiskGB,
|
||||
NetworkBWMbps: c.NetworkBWMbps,
|
||||
NetworkDownMbps: c.NetworkDownMbps,
|
||||
NetworkUpMbps: c.NetworkUpMbps,
|
||||
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
||||
IOSpeedMBps: c.IOSpeedMBps,
|
||||
AssignIPv6: c.IPv6 != "",
|
||||
IOReadMBps: c.IOReadMBps,
|
||||
IOWriteMBps: c.IOWriteMBps,
|
||||
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
|
||||
ExpiresAt: c.ExpiresAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IPv6 != "" {
|
||||
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
c.NormalizeNetworkAssignments()
|
||||
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.ApplyIPv6(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
|
||||
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
||||
os.Remove(logFile)
|
||||
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
|
||||
output, err := cmd.CombinedOutput()
|
||||
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||
}
|
||||
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
return err
|
||||
}
|
||||
|
||||
config.UpdateContainerStatus(id, "running")
|
||||
@@ -1262,7 +1474,10 @@ func (m *Manager) StartContainer(id int) error {
|
||||
if err := m.ApplyPortMappings(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
|
||||
}
|
||||
if c.IPv6 != "" {
|
||||
if err := ApplyFirewallRules(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply firewall rules: %v\n", err)
|
||||
}
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
if err := m.ApplyIPv6(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
||||
}
|
||||
@@ -1272,12 +1487,48 @@ func (m *Manager) StartContainer(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) startLXCContainerDaemon(lxcName string) (string, string, []byte, error) {
|
||||
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
||||
consoleLog := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-console.log")
|
||||
os.Remove(logFile)
|
||||
os.Remove(consoleLog)
|
||||
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG", "--console-log", consoleLog)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return logFile, consoleLog, output, err
|
||||
}
|
||||
|
||||
func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error {
|
||||
runningChecks := 0
|
||||
lastStatus := "unknown"
|
||||
for retry := 0; retry < 10; retry++ {
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err := m.GetContainerStatus(lxcName)
|
||||
if err != nil {
|
||||
lastStatus = "unknown"
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
if status == "running" {
|
||||
runningChecks++
|
||||
if runningChecks >= 3 {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if runningChecks > 0 || retry >= 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("container exited immediately after start (status: %s), lxc log: %s, console: %s", lastStatus, tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||
}
|
||||
|
||||
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
|
||||
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
|
||||
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
||||
if c == nil || c.Status != "running" {
|
||||
return nil
|
||||
}
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
lxcName := c.LxcName()
|
||||
|
||||
// CPU: write cpu.max
|
||||
@@ -1300,48 +1551,52 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
||||
os.WriteFile(path, []byte(memLine), 0644)
|
||||
}
|
||||
|
||||
// IO speed: write io.max
|
||||
if c.IOSpeedMBps > 0 {
|
||||
ioLines, err := m.ioLimitLines(lxcName, c.IOSpeedMBps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ioLine := strings.Join(ioLines, "\n")
|
||||
for _, path := range []string{
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName),
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName),
|
||||
} {
|
||||
os.WriteFile(path, []byte(ioLine), 0644)
|
||||
}
|
||||
// IO speed: write io.max, including max values to clear old per-direction limits.
|
||||
ioLines, err := m.ioLimitLines(lxcName, c.IOReadMBps, c.IOWriteMBps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ioLine := strings.Join(ioLines, "\n")
|
||||
for _, path := range []string{
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName),
|
||||
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName),
|
||||
} {
|
||||
os.WriteFile(path, []byte(ioLine), 0644)
|
||||
}
|
||||
|
||||
// Network bandwidth
|
||||
if c.NetworkBWMbps > 0 {
|
||||
m.applyBandwidthLimit(lxcName, c.NetworkBWMbps)
|
||||
} else {
|
||||
m.cleanupBandwidthLimit(lxcName)
|
||||
}
|
||||
m.applyBandwidthLimit(lxcName, c.NetworkDownMbps, c.NetworkUpMbps)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyBandwidthLimit(lxcName string, mbps int) {
|
||||
func (m *Manager) applyBandwidthLimit(lxcName string, downMbps int, upMbps int) {
|
||||
veth := m.getContainerVethByNS(lxcName)
|
||||
if veth == "" {
|
||||
fmt.Printf("Warning: could not find veth for %s\n", lxcName)
|
||||
return
|
||||
}
|
||||
rate := fmt.Sprintf("%dmbit", mbps)
|
||||
burst := fmt.Sprintf("%dkbit", mbps*100)
|
||||
exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run()
|
||||
exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run()
|
||||
exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run()
|
||||
fmt.Printf("Bandwidth limit: %s = %d Mbps on %s\n", lxcName, mbps, veth)
|
||||
exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run()
|
||||
if downMbps > 0 {
|
||||
rate := fmt.Sprintf("%dmbit", downMbps)
|
||||
burst := fmt.Sprintf("%dkbit", downMbps*100)
|
||||
exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run()
|
||||
exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run()
|
||||
}
|
||||
if upMbps > 0 {
|
||||
rate := fmt.Sprintf("%dmbit", upMbps)
|
||||
burst := fmt.Sprintf("%dkbit", upMbps*100)
|
||||
exec.Command("tc", "qdisc", "add", "dev", veth, "handle", "ffff:", "ingress").Run()
|
||||
exec.Command("tc", "filter", "add", "dev", veth, "parent", "ffff:", "protocol", "all", "u32", "match", "u32", "0", "0", "police", "rate", rate, "burst", burst, "drop", "flowid", ":1").Run()
|
||||
}
|
||||
fmt.Printf("Bandwidth limit: %s down=%d Mbps up=%d Mbps on %s\n", lxcName, downMbps, upMbps, veth)
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupBandwidthLimit(lxcName string) {
|
||||
veth := m.getContainerVethByNS(lxcName)
|
||||
if veth != "" {
|
||||
exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run()
|
||||
exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1375,11 +1630,13 @@ func (m *Manager) StopContainer(id int) error {
|
||||
if status != "running" {
|
||||
config.UpdateContainerStatus(id, "stopped")
|
||||
m.CleanPortMappings(id)
|
||||
CleanFirewallRules(id)
|
||||
m.cleanupBandwidthLimit(lxcName)
|
||||
return nil
|
||||
}
|
||||
|
||||
m.CleanPortMappings(id)
|
||||
CleanFirewallRules(id)
|
||||
m.cleanupBandwidthLimit(lxcName)
|
||||
|
||||
cmd := exec.Command("lxc-stop", "-n", lxcName)
|
||||
@@ -1553,8 +1810,17 @@ func (m *Manager) DestroyContainer(id int) error {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
lxcName := c.LxcName()
|
||||
if c.IPv6 != "" && c.IPv6Interface != "" {
|
||||
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
c.NormalizeNetworkAssignments()
|
||||
for _, assignment := range c.IPv6Addresses {
|
||||
uplink := assignment.Interface
|
||||
if uplink == "" {
|
||||
uplink = c.IPv6Interface
|
||||
}
|
||||
if uplink != "" {
|
||||
removeHostIPv6Routing(assignment.Address, uplink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.StopContainer(id); err != nil {
|
||||
@@ -1649,7 +1915,7 @@ func (m *Manager) EnsureSSH(id int) error {
|
||||
config.SaveConfig()
|
||||
}
|
||||
|
||||
script := sshSetupScript(true)
|
||||
script := sshSetupScript(true, false) // keep pubkey enabled for runtime ensure
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
@@ -1717,7 +1983,11 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
|
||||
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
|
||||
}
|
||||
|
||||
func sshSetupScript(startService bool) string {
|
||||
func sshSetupScript(startService bool, disablePubkeyAuth bool) string {
|
||||
pubkeyValue := "yes"
|
||||
if disablePubkeyAuth {
|
||||
pubkeyValue = "no"
|
||||
}
|
||||
script := `set -u
|
||||
|
||||
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
|
||||
@@ -1799,6 +2069,11 @@ install_sshd() {
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_sshd_runtime_dir() {
|
||||
mkdir -p /run/sshd /var/run/sshd
|
||||
chmod 0755 /run/sshd /var/run/sshd 2>/dev/null || true
|
||||
}
|
||||
|
||||
set_sshd_option() {
|
||||
key="$1"
|
||||
value="$2"
|
||||
@@ -1825,11 +2100,13 @@ set_sshd_option() {
|
||||
|
||||
install_sshd || exit 30
|
||||
|
||||
mkdir -p /run/sshd /var/run/sshd /etc/ssh /etc/ssh/sshd_config.d
|
||||
mkdir -p /etc/ssh /etc/ssh/sshd_config.d
|
||||
ensure_sshd_runtime_dir
|
||||
ssh-keygen -A >/dev/null 2>&1 || true
|
||||
|
||||
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
|
||||
PermitRootLogin yes
|
||||
PubkeyAuthentication __CLICD_PUBKEY_AUTH__
|
||||
PasswordAuthentication yes
|
||||
KbdInteractiveAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
@@ -1837,6 +2114,7 @@ UsePAM no
|
||||
EOF
|
||||
|
||||
set_sshd_option PermitRootLogin yes
|
||||
set_sshd_option PubkeyAuthentication __CLICD_PUBKEY_AUTH__
|
||||
set_sshd_option PasswordAuthentication yes
|
||||
set_sshd_option KbdInteractiveAuthentication no
|
||||
set_sshd_option ChallengeResponseAuthentication no
|
||||
@@ -1858,11 +2136,13 @@ if command -v chkconfig >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
SSHD_BIN="$(sshd_path)" || exit 32
|
||||
ensure_sshd_runtime_dir
|
||||
"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || {
|
||||
cat /tmp/clicd-sshd-test.log
|
||||
exit 32
|
||||
}
|
||||
`
|
||||
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
|
||||
if !startService {
|
||||
return script
|
||||
}
|
||||
@@ -1872,6 +2152,7 @@ if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop ssh.socket 2>/dev/null || true
|
||||
systemctl disable ssh.socket 2>/dev/null || true
|
||||
fi
|
||||
ensure_sshd_runtime_dir
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
|
||||
systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -1882,9 +2163,22 @@ service ssh restart >/dev/null 2>&1 ||
|
||||
/etc/init.d/sshd restart >/dev/null 2>&1 ||
|
||||
true
|
||||
|
||||
ensure_sshd_runtime_dir
|
||||
|
||||
for i in 1 2 3 4 5; do
|
||||
if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
||||
exit 0
|
||||
fi
|
||||
if pgrep -x sshd >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
|
||||
pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true
|
||||
rm -f /run/sshd.pid /var/run/sshd.pid
|
||||
ensure_sshd_runtime_dir
|
||||
"$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32
|
||||
fi
|
||||
|
||||
@@ -1922,7 +2216,7 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
|
||||
if err := m.preconfigureSSH(rootfsPath, c.Template, ""); err != nil {
|
||||
return "", fmt.Errorf("failed to configure SSH: %v", err)
|
||||
}
|
||||
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
|
||||
@@ -1986,6 +2280,45 @@ func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) installRootAuthorizedKey(rootfsPath, publicKey string) error {
|
||||
key, err := NormalizeSSHPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
sshDir := filepath.Join(rootfsPath, "root", ".ssh")
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
authPath := filepath.Join(sshDir, "authorized_keys")
|
||||
existing, _ := os.ReadFile(authPath)
|
||||
lines := strings.Split(string(existing), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == key {
|
||||
_ = os.Chmod(sshDir, 0700)
|
||||
_ = os.Chmod(authPath, 0600)
|
||||
_ = os.Chown(sshDir, 0, 0)
|
||||
_ = os.Chown(authPath, 0, 0)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
content := strings.TrimRight(string(existing), "\r\n")
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += key + "\n"
|
||||
if err := os.WriteFile(authPath, []byte(content), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(sshDir, 0700)
|
||||
_ = os.Chmod(authPath, 0600)
|
||||
_ = os.Chown(sshDir, 0, 0)
|
||||
_ = os.Chown(authPath, 0, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeRootfsCommandArgs(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("empty rootfs command")
|
||||
@@ -2269,6 +2602,8 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
||||
RAMMB: 512,
|
||||
DiskGB: 10,
|
||||
NetworkBWMbps: 100,
|
||||
NetworkDownMbps: 100,
|
||||
NetworkUpMbps: 100,
|
||||
MonthlyTrafficGB: 1000,
|
||||
TrafficMode: "total",
|
||||
Status: status,
|
||||
@@ -2383,7 +2718,7 @@ func copyRootfsContents(src, dst string) error {
|
||||
}
|
||||
|
||||
// ReinstallContainer reinstalls the container OS
|
||||
func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...ContainerConfig) error {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
@@ -2393,6 +2728,14 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("template not found: %s", templateID)
|
||||
}
|
||||
authCfg := ContainerConfig{SSHAuthMode: SSHAuthKeep}
|
||||
if len(authConfig) > 0 {
|
||||
authCfg = authConfig[0]
|
||||
}
|
||||
sshAccess, err := ResolveReinstallSSHAccess(c.SSHPassword, authCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lxcName := c.LxcName()
|
||||
|
||||
@@ -2404,6 +2747,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
|
||||
// Clean port mappings temporarily
|
||||
m.CleanPortMappings(id)
|
||||
CleanFirewallRules(id)
|
||||
|
||||
// Download the new OS into a temporary container, then replace only the
|
||||
// existing rootfs. The target container directory and config are preserved.
|
||||
@@ -2423,16 +2767,21 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
RAMMB: c.RAMMB,
|
||||
DiskGB: c.DiskGB,
|
||||
NetworkBWMbps: c.NetworkBWMbps,
|
||||
NetworkDownMbps: c.NetworkDownMbps,
|
||||
NetworkUpMbps: c.NetworkUpMbps,
|
||||
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
||||
IOSpeedMBps: c.IOSpeedMBps,
|
||||
AssignIPv6: c.IPv6 != "",
|
||||
IOReadMBps: c.IOReadMBps,
|
||||
IOWriteMBps: c.IOWriteMBps,
|
||||
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
|
||||
ExpiresAt: c.ExpiresAt,
|
||||
}
|
||||
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.IPv6 != "" {
|
||||
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
c.NormalizeNetworkAssignments()
|
||||
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -2440,17 +2789,20 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
// Set root password and pre-configure network/SSH via chroot.
|
||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||
m.preconfigureNetwork(rootfsPath, templateID)
|
||||
if c.IPv6 != "" {
|
||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
}
|
||||
if c.SSHPassword == "" {
|
||||
c.SSHPassword = generateRandomString(16)
|
||||
}
|
||||
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
|
||||
c.SSHPassword = sshAccess.Password
|
||||
if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil {
|
||||
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
|
||||
}
|
||||
if sshAccess.PublicKey != "" {
|
||||
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
|
||||
return fmt.Errorf("failed to install SSH public key: %v", err)
|
||||
}
|
||||
}
|
||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2470,14 +2822,17 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
config.SaveConfig()
|
||||
return err
|
||||
}
|
||||
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
|
||||
os.Remove(logFile)
|
||||
startCmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
|
||||
if output, err := startCmd.CombinedOutput(); err != nil {
|
||||
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to start container after reinstall: %v\n", err)
|
||||
c.Status = "stopped"
|
||||
config.SaveConfig()
|
||||
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
|
||||
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
|
||||
}
|
||||
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
|
||||
c.Status = "stopped"
|
||||
config.SaveConfig()
|
||||
return fmt.Errorf("reinstalled but container did not stay running: %v", err)
|
||||
}
|
||||
|
||||
// Wait for network and install SSH
|
||||
@@ -2500,10 +2855,9 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
|
||||
}
|
||||
}
|
||||
// Apply bandwidth limit after reinstall
|
||||
if c.NetworkBWMbps > 0 {
|
||||
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
|
||||
}
|
||||
if c.IPv6 != "" {
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
m.applyBandwidthLimit(c.LxcName(), c.NetworkDownMbps, c.NetworkUpMbps)
|
||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||
if err := m.ApplyIPv6(id); err != nil {
|
||||
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -88,3 +88,48 @@ func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
|
||||
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLXCVDenylistSeccompProfile(t *testing.T) {
|
||||
tests := []string{`
|
||||
# base profile
|
||||
2
|
||||
denylist
|
||||
[all]
|
||||
open_by_handle_at errno 1
|
||||
`, `
|
||||
2
|
||||
blacklist allow
|
||||
[all]
|
||||
open_by_handle_at errno 1
|
||||
`}
|
||||
|
||||
for _, profile := range tests {
|
||||
if !isLXCVDenylistSeccompProfile(profile) {
|
||||
t.Fatalf("expected v2 denylist profile for\n%s", profile)
|
||||
}
|
||||
}
|
||||
if isLXCVDenylistSeccompProfile("1\nallowlist\n1\n") {
|
||||
t.Fatal("did not expect v1 allowlist profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
|
||||
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
|
||||
|
||||
once := appendMissingSeccompRules(base, cve202643499FutexSeccompRules)
|
||||
twice := appendMissingSeccompRules(once, cve202643499FutexSeccompRules)
|
||||
|
||||
for _, want := range []string{
|
||||
"futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
} {
|
||||
if !strings.Contains(once, want) {
|
||||
t.Fatalf("missing seccomp rule %q in\n%s", want, once)
|
||||
}
|
||||
if strings.Count(twice, want) != 1 {
|
||||
t.Fatalf("rule %q duplicated in\n%s", want, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+747
-33
@@ -2,8 +2,10 @@ package lxc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
@@ -17,6 +19,7 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
if c.IP == "" {
|
||||
return fmt.Errorf("container has no IP")
|
||||
}
|
||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
tag := clicdTag(id)
|
||||
bridge := "lxcbr0"
|
||||
subnet := "10.0.3.0/24"
|
||||
@@ -27,35 +30,223 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
|
||||
EnsureForwardRules(bridge)
|
||||
m.CleanPortMappings(id)
|
||||
deleteBridgeMasquerade(subnet)
|
||||
|
||||
for _, pm := range c.PortMappings {
|
||||
cmd := exec.Command("iptables",
|
||||
"-t", "nat",
|
||||
"-I", "PREROUTING", "1",
|
||||
"-p", pm.Protocol,
|
||||
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
||||
"-j", "DNAT",
|
||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
|
||||
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
||||
continue
|
||||
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||
args := []string{
|
||||
"-t", "nat",
|
||||
"-I", "PREROUTING", "1",
|
||||
"-p", pm.Protocol,
|
||||
}
|
||||
if hostIP != "" {
|
||||
args = append(args, "-d", hostIP)
|
||||
}
|
||||
args = append(args,
|
||||
"--dport", fmt.Sprintf("%d", pm.HostPort),
|
||||
"-j", "DNAT",
|
||||
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), pm.HostPort),
|
||||
)
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to apply port mapping %s:%d->%s:%d: %v, output: %s\n",
|
||||
displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Port mapping: %s:%d -> %s:%d\n", displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort)
|
||||
}
|
||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
||||
}
|
||||
|
||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
|
||||
// When container has public IPv4, apply full port passthrough DNAT so the
|
||||
// container owns all ports on its public IP (no NAT management needed).
|
||||
if len(c.PublicIPv4s) > 0 {
|
||||
ensureIndependentIPv4Ingress(c, tag)
|
||||
}
|
||||
|
||||
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
||||
|
||||
if err := ApplyFirewallRules(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureIndependentIPv4Ingress(c *config.Container, tag string) {
|
||||
if c == nil || c.IP == "" || len(c.PublicIPv4s) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, assignment := range c.PublicIPv4s {
|
||||
hostIP := strings.TrimSpace(assignment.Address)
|
||||
if hostIP == "" {
|
||||
continue
|
||||
}
|
||||
// Full port passthrough: DNAT all TCP+UDP traffic on this public IP to the container.
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
args := []string{
|
||||
"-t", "nat",
|
||||
"-I", "PREROUTING", "1",
|
||||
"-d", hostIP,
|
||||
"-p", proto,
|
||||
"-j", "DNAT",
|
||||
"--to-destination", c.IP,
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-all-%s", tag, natRuleIPTag(hostIP), proto),
|
||||
}
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to apply %s passthrough %s->%s: %v, output: %s\n",
|
||||
proto, hostIP, c.IP, err, string(output))
|
||||
continue
|
||||
}
|
||||
fmt.Printf("IPv4 passthrough (%s): %s -> %s (all ports)\n", proto, hostIP, c.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
|
||||
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||
return
|
||||
}
|
||||
if containerAllowsPublicIPv4Egress(c) {
|
||||
if _, ok := primaryPublicIPv4Assignment(c); ok {
|
||||
applyPublicIPv4SNAT(c, tag)
|
||||
return
|
||||
}
|
||||
ensureContainerMasquerade(c, tag)
|
||||
return
|
||||
}
|
||||
ensureIPv4EgressBlocked(c, bridge, subnet, tag)
|
||||
}
|
||||
|
||||
func containerAllowsPublicIPv4Egress(c *config.Container) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if len(c.PublicIPv4s) > 0 {
|
||||
return true
|
||||
}
|
||||
return c.PortMappingLimit > 0 || len(c.PortMappings) > 0
|
||||
}
|
||||
|
||||
func ensureContainerMasquerade(c *config.Container, tag string) {
|
||||
args := []string{
|
||||
"-s", c.IP + "/32",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-masq", tag),
|
||||
"-j", "MASQUERADE",
|
||||
}
|
||||
if host := DetectPublicIPv4(); strings.TrimSpace(host.Interface) != "" {
|
||||
args = append([]string{"-o", strings.TrimSpace(host.Interface)}, args...)
|
||||
} else {
|
||||
args = append([]string{"-o", "eth+"}, args...)
|
||||
}
|
||||
ensureNATRule("POSTROUTING", args)
|
||||
}
|
||||
|
||||
func ensureIPv4EgressBlocked(c *config.Container, bridge, subnet, tag string) {
|
||||
args := []string{
|
||||
"-i", bridge,
|
||||
"-s", c.IP + "/32",
|
||||
"!", "-d", subnet,
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-v4-egress-block", tag),
|
||||
"-j", "REJECT",
|
||||
}
|
||||
ensureFilterRule("FORWARD", args)
|
||||
}
|
||||
|
||||
func ensureNATRule(chain string, args []string) {
|
||||
check := append([]string{"-t", "nat", "-C", chain}, args...)
|
||||
if exec.Command("iptables", check...).Run() == nil {
|
||||
return
|
||||
}
|
||||
add := append([]string{"-t", "nat", "-I", chain, "1"}, args...)
|
||||
exec.Command("iptables", add...).Run()
|
||||
}
|
||||
|
||||
func ensureFilterRule(chain string, args []string) {
|
||||
check := append([]string{"-C", chain}, args...)
|
||||
if exec.Command("iptables", check...).Run() == nil {
|
||||
return
|
||||
}
|
||||
add := append([]string{"-I", chain, "1"}, args...)
|
||||
exec.Command("iptables", add...).Run()
|
||||
}
|
||||
|
||||
func deleteBridgeMasquerade(subnet string) {
|
||||
for exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() == nil {
|
||||
}
|
||||
}
|
||||
|
||||
func applyPublicIPv4SNAT(c *config.Container, tag string) {
|
||||
if c == nil || strings.TrimSpace(c.IP) == "" {
|
||||
return
|
||||
}
|
||||
assignment, ok := primaryPublicIPv4Assignment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hostIP := strings.TrimSpace(assignment.Address)
|
||||
if hostIP == "" {
|
||||
return
|
||||
}
|
||||
iface := strings.TrimSpace(assignment.Interface)
|
||||
if iface == "" {
|
||||
if info, ok := publicIPv4InfoByAddress(hostIP); ok {
|
||||
iface = strings.TrimSpace(info.Interface)
|
||||
}
|
||||
}
|
||||
if iface == "" {
|
||||
if host := DetectPublicIPv4(); host.Interface != "" {
|
||||
iface = host.Interface
|
||||
}
|
||||
}
|
||||
args := []string{
|
||||
"-t", "nat",
|
||||
"-I", "POSTROUTING", "1",
|
||||
"-s", c.IP + "/32",
|
||||
}
|
||||
if iface != "" {
|
||||
args = append(args, "-o", iface)
|
||||
}
|
||||
args = append(args,
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-snat-%s", tag, natRuleIPTag(hostIP)),
|
||||
"-j", "SNAT", "--to-source", hostIP,
|
||||
)
|
||||
if output, err := exec.Command("iptables", args...).CombinedOutput(); err != nil {
|
||||
fmt.Printf("Warning: failed to apply public IPv4 SNAT %s -> %s: %v, output: %s\n", c.IP, hostIP, err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func primaryPublicIPv4Assignment(c *config.Container) (config.PublicIPv4Assignment, bool) {
|
||||
if c == nil {
|
||||
return config.PublicIPv4Assignment{}, false
|
||||
}
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if strings.TrimSpace(item.Address) != "" {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return config.PublicIPv4Assignment{}, false
|
||||
}
|
||||
|
||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||
|
||||
func EnsureAllRunningPortMappings() {
|
||||
m := NewManager()
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
||||
continue
|
||||
}
|
||||
if err := m.ApplyPortMappings(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to restore port mappings for %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||
func EnsureForwardRules(bridge string) {
|
||||
if bridge == "" {
|
||||
@@ -73,16 +264,21 @@ func EnsureForwardRules(bridge string) {
|
||||
break
|
||||
}
|
||||
}
|
||||
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
|
||||
exec.Command("iptables", insertArgs...).Run()
|
||||
appendArgs := append([]string{"-A", "FORWARD"}, args...)
|
||||
exec.Command("iptables", appendArgs...).Run()
|
||||
}
|
||||
}
|
||||
|
||||
// CleanPortMappings removes all iptables rules for a container
|
||||
func (m *Manager) CleanPortMappings(id int) error {
|
||||
tag := clicdTag(id)
|
||||
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
|
||||
cmd.Run()
|
||||
}
|
||||
cmd := exec.Command("sh", "-c",
|
||||
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
|
||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||
cmd.Run()
|
||||
return nil
|
||||
}
|
||||
@@ -94,12 +290,26 @@ func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||
if len(assignments) == 1 {
|
||||
return strings.TrimSpace(assignments[0].Address)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func defaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
|
||||
return DefaultPortMappingHostIP(assignments)
|
||||
}
|
||||
|
||||
// AddPortMapping adds a NAT rule to a container
|
||||
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if c.PortMappingLimit <= 0 {
|
||||
return nil, fmt.Errorf("container has no IPv4 NAT port quota")
|
||||
}
|
||||
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
|
||||
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
|
||||
}
|
||||
@@ -168,19 +378,34 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
if pm.Protocol == "" {
|
||||
pm.Protocol = "tcp"
|
||||
}
|
||||
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
|
||||
pm.HostIP = strings.TrimSpace(pm.HostIP)
|
||||
if pm.HostIP != "" {
|
||||
addr, err := netip.ParseAddr(pm.HostIP)
|
||||
if err != nil || !addr.Is4() {
|
||||
return pm, fmt.Errorf("host_ip must be a valid IPv4 address")
|
||||
}
|
||||
if !containerHasPublicIPv4(c, pm.HostIP) {
|
||||
return pm, fmt.Errorf("host_ip %s is not assigned to this container", pm.HostIP)
|
||||
}
|
||||
}
|
||||
if pm.Description == "" {
|
||||
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
|
||||
}
|
||||
if pm.HostPort <= 0 {
|
||||
pm.HostPort = pm.ContainerPort
|
||||
}
|
||||
if pm.HostIP == "" && !config.NATPortInRange(pm.HostPort) {
|
||||
start, end := config.NATPortRange()
|
||||
return pm, fmt.Errorf("host port must be within configured NAT4 range %d-%d", start, end)
|
||||
}
|
||||
// Check current container's own mappings
|
||||
for i, existing := range c.PortMappings {
|
||||
if i == skipIndex {
|
||||
continue
|
||||
}
|
||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
||||
return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
|
||||
if portMappingsConflict(c, pm, c, existing) {
|
||||
return pm, fmt.Errorf("host port %d/%s already mapped on the same IPv4 in this container", pm.HostPort, pm.Protocol)
|
||||
}
|
||||
}
|
||||
// Check all other containers (LXC + KVM) for port conflicts
|
||||
@@ -189,8 +414,9 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
continue
|
||||
}
|
||||
for _, existing := range oc.PortMappings {
|
||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
||||
return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
||||
oc := oc
|
||||
if portMappingsConflict(c, pm, &oc, existing) {
|
||||
return pm, fmt.Errorf("host port %d/%s already used on the same IPv4 by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +430,9 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
used := map[int]bool{}
|
||||
// Mark current container's ports
|
||||
for _, pm := range c.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
|
||||
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||
}
|
||||
used[pm.ContainerPort] = true
|
||||
}
|
||||
// Also mark all other containers' host ports (LXC + KVM)
|
||||
@@ -213,19 +441,505 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
continue
|
||||
}
|
||||
for _, pm := range oc.PortMappings {
|
||||
used[pm.HostPort] = true
|
||||
oc := oc
|
||||
for _, hostIP := range expandPortMappingHostIPs(&oc, pm) {
|
||||
used[hostPortKey(hostIP, pm.HostPort)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
next := 20000
|
||||
for len(ports) < count {
|
||||
if !used[next] {
|
||||
start, end := config.NATPortRange()
|
||||
for next := start; next <= end && len(ports) < count; next++ {
|
||||
hostIP := c.PrimaryPublicIPv4()
|
||||
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
next++
|
||||
if next > 65535 || len(ports) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func HostPortAvailable(c *config.Container, hostIP string, hostPort int, protocol string) bool {
|
||||
if c == nil || hostPort <= 0 {
|
||||
return false
|
||||
}
|
||||
pm := config.PortMapping{HostIP: strings.TrimSpace(hostIP), HostPort: hostPort, Protocol: protocol}
|
||||
for _, existing := range c.PortMappings {
|
||||
if portMappingsConflict(c, pm, c, existing) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, oc := range config.AppConfig.Containers {
|
||||
if oc.ID == c.ID {
|
||||
continue
|
||||
}
|
||||
oc := oc
|
||||
for _, existing := range oc.PortMappings {
|
||||
if portMappingsConflict(c, pm, &oc, existing) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func expandPortMappingHostIPs(c *config.Container, pm config.PortMapping) []string {
|
||||
if strings.TrimSpace(pm.HostIP) != "" {
|
||||
return []string{strings.TrimSpace(pm.HostIP)}
|
||||
}
|
||||
if c != nil && len(c.PublicIPv4s) > 0 {
|
||||
values := make([]string, 0, len(c.PublicIPv4s))
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if strings.TrimSpace(item.Address) != "" {
|
||||
values = append(values, strings.TrimSpace(item.Address))
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return []string{""}
|
||||
}
|
||||
|
||||
func containerHasPublicIPv4(c *config.Container, hostIP string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if item.Address == hostIP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func portMappingsConflict(aContainer *config.Container, a config.PortMapping, bContainer *config.Container, b config.PortMapping) bool {
|
||||
if a.HostPort != b.HostPort || !protocolsOverlap(a.Protocol, b.Protocol) {
|
||||
return false
|
||||
}
|
||||
aIPs := expandPortMappingHostIPs(aContainer, a)
|
||||
bIPs := expandPortMappingHostIPs(bContainer, b)
|
||||
for _, aIP := range aIPs {
|
||||
for _, bIP := range bIPs {
|
||||
if aIP == "" || bIP == "" || aIP == bIP {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func protocolsOverlap(a, b string) bool {
|
||||
a = strings.ToLower(strings.TrimSpace(a))
|
||||
b = strings.ToLower(strings.TrimSpace(b))
|
||||
if a == "" {
|
||||
a = "tcp"
|
||||
}
|
||||
if b == "" {
|
||||
b = "tcp"
|
||||
}
|
||||
if a == b || a == "all" || b == "all" {
|
||||
return true
|
||||
}
|
||||
return (a == "tcp+udp" && (b == "tcp" || b == "udp")) ||
|
||||
(b == "tcp+udp" && (a == "tcp" || a == "udp"))
|
||||
}
|
||||
|
||||
func natRuleIPTag(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return "any"
|
||||
}
|
||||
return strings.ReplaceAll(ip, ".", "_")
|
||||
}
|
||||
|
||||
func displayHostIP(ip string) string {
|
||||
if strings.TrimSpace(ip) == "" {
|
||||
return "host"
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func hostPortKey(hostIP string, port int) int {
|
||||
if hostIP == "" {
|
||||
return port
|
||||
}
|
||||
sum := 0
|
||||
for _, r := range hostIP {
|
||||
sum = sum*31 + int(r)
|
||||
}
|
||||
if sum < 0 {
|
||||
sum = -sum
|
||||
}
|
||||
return port + (sum % 1000000 * 100000)
|
||||
}
|
||||
|
||||
// CleanFirewallRules removes all firewall rules for a container from the FORWARD chain.
|
||||
func CleanFirewallRules(id int) {
|
||||
tag := clicdTag(id)
|
||||
// Remove all rules with the firewall tag prefix
|
||||
cmd := exec.Command("bash", "-c",
|
||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||
cmd.CombinedOutput()
|
||||
cmd = exec.Command("bash", "-c",
|
||||
fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do ip6tables $rule; done", tag))
|
||||
cmd.CombinedOutput()
|
||||
|
||||
// Also remove legacy default policy rules (without specific rule ID)
|
||||
for _, suffix := range []string{"default-in", "default-out"} {
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
exec.Command("iptables", "-D", "FORWARD",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
|
||||
).CombinedOutput()
|
||||
}
|
||||
exec.Command("ip6tables", "-D", "FORWARD",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s", tag, suffix),
|
||||
).CombinedOutput()
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyFirewallRules applies iptables FORWARD rules for a container's firewall configuration.
|
||||
func ApplyFirewallRules(id int) error {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
|
||||
// Always clean existing firewall rules first
|
||||
CleanFirewallRules(id)
|
||||
|
||||
// If firewall is disabled or no rules, nothing to apply
|
||||
if !c.FirewallEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
bridge := "lxcbr0"
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
}
|
||||
containerIP := strings.TrimSpace(c.IP)
|
||||
containerIPv6s := firewallIPv6Addresses(c)
|
||||
if containerIP == "" && len(containerIPv6s) == 0 {
|
||||
return nil
|
||||
}
|
||||
tag := clicdTag(id)
|
||||
|
||||
defaultAction := normalizeFirewallDefaultAction(c.FirewallDefaultAction)
|
||||
if defaultAction == "DROP" {
|
||||
if containerIP != "" {
|
||||
if err := applyDefaultFirewallPolicy(tag, bridge, containerIP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := applyDefaultFirewallIPv6Policy(tag, bridge, containerIPv6s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i := len(c.FirewallRules) - 1; i >= 0; i-- {
|
||||
rule := c.FirewallRules[i]
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if containerIP != "" && firewallRuleAppliesToFamily(rule, true) {
|
||||
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
|
||||
return fmt.Errorf("failed to apply firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||
}
|
||||
}
|
||||
if len(containerIPv6s) > 0 && firewallRuleAppliesToFamily(rule, false) {
|
||||
if err := applyOneFirewallIPv6Rule(tag, bridge, containerIPv6s, rule); err != nil {
|
||||
return fmt.Errorf("failed to apply IPv6 firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" {
|
||||
return "ACCEPT"
|
||||
}
|
||||
return "DROP"
|
||||
}
|
||||
|
||||
func normalizeFirewallNetwork(network string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
switch network {
|
||||
case "", "ipv4", "nat4":
|
||||
return "ipv4"
|
||||
case "ipv6":
|
||||
return "ipv6"
|
||||
case "all", "both":
|
||||
return "all"
|
||||
default:
|
||||
return "ipv4"
|
||||
}
|
||||
}
|
||||
|
||||
func firewallRuleAppliesToFamily(rule config.FirewallRule, ipv4 bool) bool {
|
||||
network := normalizeFirewallNetwork(rule.Network)
|
||||
if network == "ipv4" {
|
||||
return ipv4
|
||||
}
|
||||
if network == "ipv6" {
|
||||
return !ipv4
|
||||
}
|
||||
if rule.SourceIP == "" {
|
||||
return true
|
||||
}
|
||||
addr := firewallIPSpecAddr(rule.SourceIP)
|
||||
if !addr.IsValid() {
|
||||
return true
|
||||
}
|
||||
if ipv4 {
|
||||
return addr.Is4()
|
||||
}
|
||||
return addr.Is6() && !addr.Is4In6()
|
||||
}
|
||||
|
||||
func firewallIPSpecAddr(value string) netip.Addr {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return netip.Addr{}
|
||||
}
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return prefix.Addr()
|
||||
}
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func firewallIPv6Addresses(c *config.Container) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.NormalizeNetworkAssignments()
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, assignment := range c.IPv6Addresses {
|
||||
ip := strings.TrimSpace(assignment.Address)
|
||||
if ip == "" || seen[ip] {
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(ip); err == nil && addr.Is6() && !addr.Is4In6() {
|
||||
seen[ip] = true
|
||||
result = append(result, ip)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error {
|
||||
commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID)
|
||||
|
||||
// Build base iptables args
|
||||
args := []string{"-I", "FORWARD", "1"}
|
||||
|
||||
// Direction: in = traffic arriving at container (-o bridge -d containerIP)
|
||||
// out = traffic leaving container (-i bridge -s containerIP)
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-o", bridge, "-d", containerIP+"/32")
|
||||
case "out":
|
||||
args = append(args, "-i", bridge, "-s", containerIP+"/32")
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||
}
|
||||
|
||||
// Protocol
|
||||
switch rule.Protocol {
|
||||
case "tcp", "udp":
|
||||
args = append(args, "-p", rule.Protocol)
|
||||
case "icmp":
|
||||
args = append(args, "-p", "icmp")
|
||||
case "all":
|
||||
// no protocol filter
|
||||
default:
|
||||
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
|
||||
}
|
||||
|
||||
// Port matching (only for tcp/udp)
|
||||
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||
// For "in" direction, traffic going TO the container uses --dport
|
||||
// For "out" direction, traffic going FROM the container uses --dport (destination port on remote)
|
||||
args = append(args, firewallPortArgs(rule.Port)...)
|
||||
}
|
||||
|
||||
// Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender)
|
||||
if rule.SourceIP != "" {
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-s", rule.SourceIP)
|
||||
case "out":
|
||||
args = append(args, "-d", rule.SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
// Action
|
||||
action := "DROP"
|
||||
if rule.Action == "ACCEPT" {
|
||||
action = "ACCEPT"
|
||||
}
|
||||
args = append(args, "-j", action)
|
||||
|
||||
// Comment tag for cleanup
|
||||
args = append(args, "-m", "comment", "--comment", commentTag)
|
||||
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("iptables error: %s", string(output))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyOneFirewallIPv6Rule(tag, bridge string, containerIPs []string, rule config.FirewallRule) error {
|
||||
for _, containerIP := range containerIPs {
|
||||
commentTag := fmt.Sprintf("clicd-%s-fw-%s-v6-%s", tag, rule.ID, firewallCommentIPTag(containerIP))
|
||||
args := []string{"-I", "FORWARD", "1"}
|
||||
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-o", bridge, "-d", containerIP+"/128")
|
||||
case "out":
|
||||
args = append(args, "-i", bridge, "-s", containerIP+"/128")
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||
}
|
||||
|
||||
switch rule.Protocol {
|
||||
case "tcp", "udp":
|
||||
args = append(args, "-p", rule.Protocol)
|
||||
case "icmp":
|
||||
args = append(args, "-p", "ipv6-icmp")
|
||||
case "all":
|
||||
default:
|
||||
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
|
||||
}
|
||||
|
||||
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||
args = append(args, firewallPortArgs(rule.Port)...)
|
||||
}
|
||||
|
||||
if rule.SourceIP != "" {
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-s", rule.SourceIP)
|
||||
case "out":
|
||||
args = append(args, "-d", rule.SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
action := "DROP"
|
||||
if rule.Action == "ACCEPT" {
|
||||
action = "ACCEPT"
|
||||
}
|
||||
args = append(args, "-j", action)
|
||||
args = append(args, "-m", "comment", "--comment", commentTag)
|
||||
|
||||
cmd := exec.Command("ip6tables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip6tables error: %s", string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firewallPortArgs(port string) []string {
|
||||
spec := normalizePortSpec(port)
|
||||
if strings.Contains(spec, ",") {
|
||||
return []string{"-m", "multiport", "--dports", spec}
|
||||
}
|
||||
return []string{"--dport", spec}
|
||||
}
|
||||
|
||||
// normalizePortSpec converts user port input to iptables-compatible port spec.
|
||||
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "80,443,8000-9000" -> "80,443,8000:9000"
|
||||
func normalizePortSpec(port string) string {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(port, ",")
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.Contains(part, "-") && !strings.Contains(part, ":") {
|
||||
bounds := strings.SplitN(part, "-", 2)
|
||||
if len(bounds) == 2 {
|
||||
part = strings.TrimSpace(bounds[0]) + ":" + strings.TrimSpace(bounds[1])
|
||||
}
|
||||
}
|
||||
parts[i] = part
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) error {
|
||||
defaults := [][]string{
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-o", bridge,
|
||||
"-d", containerIP + "/32",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in", tag),
|
||||
},
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-s", containerIP + "/32",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out", tag),
|
||||
},
|
||||
}
|
||||
for _, args := range defaults {
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("iptables default firewall error: %s", string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaultFirewallIPv6Policy(tag, bridge string, containerIPs []string) error {
|
||||
for _, containerIP := range containerIPs {
|
||||
defaults := [][]string{
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-o", bridge,
|
||||
"-d", containerIP + "/128",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||
},
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-s", containerIP + "/128",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||
},
|
||||
}
|
||||
for _, args := range defaults {
|
||||
cmd := exec.Command("ip6tables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip6tables default firewall error: %s", string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firewallCommentIPTag(ip string) string {
|
||||
replacer := strings.NewReplacer(":", "_", ".", "_", "/", "_")
|
||||
return replacer.Replace(ip)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package lxc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
SSHAuthAutoPassword = "auto_password"
|
||||
SSHAuthPassword = "password"
|
||||
SSHAuthKey = "key"
|
||||
SSHAuthKeep = "keep"
|
||||
)
|
||||
|
||||
type SSHAccess struct {
|
||||
Mode string
|
||||
Password string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
func HasSSHAuthOptions(cfg ContainerConfig) bool {
|
||||
return strings.TrimSpace(cfg.SSHAuthMode) != "" ||
|
||||
strings.TrimSpace(cfg.SSHPassword) != "" ||
|
||||
strings.TrimSpace(cfg.SSHPublicKey) != ""
|
||||
}
|
||||
|
||||
func ResolveCreateSSHAccess(cfg ContainerConfig) (SSHAccess, error) {
|
||||
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthAutoPassword)
|
||||
if err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
if mode == SSHAuthKeep {
|
||||
mode = SSHAuthAutoPassword
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case SSHAuthAutoPassword:
|
||||
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
|
||||
case SSHAuthPassword:
|
||||
password := strings.TrimSpace(cfg.SSHPassword)
|
||||
if password == "" {
|
||||
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
|
||||
}
|
||||
if err := ValidateCustomSSHPassword(password); err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
return SSHAccess{Mode: mode, Password: password}, nil
|
||||
case SSHAuthKey:
|
||||
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
|
||||
if err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
if publicKey == "" {
|
||||
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
|
||||
}
|
||||
password := strings.TrimSpace(cfg.SSHPassword)
|
||||
if password == "" {
|
||||
password = generateRandomString(16)
|
||||
} else if err := ValidateCustomSSHPassword(password); err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
|
||||
default:
|
||||
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveReinstallSSHAccess(currentPassword string, cfg ContainerConfig) (SSHAccess, error) {
|
||||
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthKeep)
|
||||
if err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case SSHAuthKeep:
|
||||
password := strings.TrimSpace(currentPassword)
|
||||
if password == "" {
|
||||
password = generateRandomString(16)
|
||||
}
|
||||
if err := validateRootPassword(password); err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
return SSHAccess{Mode: mode, Password: password}, nil
|
||||
case SSHAuthAutoPassword:
|
||||
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
|
||||
case SSHAuthPassword:
|
||||
password := strings.TrimSpace(cfg.SSHPassword)
|
||||
if password == "" {
|
||||
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
|
||||
}
|
||||
if err := ValidateCustomSSHPassword(password); err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
return SSHAccess{Mode: mode, Password: password}, nil
|
||||
case SSHAuthKey:
|
||||
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
|
||||
if err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
if publicKey == "" {
|
||||
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
|
||||
}
|
||||
password := strings.TrimSpace(cfg.SSHPassword)
|
||||
if password != "" {
|
||||
if err := ValidateCustomSSHPassword(password); err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
} else {
|
||||
password = strings.TrimSpace(currentPassword)
|
||||
if password == "" {
|
||||
password = generateRandomString(16)
|
||||
}
|
||||
}
|
||||
if err := validateRootPassword(password); err != nil {
|
||||
return SSHAccess{}, err
|
||||
}
|
||||
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
|
||||
default:
|
||||
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateCustomSSHPassword(password string) error {
|
||||
if len(password) < 8 || len(password) > 64 {
|
||||
return fmt.Errorf("密码长度必须为 8-64 位")
|
||||
}
|
||||
hasLetter := false
|
||||
hasDigit := false
|
||||
for _, r := range password {
|
||||
if unicode.IsSpace(r) {
|
||||
return fmt.Errorf("密码不能包含空白字符")
|
||||
}
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
if !hasLetter || !hasDigit {
|
||||
return fmt.Errorf("密码至少需要包含字母和数字")
|
||||
}
|
||||
return validateRootPassword(password)
|
||||
}
|
||||
|
||||
func NormalizeSSHPublicKey(publicKey string) (string, error) {
|
||||
key := strings.TrimSpace(publicKey)
|
||||
if key == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(key) > 8192 {
|
||||
return "", fmt.Errorf("SSH 公钥长度不能超过 8192 字符")
|
||||
}
|
||||
if strings.ContainsAny(key, "\r\n") || strings.ContainsRune(key, '\x00') {
|
||||
return "", fmt.Errorf("SSH 公钥只能填写一行")
|
||||
}
|
||||
|
||||
fields := strings.Fields(key)
|
||||
if len(fields) < 2 {
|
||||
return "", fmt.Errorf("SSH 公钥格式不正确")
|
||||
}
|
||||
if !isSupportedSSHKeyType(fields[0]) {
|
||||
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", fields[0])
|
||||
}
|
||||
parsed, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("SSH 公钥格式不正确")
|
||||
}
|
||||
if strings.TrimSpace(string(rest)) != "" {
|
||||
return "", fmt.Errorf("一次只能填写一个 SSH 公钥")
|
||||
}
|
||||
if !isSupportedSSHKeyType(parsed.Type()) {
|
||||
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", parsed.Type())
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func resolveSSHAuthMode(rawMode, password, publicKey, defaultMode string) (string, error) {
|
||||
mode := strings.ToLower(strings.TrimSpace(rawMode))
|
||||
mode = strings.ReplaceAll(mode, "-", "_")
|
||||
if mode == "" {
|
||||
if strings.TrimSpace(publicKey) != "" {
|
||||
return SSHAuthKey, nil
|
||||
}
|
||||
if strings.TrimSpace(password) != "" {
|
||||
return SSHAuthPassword, nil
|
||||
}
|
||||
return defaultMode, nil
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "auto", "auto_password", "generated", "generate":
|
||||
return SSHAuthAutoPassword, nil
|
||||
case "password", "custom_password":
|
||||
return SSHAuthPassword, nil
|
||||
case "key", "ssh_key", "public_key":
|
||||
return SSHAuthKey, nil
|
||||
case "keep", "retain", "keep_password":
|
||||
return SSHAuthKeep, nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的 SSH 登录方式: %s", rawMode)
|
||||
}
|
||||
}
|
||||
|
||||
func isSupportedSSHKeyType(keyType string) bool {
|
||||
switch keyType {
|
||||
case "ssh-ed25519",
|
||||
"ssh-rsa",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package lxc
|
||||
|
||||
import "runtime"
|
||||
|
||||
// Template represents an LXC image template
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
@@ -13,55 +15,65 @@ type Template struct {
|
||||
|
||||
// GetTemplates returns available LXC image templates (only verified working ones)
|
||||
func GetTemplates() []Template {
|
||||
arch := defaultTemplateArch()
|
||||
return []Template{
|
||||
{
|
||||
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
||||
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
||||
Distro: "ubuntu", Release: "noble", Arch: arch,
|
||||
Description: "Ubuntu 24.04 LTS",
|
||||
},
|
||||
{
|
||||
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
|
||||
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
||||
Distro: "ubuntu", Release: "jammy", Arch: arch,
|
||||
Description: "Ubuntu 22.04 LTS",
|
||||
},
|
||||
{
|
||||
ID: "debian-bookworm", Name: "Debian 12",
|
||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||
Distro: "debian", Release: "bookworm", Arch: arch,
|
||||
Description: "Debian 12 (Bookworm)",
|
||||
},
|
||||
{
|
||||
ID: "debian-bullseye", Name: "Debian 11",
|
||||
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
||||
Distro: "debian", Release: "bullseye", Arch: arch,
|
||||
Description: "Debian 11 (Bullseye)",
|
||||
},
|
||||
{
|
||||
ID: "alpine-3.21", Name: "Alpine 3.21",
|
||||
Distro: "alpine", Release: "3.21", Arch: "amd64",
|
||||
Distro: "alpine", Release: "3.21", Arch: arch,
|
||||
Description: "Alpine Linux 3.21",
|
||||
},
|
||||
{
|
||||
ID: "centos-9-stream", Name: "CentOS 9 Stream",
|
||||
Distro: "centos", Release: "9-Stream", Arch: "amd64",
|
||||
Distro: "centos", Release: "9-Stream", Arch: arch,
|
||||
Description: "CentOS 9 Stream",
|
||||
},
|
||||
{
|
||||
ID: "archlinux-current", Name: "Arch Linux",
|
||||
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
|
||||
Distro: "archlinux", Release: "current", Arch: arch,
|
||||
Description: "Arch Linux (Rolling)",
|
||||
},
|
||||
{
|
||||
ID: "fedora-44", Name: "Fedora 44",
|
||||
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
|
||||
Distro: "fedora", Release: "44", Arch: arch,
|
||||
Description: "Fedora 44",
|
||||
},
|
||||
{
|
||||
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
||||
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
|
||||
Distro: "rockylinux", Release: "10", Arch: arch,
|
||||
Description: "Rocky Linux 10",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultTemplateArch() string {
|
||||
switch runtime.GOARCH {
|
||||
case "arm64":
|
||||
return "arm64"
|
||||
default:
|
||||
return "amd64"
|
||||
}
|
||||
}
|
||||
|
||||
// FindTemplate finds a template by ID
|
||||
func FindTemplate(id string) *Template {
|
||||
templates := GetTemplates()
|
||||
|
||||
@@ -4,10 +4,7 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/api"
|
||||
@@ -20,7 +17,7 @@ var webFS http.FileSystem
|
||||
// corsMiddleware adds CORS headers
|
||||
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && config.IsOriginAllowed(origin, r.Host) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
@@ -29,7 +26,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" && !config.IsOriginAllowed(origin, r.Host) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -41,43 +38,17 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedOrigin(origin string, requestHost string) bool {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
originHost := normalizeHost(u.Host)
|
||||
host := normalizeHost(requestHost)
|
||||
if originHost == host {
|
||||
return true
|
||||
}
|
||||
return isLoopbackHost(originHost) && isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// setupRoutes configures API and static routes
|
||||
func setupRoutes(mux *http.ServeMux) {
|
||||
// API routes
|
||||
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
|
||||
mux.HandleFunc("/api/language", corsMiddleware(api.HandleLanguage))
|
||||
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
|
||||
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
|
||||
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
|
||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
@@ -92,6 +63,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
@@ -120,6 +92,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
|
||||
// Versioned external API routes
|
||||
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
|
||||
mux.HandleFunc("/api/v1/language", corsMiddleware(api.HandleLanguage))
|
||||
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
@@ -133,6 +106,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
@@ -145,6 +119,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
|
||||
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||
@@ -213,11 +188,26 @@ func Run() error {
|
||||
}
|
||||
|
||||
if sslEnabled() {
|
||||
certPath, keyPath, err := config.ResolveSSLConfigPaths(config.AppConfig.SSL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server.TLSConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath)
|
||||
return &cert, err
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(safeCertPath, safeKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
},
|
||||
}
|
||||
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
|
||||
@@ -229,14 +219,29 @@ func Run() error {
|
||||
|
||||
func sslEnabled() bool {
|
||||
ssl := config.AppConfig.SSL
|
||||
if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" {
|
||||
if !ssl.Enabled {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(ssl.CertPath); err != nil {
|
||||
certPath, keyPath, err := config.ResolveSSLConfigPaths(ssl)
|
||||
if err != nil {
|
||||
log.Printf("SSL paths are invalid, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
log.Printf("SSL certificate path is not allowed, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
log.Printf("SSL private key path is not allowed, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
if _, err := config.ReadableFileStat(safeCertPath); err != nil {
|
||||
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(ssl.KeyPath); err != nil {
|
||||
if _, err := config.ReadableFileStat(safeKeyPath); err != nil {
|
||||
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.7"
|
||||
Version = "1.1.22"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
+29
-2
@@ -55,6 +55,7 @@ func main() {
|
||||
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||
lxc.EnsureForwardRules("lxcbr0")
|
||||
lxc.EnsureForwardRules("virbr0")
|
||||
lxc.EnsureAllAssignedPublicIPv4s()
|
||||
|
||||
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
||||
manager := lxc.NewManager()
|
||||
@@ -74,6 +75,7 @@ func main() {
|
||||
|
||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||
config.CleanStaleContainers()
|
||||
lxc.EnsureAllRunningPortMappings()
|
||||
|
||||
// Pre-warm SSH for containers already running after host boot or service restart.
|
||||
manager.StartSSHWarmupScanner()
|
||||
@@ -107,8 +109,33 @@ func isWebPanelSystemdRunning() bool {
|
||||
func startWebPanelSystemd() {
|
||||
cmd := exec.Command("systemctl", "start", "clicd")
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", mainT("警告: 自动启动 Web 面板失败"), err)
|
||||
} else {
|
||||
fmt.Println("Web 面板已自动启动")
|
||||
fmt.Println(mainT("Web 面板已自动启动"))
|
||||
}
|
||||
}
|
||||
|
||||
func mainT(text string) string {
|
||||
if !mainEnglish() {
|
||||
return text
|
||||
}
|
||||
switch text {
|
||||
case "警告: 自动启动 Web 面板失败":
|
||||
return "Warning: failed to auto-start web panel"
|
||||
case "Web 面板已自动启动":
|
||||
return "Web panel auto-started"
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
func mainEnglish() bool {
|
||||
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
|
||||
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
|
||||
return true
|
||||
}
|
||||
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
|
||||
return false
|
||||
}
|
||||
return config.AppConfig != nil && config.NormalizeLanguage(config.AppConfig.Language) == "en"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR/build"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
FRONTEND_DIR="$SCRIPT_DIR/frontend"
|
||||
BACKEND_DIR="$SCRIPT_DIR/backend"
|
||||
WEB_DIR="$SCRIPT_DIR/web"
|
||||
@@ -17,9 +18,11 @@ echo "====================================="
|
||||
|
||||
# Clean previous build
|
||||
rm -rf "$BUILD_DIR"
|
||||
rm -rf "$DIST_DIR"
|
||||
rm -rf "$WEB_DIR"
|
||||
rm -rf "$EMBED_WEB_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
mkdir -p "$WEB_DIR"
|
||||
mkdir -p "$EMBED_WEB_DIR"
|
||||
touch "$EMBED_WEB_DIR/.gitkeep"
|
||||
@@ -51,9 +54,26 @@ cd "$BACKEND_DIR"
|
||||
go mod tidy
|
||||
go mod download
|
||||
|
||||
# Build for Linux amd64
|
||||
BUILD_VERSION="${CLICD_VERSION:-dev}"
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" .
|
||||
TARGET_GOOS="${CLICD_GOOS:-linux}"
|
||||
TARGET_GOARCH="${CLICD_GOARCH:-amd64}"
|
||||
|
||||
case "$TARGET_GOARCH" in
|
||||
all) TARGET_GOARCH_LIST="amd64 arm64" ;;
|
||||
amd64|arm64) TARGET_GOARCH_LIST="$TARGET_GOARCH" ;;
|
||||
*)
|
||||
echo "Unsupported CLICD_GOARCH: $TARGET_GOARCH (expected amd64, arm64, or all)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
for arch in $TARGET_GOARCH_LIST; do
|
||||
echo "Target: ${TARGET_GOOS}/${arch}"
|
||||
GOOS="$TARGET_GOOS" GOARCH="$arch" CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd-linux-${arch}" .
|
||||
done
|
||||
|
||||
first_arch="${TARGET_GOARCH_LIST%% *}"
|
||||
cp "$BUILD_DIR/clicd-linux-${first_arch}" "$BUILD_DIR/clicd"
|
||||
|
||||
echo "Go backend built successfully"
|
||||
|
||||
@@ -62,7 +82,20 @@ echo ""
|
||||
echo "[3/3] Packaging..."
|
||||
cp -r "$WEB_DIR" "$BUILD_DIR/web"
|
||||
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
|
||||
chmod +x "$BUILD_DIR/clicd"
|
||||
chmod +x "$BUILD_DIR"/clicd*
|
||||
|
||||
for arch in $TARGET_GOARCH_LIST; do
|
||||
asset_dir="clicd-linux-${arch}"
|
||||
package_root="$BUILD_DIR/package-${arch}"
|
||||
rm -rf "$package_root"
|
||||
mkdir -p "$package_root/$asset_dir"
|
||||
cp "$BUILD_DIR/clicd-linux-${arch}" "$package_root/$asset_dir/clicd"
|
||||
cp "$BUILD_DIR/install.sh" "$package_root/$asset_dir/install.sh" 2>/dev/null || true
|
||||
chmod +x "$package_root/$asset_dir/clicd"
|
||||
[ ! -f "$package_root/$asset_dir/install.sh" ] || chmod +x "$package_root/$asset_dir/install.sh"
|
||||
tar -C "$package_root" -czf "$DIST_DIR/${asset_dir}.tar.gz" "$asset_dir"
|
||||
cp "$BUILD_DIR/clicd-linux-${arch}" "$DIST_DIR/${asset_dir}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "====================================="
|
||||
@@ -70,6 +103,11 @@ echo " Build Complete!"
|
||||
echo "====================================="
|
||||
echo " Output: $BUILD_DIR/clicd"
|
||||
echo " Web: $BUILD_DIR/web/"
|
||||
echo " Dist: $DIST_DIR/"
|
||||
for arch in $TARGET_GOARCH_LIST; do
|
||||
echo " dist/clicd-linux-${arch}"
|
||||
echo " dist/clicd-linux-${arch}.tar.gz"
|
||||
done
|
||||
echo ""
|
||||
echo " To deploy:"
|
||||
echo " 1. Copy build/ directory to server"
|
||||
|
||||
+144
-48
@@ -1,5 +1,105 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
const zhNav = [
|
||||
{ text: '指南', link: '/guide/introduction' },
|
||||
{ text: '功能', link: '/features/dashboard' },
|
||||
{ text: '运维', link: '/operations/deployment' },
|
||||
{ text: '开发', link: '/developer/architecture' },
|
||||
]
|
||||
|
||||
const enNav = [
|
||||
{ text: 'Guide', link: '/en/guide/introduction' },
|
||||
{ text: 'Features', link: '/en/features/dashboard' },
|
||||
{ text: 'Operations', link: '/en/operations/deployment' },
|
||||
{ text: 'Developer', link: '/en/developer/architecture' },
|
||||
]
|
||||
|
||||
const zhSidebar = [
|
||||
{
|
||||
text: '开始',
|
||||
items: [
|
||||
{ text: '项目介绍', link: '/guide/introduction' },
|
||||
{ text: '安装', link: '/guide/installation' },
|
||||
{ text: '升级', link: '/guide/upgrade' },
|
||||
{ text: '快速上手', link: '/guide/quick-start' },
|
||||
{ text: '配置说明', link: '/guide/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '功能',
|
||||
items: [
|
||||
{ text: '控制面板', link: '/features/dashboard' },
|
||||
{ text: '容器管理', link: '/features/containers' },
|
||||
{ text: '镜像管理', link: '/features/images' },
|
||||
{ text: '网络与路由', link: '/features/networking' },
|
||||
{ text: '快照管理', link: '/features/snapshots' },
|
||||
{ text: '安全告警', link: '/features/security' },
|
||||
{ text: '子用户', link: '/features/sub-users' },
|
||||
{ text: 'API 集成', link: '/features/api' },
|
||||
{ text: '主机报告', link: '/features/host-report' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '运维',
|
||||
items: [
|
||||
{ text: '部署建议', link: '/operations/deployment' },
|
||||
{ text: '故障排查', link: '/operations/troubleshooting' },
|
||||
{ text: '常见问题', link: '/operations/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '开发',
|
||||
items: [
|
||||
{ text: '系统架构', link: '/developer/architecture' },
|
||||
{ text: '本地构建', link: '/developer/build' },
|
||||
{ text: '发布流程', link: '/developer/release' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const enSidebar = [
|
||||
{
|
||||
text: 'Get Started',
|
||||
items: [
|
||||
{ text: 'Introduction', link: '/en/guide/introduction' },
|
||||
{ text: 'Installation', link: '/en/guide/installation' },
|
||||
{ text: 'Upgrade', link: '/en/guide/upgrade' },
|
||||
{ text: 'Quick Start', link: '/en/guide/quick-start' },
|
||||
{ text: 'Configuration', link: '/en/guide/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Features',
|
||||
items: [
|
||||
{ text: 'Dashboard', link: '/en/features/dashboard' },
|
||||
{ text: 'Containers', link: '/en/features/containers' },
|
||||
{ text: 'Images', link: '/en/features/images' },
|
||||
{ text: 'Networking & Routing', link: '/en/features/networking' },
|
||||
{ text: 'Snapshots', link: '/en/features/snapshots' },
|
||||
{ text: 'Security Alerts', link: '/en/features/security' },
|
||||
{ text: 'Sub-users', link: '/en/features/sub-users' },
|
||||
{ text: 'API Integration', link: '/en/features/api' },
|
||||
{ text: 'Host Report', link: '/en/features/host-report' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Operations',
|
||||
items: [
|
||||
{ text: 'Deployment', link: '/en/operations/deployment' },
|
||||
{ text: 'Troubleshooting', link: '/en/operations/troubleshooting' },
|
||||
{ text: 'FAQ', link: '/en/operations/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Developer',
|
||||
items: [
|
||||
{ text: 'Architecture', link: '/en/developer/architecture' },
|
||||
{ text: 'Local Build', link: '/en/developer/build' },
|
||||
{ text: 'Release Process', link: '/en/developer/release' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
title: 'CLICD',
|
||||
description: '面向 LXC/KVM 的轻量虚拟化管理面板文档',
|
||||
@@ -10,59 +110,55 @@ export default defineConfig({
|
||||
head: [
|
||||
['link', { rel: 'icon', href: '/favicon.svg' }],
|
||||
],
|
||||
vite: {
|
||||
esbuild: {
|
||||
supported: {
|
||||
destructuring: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
locales: {
|
||||
root: {
|
||||
label: '简体中文',
|
||||
lang: 'zh-CN',
|
||||
description: '面向 LXC/KVM 的轻量虚拟化管理面板文档',
|
||||
themeConfig: {
|
||||
nav: zhNav,
|
||||
sidebar: zhSidebar,
|
||||
outline: {
|
||||
label: '页面导航',
|
||||
},
|
||||
darkModeSwitchLabel: '外观',
|
||||
sidebarMenuLabel: '菜单',
|
||||
returnToTopLabel: '返回顶部',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
label: 'English',
|
||||
lang: 'en-US',
|
||||
link: '/en/',
|
||||
description: 'Documentation for the lightweight LXC/KVM virtualization management panel.',
|
||||
themeConfig: {
|
||||
nav: enNav,
|
||||
sidebar: enSidebar,
|
||||
outline: {
|
||||
label: 'On This Page',
|
||||
},
|
||||
darkModeSwitchLabel: 'Appearance',
|
||||
sidebarMenuLabel: 'Menu',
|
||||
returnToTopLabel: 'Return to Top',
|
||||
footer: {
|
||||
message: 'CLICD documentation for deployment, usage, operations, and integration.',
|
||||
copyright: 'Copyright © CLICD contributors',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
logo: '/favicon.svg',
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
nav: [
|
||||
{ text: '指南', link: '/guide/introduction' },
|
||||
{ text: '功能', link: '/features/dashboard' },
|
||||
{ text: '运维', link: '/operations/deployment' },
|
||||
{ text: '开发', link: '/developer/architecture' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: '开始',
|
||||
items: [
|
||||
{ text: '项目介绍', link: '/guide/introduction' },
|
||||
{ text: '安装', link: '/guide/installation' },
|
||||
{ text: '升级', link: '/guide/upgrade' },
|
||||
{ text: '快速上手', link: '/guide/quick-start' },
|
||||
{ text: '配置说明', link: '/guide/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '功能',
|
||||
items: [
|
||||
{ text: '控制面板', link: '/features/dashboard' },
|
||||
{ text: '容器管理', link: '/features/containers' },
|
||||
{ text: '镜像管理', link: '/features/images' },
|
||||
{ text: '网络与路由', link: '/features/networking' },
|
||||
{ text: '快照管理', link: '/features/snapshots' },
|
||||
{ text: '安全告警', link: '/features/security' },
|
||||
{ text: '子用户', link: '/features/sub-users' },
|
||||
{ text: 'API 集成', link: '/features/api' },
|
||||
{ text: '主机报告', link: '/features/host-report' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '运维',
|
||||
items: [
|
||||
{ text: '部署建议', link: '/operations/deployment' },
|
||||
{ text: '故障排查', link: '/operations/troubleshooting' },
|
||||
{ text: '常见问题', link: '/operations/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '开发',
|
||||
items: [
|
||||
{ text: '系统架构', link: '/developer/architecture' },
|
||||
{ text: '本地构建', link: '/developer/build' },
|
||||
{ text: '发布流程', link: '/developer/release' },
|
||||
],
|
||||
},
|
||||
],
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/MengMengCode/CLICD' },
|
||||
],
|
||||
|
||||
@@ -30,6 +30,25 @@ bash build.sh
|
||||
|
||||
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
|
||||
|
||||
默认目标为 Linux amd64。需要构建 ARM64 包时可以指定:
|
||||
|
||||
```bash
|
||||
CLICD_GOARCH=arm64 bash build.sh
|
||||
```
|
||||
|
||||
需要同时构建 amd64 和 arm64 发布包时:
|
||||
|
||||
```bash
|
||||
CLICD_GOARCH=all bash build.sh
|
||||
```
|
||||
|
||||
构建完成后会生成:
|
||||
|
||||
- `dist/clicd-linux-amd64`
|
||||
- `dist/clicd-linux-amd64.tar.gz`
|
||||
- `dist/clicd-linux-arm64`
|
||||
- `dist/clicd-linux-arm64.tar.gz`
|
||||
|
||||
## 文档站构建
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
|
||||
|
||||
## Release 产物
|
||||
|
||||
安装脚本会优先下载 Linux AMD64 产物:
|
||||
安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64.tar.gz
|
||||
clicd-linux-arm64.tar.gz
|
||||
```
|
||||
|
||||
在部分场景中也会尝试下载单独二进制:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64
|
||||
clicd-linux-arm64
|
||||
```
|
||||
|
||||
## 安装脚本行为
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Architecture
|
||||
|
||||
CLICD consists of a Go backend, a React frontend, and host virtualization capabilities.
|
||||
|
||||
## Backend
|
||||
|
||||
The backend entry point is `backend/main.go`, and HTTP routes are centralized in `backend/internal/server/server.go`. Main modules:
|
||||
|
||||
- `internal/api`: HTTP APIs for the web panel and `/api/v1`.
|
||||
- `internal/config`: configuration and SQLite storage.
|
||||
- `internal/lxc`: LXC container management.
|
||||
- `internal/kvm`: KVM/libvirt virtual machine management.
|
||||
- `internal/cli`: command-line management entry point.
|
||||
- `internal/server`: embedded frontend assets and HTTP service.
|
||||
- `internal/version`: version number.
|
||||
|
||||
## Frontend
|
||||
|
||||
The frontend entry point is `frontend/src/main.tsx`. Pages live in `frontend/src/pages`, and shared components live in `frontend/src/components`.
|
||||
|
||||
Main pages:
|
||||
|
||||
- Dashboard: `Dashboard.tsx`
|
||||
- Container list: `Containers.tsx`
|
||||
- Container details: `ContainerDetail.tsx`
|
||||
- Image Management: `ImageManagement.tsx`
|
||||
- Security Alerts: `Security.tsx`
|
||||
- Snapshot Management: `Snapshots.tsx`
|
||||
- Routing Management: `Routing.tsx`
|
||||
- API Integration: `ApiIntegration.tsx`
|
||||
- Host Report: `HostReport.tsx`
|
||||
- Sub-user Management: `SubUserManagement.tsx`
|
||||
|
||||
## Frontend Embedding
|
||||
|
||||
For production builds, frontend artifacts are placed in `backend/internal/server/web`. The backend serves them through Go embed and returns the SPA entry for non-API routes.
|
||||
|
||||
## API Layers
|
||||
|
||||
- `/api/*`: web panel and compatibility APIs.
|
||||
- `/api/v1/*`: versioned APIs recommended for external automation.
|
||||
- WebSSH and WebVNC use short-lived tickets before opening WebSocket connections.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Local Build
|
||||
|
||||
## Frontend Build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Build output is written to `frontend/dist`.
|
||||
|
||||
## Backend Build
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go test ./...
|
||||
go build -o ../build/clicd .
|
||||
```
|
||||
|
||||
To package the embedded web panel, sync the frontend build output into the backend embed directory first.
|
||||
|
||||
## One-command Build
|
||||
|
||||
The project root provides a build script:
|
||||
|
||||
```bash
|
||||
bash build.sh
|
||||
```
|
||||
|
||||
The script chains frontend build, static asset sync, and Go binary build.
|
||||
|
||||
The default target is Linux amd64. To build an ARM64 package, set:
|
||||
|
||||
```bash
|
||||
CLICD_GOARCH=arm64 bash build.sh
|
||||
```
|
||||
|
||||
To build both amd64 and arm64 release assets at once:
|
||||
|
||||
```bash
|
||||
CLICD_GOARCH=all bash build.sh
|
||||
```
|
||||
|
||||
The build writes:
|
||||
|
||||
- `dist/clicd-linux-amd64`
|
||||
- `dist/clicd-linux-amd64.tar.gz`
|
||||
- `dist/clicd-linux-arm64`
|
||||
- `dist/clicd-linux-arm64.tar.gz`
|
||||
|
||||
## Docs Build
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run dev` starts a local preview, and `npm run build` generates static documentation.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Release Process
|
||||
|
||||
CLICD installation and upgrade rely on GitHub Release artifacts. Use semantic version tags such as `v1.1.6`.
|
||||
|
||||
## Version Number
|
||||
|
||||
Check the version in:
|
||||
|
||||
- `backend/internal/version/version.go`
|
||||
- `frontend/package.json`
|
||||
- Release tag.
|
||||
|
||||
## Release Artifacts
|
||||
|
||||
The installer first tries to download the Linux AMD64 or ARM64 archive for the host architecture:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64.tar.gz
|
||||
clicd-linux-arm64.tar.gz
|
||||
```
|
||||
|
||||
In some cases, it may also try the standalone binary:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64
|
||||
clicd-linux-arm64
|
||||
```
|
||||
|
||||
## Installer Behavior
|
||||
|
||||
- `CLICD_VERSION=latest`: use GitHub `releases/latest`.
|
||||
- `CLICD_VERSION=vX.Y.Z`: download artifacts from the specified release tag.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
CLICD_VERSION=v1.1.6 sh install.sh
|
||||
```
|
||||
|
||||
## Post-release Verification
|
||||
|
||||
- The installer can download the new version.
|
||||
- `systemctl status clicd` is healthy.
|
||||
- `/api/version` returns the new version.
|
||||
- The web panel can load frontend assets.
|
||||
- Container list, task queue, and API Key pages open correctly.
|
||||
@@ -0,0 +1,858 @@
|
||||
# API Integration
|
||||
|
||||
CLICD remains compatible with legacy `/api` endpoints, so existing integrations do not need to change. New integrations should use `/api/v1`; the list below is all v1, and the recommended container list endpoint is `GET /api/v1/containers`.
|
||||
|
||||
## Authentication
|
||||
|
||||
API keys can be created and managed from the API Integration page. Requests support either of these headers:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/containers
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
|
||||
```
|
||||
|
||||
## Response Shape
|
||||
|
||||
All APIs use the same response envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OK",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Integrations should read only the business fields they need. New capabilities are added as optional fields where possible, without requiring existing plugins to rename current fields.
|
||||
|
||||
## Creation and Reinstall
|
||||
|
||||
Container creation, batch creation, reinstall, and batch reinstall support mixed NAT, public IPv4, IPv6 networking, plus Linux SSH login configuration. Public IPv4/IPv6 pools can be viewed with `GET /api/v1/routing` and updated with `PUT /api/v1/routing`.
|
||||
|
||||
Create container example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "demo-lxc-01",
|
||||
"virtualization": "lxc",
|
||||
"template_id": "debian-bookworm",
|
||||
"vcpu": 1,
|
||||
"ram_mb": 512,
|
||||
"disk_gb": 10,
|
||||
"assign_nat": true,
|
||||
"port_mapping_count": 2,
|
||||
"assign_ipv4": false,
|
||||
"ipv4_count": 1,
|
||||
"public_ipv4s": [],
|
||||
"assign_ipv6": true,
|
||||
"ipv6_count": 1,
|
||||
"ipv6_addresses": [],
|
||||
"ssh_auth_mode": "auto_password",
|
||||
"ssh_password": "",
|
||||
"ssh_public_key": "",
|
||||
"expires_at": "",
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `assign_nat` | Whether to allocate NAT port mappings. If omitted, default NAT behavior is preserved. |
|
||||
| `assign_ipv4` | Whether to allocate public IPv4. |
|
||||
| `ipv4_count` | Number of public IPv4 addresses to allocate automatically. |
|
||||
| `public_ipv4s` | Explicit public IPv4 address list. |
|
||||
| `assign_ipv6` | Whether to allocate IPv6. |
|
||||
| `ipv6_count` | Number of IPv6 addresses to allocate automatically. |
|
||||
| `ipv6_addresses` | Explicit IPv6 address list. |
|
||||
| `ssh_auth_mode` | Linux creation supports `auto_password`, `password`, and `key`; reinstall also supports `keep`. |
|
||||
| `ssh_password` | Custom password for `password` mode. It must be 8-64 characters, include letters and digits, and contain no whitespace. |
|
||||
| `ssh_public_key` | One-line SSH public key for `key` mode. |
|
||||
| `network_down_mbps` | Optional container download/downlink bandwidth limit in Mbps. `0` means unlimited. |
|
||||
| `network_up_mbps` | Optional container upload/uplink bandwidth limit in Mbps. `0` means unlimited. |
|
||||
| `io_read_mbps` | Optional disk read limit in MB/s. `0` means unlimited. |
|
||||
| `io_write_mbps` | Optional disk write limit in MB/s. `0` means unlimited. |
|
||||
| `network_bw_mbps` | Legacy-compatible field. Sets symmetric downlink/uplink bandwidth; new integrations should prefer the split fields. |
|
||||
| `io_speed_mbps` | Legacy-compatible field. Sets symmetric read/write I/O limits; new integrations should prefer the split fields. |
|
||||
|
||||
Reinstall example:
|
||||
|
||||
```json
|
||||
{
|
||||
"template_id": "debian-bookworm",
|
||||
"ssh_auth_mode": "keep",
|
||||
"ssh_password": "",
|
||||
"ssh_public_key": ""
|
||||
}
|
||||
```
|
||||
|
||||
`keep` is only for reinstall and keeps the current SSH password. Windows KVM images ignore Linux SSH public key fields.
|
||||
|
||||
## Resource and Traffic Limits
|
||||
|
||||
`PUT /api/v1/containers/{id}/resource-limit` supports partial updates. Fields omitted from the request remain unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"vcpu": 2,
|
||||
"ram_mb": 1024,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
Legacy `network_bw_mbps` and `io_speed_mbps` are still accepted. They mean symmetric downlink/uplink bandwidth and symmetric read/write I/O limits. New integrations should use the split fields to control download/upload and read/write independently.
|
||||
|
||||
`PUT /api/v1/containers/{id}/traffic-limit` request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"traffic_mode": "total",
|
||||
"monthly_traffic_gb": 1024,
|
||||
"traffic_in_gb": 0,
|
||||
"traffic_out_gb": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `traffic_mode` | Traffic limit mode. Common values are `total` for a shared total limit and `split` for separate inbound/outbound limits. |
|
||||
| `monthly_traffic_gb` | Monthly total traffic quota for `total` mode, in GB. `0` means unlimited. |
|
||||
| `traffic_in_gb` | Monthly inbound quota for `split` mode, in GB. `0` means unlimited. |
|
||||
| `traffic_out_gb` | Monthly outbound quota for `split` mode, in GB. `0` means unlimited. |
|
||||
|
||||
## Container Firewall
|
||||
|
||||
Read container firewall settings with `GET /api/v1/containers/{id}/firewall` and update them with `PUT /api/v1/containers/{id}/firewall`. Updates are applied immediately when the container is running.
|
||||
|
||||
Update example:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"action": "ACCEPT",
|
||||
"network": "ipv4",
|
||||
"source_ip": "203.0.113.0/24",
|
||||
"port": "22,80,443",
|
||||
"description": "allow admin and web"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `enabled` | Whether the container firewall is enabled. |
|
||||
| `default_action` | Default action: `ACCEPT` or `DROP`. |
|
||||
| `rules[].id` | Optional. Omit for new rules and the backend will generate one. |
|
||||
| `rules[].direction` | Direction: `in` or `out`. |
|
||||
| `rules[].protocol` | Protocol: `tcp`, `udp`, `icmp`, or `all`. |
|
||||
| `rules[].action` | Action: `ACCEPT` or `DROP`. |
|
||||
| `rules[].network` | Network type: `ipv4`, `ipv6`, or `all`. |
|
||||
| `rules[].source_ip` | Optional source IP, CIDR, or address range. |
|
||||
| `rules[].port` | Optional. Supported only for `tcp`/`udp`; examples: `22`, `80,443`, or `8000-9000`. |
|
||||
| `rules[].description` | Optional note. |
|
||||
|
||||
## API Key Create and Update
|
||||
|
||||
`POST /api/v1/api-keys` and `PATCH /api/v1/api-keys/{id}` use the same field shape. `name` is required when creating a key; updates overwrite the fields you send.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Automation",
|
||||
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
|
||||
"scopes": ["dashboard:read", "container:read", "container:power"],
|
||||
"expires_at": "2026-12-31 23:59:59",
|
||||
"disabled": false,
|
||||
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `name` | API key name. Required when creating a key. |
|
||||
| `ip_whitelist` | Optional allowed source IPs/CIDRs, comma-separated. Empty means no IP restriction. |
|
||||
| `scopes` | Optional permission scopes. If omitted, the default read-only scopes are used. `*` grants all permissions. |
|
||||
| `expires_at` | Optional expiration time. Empty means no expiration. |
|
||||
| `disabled` | Whether this key is disabled. |
|
||||
| `container_uuids` | Optional container allowlist that limits the key to specific containers. |
|
||||
|
||||
## Python Example
|
||||
|
||||
Fetch containers:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://panel.example.com"
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update({
|
||||
"X-API-Key": API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
resp = session.get(f"{BASE_URL}/api/v1/containers", timeout=15)
|
||||
resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
Create a port mapping:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://panel.example.com"
|
||||
API_KEY = "YOUR_API_KEY"
|
||||
CONTAINER_ID = "example-vm"
|
||||
|
||||
payload = {
|
||||
"protocol": "tcp",
|
||||
"host_port": 18080,
|
||||
"container_port": 80,
|
||||
"description": "web",
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/api/v1/containers/{CONTAINER_ID}/port-mappings",
|
||||
headers={"X-API-Key": API_KEY},
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
## Endpoint List
|
||||
|
||||
### Overview
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard` | Dashboard statistics |
|
||||
| GET | `/api/v1/host-info` | Host resources |
|
||||
| GET | `/api/v1/host-report` | Host inspection report |
|
||||
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing |
|
||||
| PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools |
|
||||
| POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment |
|
||||
| GET | `/api/v1/ipv6/status` | IPv6 status |
|
||||
| GET | `/api/v1/tasks` | Task queue |
|
||||
| DELETE | `/api/v1/tasks/{task_id}` | Delete a task |
|
||||
|
||||
### Containers
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers` | Container list (recommended) |
|
||||
| GET | `/api/v1/containers/list` | Compatible GET form for container list |
|
||||
| POST | `/api/v1/containers/list` | Compatible POST form for container list |
|
||||
| POST | `/api/v1/containers` | Create container |
|
||||
| GET | `/api/v1/containers/{id\|uuid\|name}` | Container details |
|
||||
| POST | `/api/v1/containers/{id}/start` | Start |
|
||||
| POST | `/api/v1/containers/{id}/stop` | Stop |
|
||||
| POST | `/api/v1/containers/{id}/restart` | Restart |
|
||||
| POST | `/api/v1/containers/{id}/reinstall` | Reinstall |
|
||||
| DELETE | `/api/v1/containers/{id}/delete` | Delete |
|
||||
| GET | `/api/v1/containers/{id}/usage` | Resource usage |
|
||||
| GET | `/api/v1/containers/{id}/traffic` | Traffic statistics |
|
||||
| POST | `/api/v1/containers/{id}/traffic-reset` | Reset traffic |
|
||||
| PUT | `/api/v1/containers/{id}/traffic-limit` | Update traffic limits |
|
||||
| PUT | `/api/v1/containers/{id}/resource-limit` | Update resource limits |
|
||||
| PUT | `/api/v1/containers/{id}/expiry` | Update expiration time |
|
||||
| POST | `/api/v1/containers/{id}/reset-password` | Reset SSH password |
|
||||
| POST | `/api/v1/containers/{id}/ipv6` | Assign IPv6 |
|
||||
|
||||
### Ports and Snapshots
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers/{id}/random-port` | Random available port; accepts `host_ip` to check a specific host IP |
|
||||
| POST | `/api/v1/containers/{id}/port-mappings` | Add port mapping |
|
||||
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping |
|
||||
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | Delete port mapping |
|
||||
| GET | `/api/v1/containers/{id}/firewall` | Get container firewall settings |
|
||||
| PUT | `/api/v1/containers/{id}/firewall` | Update container firewall settings |
|
||||
| GET | `/api/v1/snapshots` | Snapshot overview |
|
||||
| GET | `/api/v1/containers/{id}/snapshots` | Container snapshots |
|
||||
| POST | `/api/v1/containers/{id}/snapshots` | Create snapshot |
|
||||
| DELETE | `/api/v1/containers/{id}/snapshots/{snapshot_id}` | Delete snapshot |
|
||||
| POST | `/api/v1/containers/{id}/snapshots/{snapshot_id}/restore` | Restore snapshot |
|
||||
| POST | `/api/v1/containers/{id}/snapshots/schedule` | Schedule snapshots |
|
||||
| PUT | `/api/v1/containers/{id}/snapshots/quota` | Snapshot quota |
|
||||
|
||||
### Platform Management
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/templates` | Template list |
|
||||
| GET | `/api/v1/images` | Image management list |
|
||||
| GET | `/api/v1/images/enabled` | Enabled and downloaded images; supports `type=lxc\|kvm` |
|
||||
| POST | `/api/v1/images/download` | Download image |
|
||||
| POST | `/api/v1/images/cancel` | Cancel image download |
|
||||
| DELETE | `/api/v1/images/delete` | Delete image cache |
|
||||
| PUT | `/api/v1/images/toggle` | Enable or disable image |
|
||||
| GET | `/api/v1/security/alerts` | Security alerts |
|
||||
| POST | `/api/v1/security/check` | Run security check |
|
||||
| GET | `/api/v1/security/logs?container={name}` | Security connection logs |
|
||||
| GET | `/api/v1/security/summary` | Security summary |
|
||||
| GET | `/api/v1/security/settings` | Security settings |
|
||||
| PUT | `/api/v1/security/settings` | Update security settings |
|
||||
| GET | `/api/v1/swap` | Swap information |
|
||||
| POST | `/api/v1/swap` | Adjust Swap |
|
||||
| GET | `/api/v1/language` | Current panel language |
|
||||
| POST/PUT | `/api/v1/language` | Update panel language |
|
||||
| GET | `/api/v1/ssl` | SSL settings (requires admin permission / `admin:access`) |
|
||||
| PUT | `/api/v1/ssl` | Update SSL settings (requires admin permission / `admin:access`) |
|
||||
| GET | `/api/v1/webssh-origins` | WebSSH Origin allowlist (requires admin permission / `admin:access`) |
|
||||
| PUT | `/api/v1/webssh-origins` | Update WebSSH Origin allowlist (requires admin permission / `admin:access`) |
|
||||
| POST | `/api/v1/batch-create` | Batch create containers |
|
||||
| POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall |
|
||||
| POST | `/api/v1/ssh-ticket` | Create WebSSH ticket |
|
||||
| POST | `/api/v1/vnc-ticket` | Create WebVNC ticket |
|
||||
|
||||
### Accounts and Logs
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/v1/sub-user/create` | Create sub-user link |
|
||||
| GET | `/api/v1/sub-users` | Sub-user list |
|
||||
| POST | `/api/v1/sub-users/{id}/rotate-password` | Rotate sub-user password |
|
||||
| GET | `/api/v1/sub-users/{id}/audit-logs` | Sub-user audit logs |
|
||||
| GET | `/api/v1/sub-users/{id}/login-logs` | Sub-user login logs |
|
||||
| GET | `/api/v1/audit-logs` | Audit logs |
|
||||
| GET | `/api/v1/login-logs` | Login logs |
|
||||
| GET | `/api/v1/api-keys` | API key list |
|
||||
| POST | `/api/v1/api-keys` | Create API key |
|
||||
| PATCH | `/api/v1/api-keys/{id}` | Update API key |
|
||||
| DELETE | `/api/v1/api-keys/{id}` | Delete API key |
|
||||
|
||||
## Response Samples
|
||||
|
||||
The samples below are grouped by endpoint path. Resource numbers, task IDs, container IDs, timestamps, IP addresses, and keys will differ in real environments. Passwords, tickets, and API keys are masked.
|
||||
|
||||
### Overview
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/dashboard": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"running": 31,
|
||||
"stopped": 0,
|
||||
"total_containers": 31
|
||||
}
|
||||
},
|
||||
"GET /api/v1/host-info": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"cpu": { "cores": 8, "usage_pct": 1.16 },
|
||||
"ram": { "total_mb": 31825, "used_mb": 1275, "free_mb": 30550 },
|
||||
"disk": { "total_gb": 1750.49, "used_gb": 123.98, "free_gb": 1626.51 },
|
||||
"network": {
|
||||
"public_ipv4": "203.0.113.10",
|
||||
"public_ipv4_interface": "eth0",
|
||||
"public_ipv6": "2001:db8:100::2",
|
||||
"public_ipv6_interface": "eth0"
|
||||
},
|
||||
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/host-report": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"generated_at": "2026-06-12 10:00:00",
|
||||
"summary": { "status": "ok", "warnings": 0 },
|
||||
"host": { "hostname": "node-1", "kernel": "6.8.0" },
|
||||
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
|
||||
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/routing": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"nat4": { "used": 62, "remaining": "45474", "total": "45536" },
|
||||
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||
"ipv6": { "used": 31, "remaining": "large", "total": "large" },
|
||||
"public_ipv4_addresses": [
|
||||
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||
],
|
||||
"ipv4_assignments": [
|
||||
{ "container_id": 5, "container_name": "example-vm", "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||
],
|
||||
"nat4_mappings": [
|
||||
{ "container_id": 5, "container_name": "example-vm", "status": "running", "ip": "10.0.0.10", "host_port": 22004, "container_port": 22, "protocol": "tcp" }
|
||||
],
|
||||
"ipv6_assignments": [
|
||||
{ "container_id": 5, "container_name": "example-vm", "address": "2001:db8:100::1005", "prefix_len": 64, "interface": "eth0" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/routing": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||
"public_ipv4_addresses": [
|
||||
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||
],
|
||||
"ipv6_prefixes": [
|
||||
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"POST /api/v1/routing/ipv4-scan": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1", "status": "available", "usable": true, "reason": "" }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/ipv6/status": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"available": true,
|
||||
"reachable": true,
|
||||
"reason": "usable public IPv6 prefix detected",
|
||||
"prefixes": [
|
||||
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"GET /api/v1/tasks": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"DELETE /api/v1/tasks/{task_id}": {
|
||||
"success": true,
|
||||
"message": "Task deleted"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Containers
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/containers": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 5,
|
||||
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "example-vm",
|
||||
"virtualization": "lxc",
|
||||
"template": "debian-bullseye",
|
||||
"vcpu": 1,
|
||||
"ram_mb": 512,
|
||||
"disk_gb": 10,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80,
|
||||
"status": "running",
|
||||
"ip": "10.0.0.10",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
"ssh_port": 22004,
|
||||
"ssh_password": "***",
|
||||
"port_mappings": [
|
||||
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||
{ "container_port": 20000, "host_port": 20000, "protocol": "tcp", "description": "Port-20000" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"GET /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/containers": {
|
||||
"success": true,
|
||||
"message": "Container created successfully"
|
||||
},
|
||||
"GET /api/v1/containers/{id|uuid|name}": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 5,
|
||||
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "example-vm",
|
||||
"status": "running",
|
||||
"ip": "10.0.0.10",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
"ssh_port": 22004,
|
||||
"ssh_password": "***",
|
||||
"policy_blocked": false
|
||||
}
|
||||
},
|
||||
"POST /api/v1/containers/{id}/start": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "start" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/stop": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "stop" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/restart": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "restart" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/reinstall": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "reinstall" }
|
||||
},
|
||||
"DELETE /api/v1/containers/{id}/delete": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "delete" }
|
||||
},
|
||||
"GET /api/v1/containers/{id}/usage": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"cpu_usage_pct": 0,
|
||||
"cpu_usage_usec": 3908852,
|
||||
"memory_usage_bytes": 29331456,
|
||||
"disk_usage_bytes": 515100672,
|
||||
"network_rx_bytes": 131232,
|
||||
"network_tx_bytes": 16828,
|
||||
"load1": 0.1,
|
||||
"load5": 0.06,
|
||||
"load15": 0.01
|
||||
}
|
||||
},
|
||||
"GET /api/v1/containers/{id}/traffic": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"mode": "total",
|
||||
"limit_gb": 1024,
|
||||
"in_limit_gb": 0,
|
||||
"out_limit_gb": 0,
|
||||
"total_used_bytes": 142082,
|
||||
"rx_used_bytes": 127212,
|
||||
"tx_used_bytes": 14870,
|
||||
"used_pct": 0,
|
||||
"reset_date": "2026-06"
|
||||
}
|
||||
},
|
||||
"POST /api/v1/containers/{id}/traffic-reset": {
|
||||
"success": true,
|
||||
"message": "Traffic reset"
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/traffic-limit": {
|
||||
"success": true,
|
||||
"message": "Traffic limit updated"
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/resource-limit": {
|
||||
"success": true,
|
||||
"message": "Resource limits updated"
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/expiry": {
|
||||
"success": true,
|
||||
"message": "Expiry updated"
|
||||
},
|
||||
"POST /api/v1/containers/{id}/reset-password": {
|
||||
"success": true,
|
||||
"message": "SSH password reset successfully",
|
||||
"data": { "password": "***" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/ipv6": {
|
||||
"success": true,
|
||||
"message": "IPv6 assigned",
|
||||
"data": { "id": 5, "name": "example-vm", "ipv6": "2001:db8:100::1005" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ports and Snapshots
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
|
||||
"success": true,
|
||||
"data": { "port": 61320 }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/port-mappings": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||
{ "container_port": 8080, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||
]
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/port-mappings/{index}": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "container_port": 8081, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||
]
|
||||
},
|
||||
"DELETE /api/v1/containers/{id}/port-mappings/{index}": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"message": "Firewall updated",
|
||||
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
|
||||
},
|
||||
"GET /api/v1/snapshots": {
|
||||
"success": true,
|
||||
"data": null
|
||||
},
|
||||
"GET /api/v1/containers/{id}/snapshots": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"quota": 1,
|
||||
"schedule": { "enabled": false, "interval_hours": 0, "last_run": "", "next_run": "", "time": "", "created_by": "" },
|
||||
"snapshots": []
|
||||
}
|
||||
},
|
||||
"POST /api/v1/containers/{id}/snapshots": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "snap-20260608-001",
|
||||
"container_id": 5,
|
||||
"container_name": "example-vm",
|
||||
"created_at": "2026-06-08 16:00:00",
|
||||
"created_by": "api:Automation",
|
||||
"scheduled": false,
|
||||
"size_bytes": 10485760
|
||||
}
|
||||
},
|
||||
"DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}": {
|
||||
"success": true,
|
||||
"message": "Snapshot deleted"
|
||||
},
|
||||
"POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore": {
|
||||
"success": true,
|
||||
"message": "Snapshot restored"
|
||||
},
|
||||
"POST /api/v1/containers/{id}/snapshots/schedule": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"container": { "id": 5, "name": "example-vm", "snapshot_schedule_enabled": true, "snapshot_schedule_interval_hours": 24, "snapshot_schedule_time": "03:00" }
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/snapshots/quota": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"quota": 2,
|
||||
"container": { "id": 5, "name": "example-vm", "snapshot_limit": 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Platform Management
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/templates": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "description": "Ubuntu 24.04 LTS" },
|
||||
{ "id": "debian-bookworm", "name": "Debian 12", "distro": "debian", "release": "bookworm", "arch": "amd64", "description": "Debian 12 (Bookworm)" }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/images": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/images/enabled?type=lxc": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/images/download": {
|
||||
"success": true,
|
||||
"message": "Already downloaded"
|
||||
},
|
||||
"POST /api/v1/images/cancel": {
|
||||
"success": true,
|
||||
"message": "Cancel requested"
|
||||
},
|
||||
"DELETE /api/v1/images/delete": {
|
||||
"success": true,
|
||||
"message": "Deleted"
|
||||
},
|
||||
"PUT /api/v1/images/toggle": {
|
||||
"success": true,
|
||||
"message": "OK"
|
||||
},
|
||||
"GET /api/v1/security/alerts": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"POST /api/v1/security/check": {
|
||||
"success": true,
|
||||
"message": "Security check completed"
|
||||
},
|
||||
"GET /api/v1/security/logs?container={name}": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/security/summary": {
|
||||
"success": true,
|
||||
"data": { "critical": 0, "high": 0, "medium": 0, "low": 0, "total_alerts": 0 }
|
||||
},
|
||||
"GET /api/v1/security/settings": {
|
||||
"success": true,
|
||||
"data": { "auto_shutdown": false }
|
||||
},
|
||||
"PUT /api/v1/security/settings": {
|
||||
"success": true,
|
||||
"data": { "auto_shutdown": false }
|
||||
},
|
||||
"GET /api/v1/swap": {
|
||||
"success": true,
|
||||
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||
},
|
||||
"POST /api/v1/swap": {
|
||||
"success": true,
|
||||
"message": "SWAP adjusted to 16384 MB",
|
||||
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||
},
|
||||
"GET /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "zh" }
|
||||
},
|
||||
"PUT /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "en" }
|
||||
},
|
||||
"GET /api/v1/ssl": {
|
||||
"success": true,
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
|
||||
},
|
||||
"PUT /api/v1/ssl": {
|
||||
"success": true,
|
||||
"message": "SSL settings saved",
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
|
||||
},
|
||||
"GET /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"PUT /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"message": "Origin allowlist saved",
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"POST /api/v1/batch-create": {
|
||||
"success": true,
|
||||
"data": ["task-12"]
|
||||
},
|
||||
"POST /api/v1/batch-action": {
|
||||
"success": true,
|
||||
"data": ["task-13"]
|
||||
},
|
||||
"POST /api/v1/ssh-ticket": {
|
||||
"success": true,
|
||||
"data": { "ticket": "***60 seconds valid***" }
|
||||
},
|
||||
"POST /api/v1/vnc-ticket": {
|
||||
"success": true,
|
||||
"data": { "ticket": "***60 seconds valid***" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accounts and Logs
|
||||
|
||||
```json
|
||||
{
|
||||
"POST /api/v1/sub-user/create": {
|
||||
"success": true,
|
||||
"message": "Sub-user created",
|
||||
"data": {
|
||||
"id": "sub-xxxxxxxx",
|
||||
"username": "user-xxxxxxxx",
|
||||
"password": "***",
|
||||
"container_names": ["example-vm"],
|
||||
"access_code": "********",
|
||||
"created_at": "2026-06-08 16:00:00"
|
||||
}
|
||||
},
|
||||
"GET /api/v1/sub-users": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"POST /api/v1/sub-users/{id}/rotate-password": {
|
||||
"success": true,
|
||||
"data": { "username": "user-xxxxxxxx", "password": "***", "access_code": "********" }
|
||||
},
|
||||
"GET /api/v1/sub-users/{id}/audit-logs": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/sub-users/{id}/login-logs": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/audit-logs": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "time": "2026-06-08 15:44:40", "action": "apikey.create", "target": "Test", "detail": "scopes=*", "user": "admin", "success": true }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/login-logs": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "time": "2026-06-08 08:24:00 UTC", "username": "admin", "ip": "198.51.100.23", "user_agent": "Mozilla/5.0 ...", "success": true }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"message": "API key created. Save this key now - it won't be shown again.",
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"PATCH /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"DELETE /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
"message": "API key deleted"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# Container Management
|
||||
|
||||
Container Management is the core CLICD module. It covers creation, lifecycle operations, resource limits, network mappings, traffic statistics, password resets, and console access.
|
||||
|
||||
## Container List
|
||||
|
||||
The list page scans container status. Administrators can view all containers. Sub-users only see containers within their authorization scope.
|
||||
|
||||
Common fields include:
|
||||
|
||||
- ID, UUID, and name.
|
||||
- Virtualization type.
|
||||
- Runtime status.
|
||||
- IP and IPv6.
|
||||
- CPU, memory, and disk limits.
|
||||
- Traffic usage and traffic limits.
|
||||
- Expiration time.
|
||||
|
||||
## Create Containers
|
||||
|
||||
Creation requires a template and resource quotas. Batch creation is available from the panel or API and is useful for issuing multiple containers at once.
|
||||
|
||||
```http
|
||||
POST /api/v1/containers
|
||||
POST /api/v1/batch-create
|
||||
```
|
||||
|
||||
Linux containers and Linux KVM virtual machines support SSH login configuration during creation:
|
||||
|
||||
- `auto_password`: generate a root SSH password automatically.
|
||||
- `password`: use a custom `ssh_password`.
|
||||
- `key`: write a one-line `ssh_public_key`; a password is still kept for WebSSH.
|
||||
|
||||
Network allocation can combine NAT, public IPv4, and IPv6 as needed. API fields such as `assign_nat`, `assign_ipv4`, `public_ipv4s`, `assign_ipv6`, and `ipv6_addresses` are optional. If they are omitted, default behavior is preserved.
|
||||
|
||||
## Lifecycle Operations
|
||||
|
||||
```http
|
||||
POST /api/v1/containers/{id}/start
|
||||
POST /api/v1/containers/{id}/stop
|
||||
POST /api/v1/containers/{id}/restart
|
||||
POST /api/v1/containers/{id}/reinstall
|
||||
DELETE /api/v1/containers/{id}/delete
|
||||
```
|
||||
|
||||
Start, stop, reinstall, and delete actions enter the task queue. Call `GET /api/v1/tasks` afterwards to check execution status.
|
||||
|
||||
When reinstalling a Linux system, you may pass `ssh_auth_mode`, `ssh_password`, and `ssh_public_key`. `ssh_auth_mode=keep` keeps the current SSH password. If these fields are omitted, the old behavior is preserved.
|
||||
|
||||
## Resources and Traffic
|
||||
|
||||
The container details page supports resource usage, traffic limit changes, resource limit changes, and expiration changes.
|
||||
|
||||
```http
|
||||
GET /api/v1/containers/{id}/usage
|
||||
GET /api/v1/containers/{id}/traffic
|
||||
POST /api/v1/containers/{id}/traffic-reset
|
||||
PUT /api/v1/containers/{id}/traffic-limit
|
||||
PUT /api/v1/containers/{id}/resource-limit
|
||||
PUT /api/v1/containers/{id}/expiry
|
||||
```
|
||||
|
||||
## NAT Port Management
|
||||
|
||||
The NAT port management section supports adding, editing, and deleting mappings. Add and edit actions use a dialog so name, protocol, external port, and internal port can be filled in together.
|
||||
|
||||
```http
|
||||
GET /api/v1/containers/{id}/random-port
|
||||
POST /api/v1/containers/{id}/port-mappings
|
||||
PUT /api/v1/containers/{id}/port-mappings/{index}
|
||||
DELETE /api/v1/containers/{id}/port-mappings/{index}
|
||||
```
|
||||
|
||||
In sub-user mode, administrators can limit sub-users to changing only the internal port, preventing changes to the host-facing port and protocol.
|
||||
|
||||
## Remote Console
|
||||
|
||||
```http
|
||||
POST /api/v1/ssh-ticket
|
||||
POST /api/v1/vnc-ticket
|
||||
```
|
||||
|
||||
Tickets are short-lived. Use them immediately for WebSSH or WebVNC and do not persist them.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Dashboard
|
||||
|
||||
The dashboard shows the overall state of the host and virtualization resources.
|
||||
|
||||
## Metrics
|
||||
|
||||
- Total containers, running containers, and stopped containers.
|
||||
- CPU, memory, disk, and Swap overview.
|
||||
- Entry points for host network and routing status.
|
||||
- Task queue status.
|
||||
- Security alert summary.
|
||||
|
||||
## Related APIs
|
||||
|
||||
```http
|
||||
GET /api/v1/dashboard
|
||||
GET /api/v1/host-info
|
||||
GET /api/v1/routing
|
||||
GET /api/v1/ipv6/status
|
||||
GET /api/v1/tasks
|
||||
```
|
||||
|
||||
API requests must include an API key:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# Host Report
|
||||
|
||||
The host report summarizes the host runtime environment, resource status, and virtualization dependencies. It is useful for post-installation checks, troubleshooting, or sharing environment information with maintainers.
|
||||
|
||||
## Contents
|
||||
|
||||
- System version and kernel information.
|
||||
- CPU, memory, disk, and Swap.
|
||||
- Network status.
|
||||
- LXC/KVM dependency status.
|
||||
- CLICD service status.
|
||||
|
||||
## Related APIs
|
||||
|
||||
```http
|
||||
GET /api/v1/host-report
|
||||
GET /api/v1/host-info
|
||||
GET /api/v1/swap
|
||||
```
|
||||
|
||||
Before sending a report externally, check whether it contains public IPs, private networks, usernames, keys, tickets, or business domains.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Image Management
|
||||
|
||||
Image Management maintains templates used to create containers or virtual machines.
|
||||
|
||||
## Supported Template Types
|
||||
|
||||
The project includes common Linux distribution templates such as Debian, Ubuntu, Alpine, CentOS, Fedora, Arch Linux, and Rocky Linux. KVM templates use the corresponding distribution cloud image resources.
|
||||
|
||||
## Management Actions
|
||||
|
||||
```http
|
||||
GET /api/v1/templates
|
||||
GET /api/v1/images
|
||||
POST /api/v1/images/download
|
||||
POST /api/v1/images/cancel
|
||||
DELETE /api/v1/images/delete
|
||||
PUT /api/v1/images/toggle
|
||||
```
|
||||
|
||||
- `templates` returns available template definitions.
|
||||
- `images` returns local image status.
|
||||
- `download` downloads a specific template.
|
||||
- `cancel` cancels a download task.
|
||||
- `delete` removes the local image cache.
|
||||
- `toggle` controls whether a template can be used during creation.
|
||||
|
||||
## Windows Images
|
||||
|
||||
This project does not distribute Windows system images and does not provide features to bypass or avoid Windows activation. Windows download links should point to official Microsoft resources, and users must obtain valid licenses themselves.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Networking and Routing
|
||||
|
||||
CLICD provides NAT4 port mapping, random available ports, public IPv4 assignment, IPv6 status checks, and IPv6 assignment. During container creation, you can use NAT only, public IPv4 only, IPv6 only, or a mixed network setup.
|
||||
|
||||
## NAT4
|
||||
|
||||
NAT4 forwards host ports to container internal ports. Common uses include:
|
||||
|
||||
- Forwarding SSH.
|
||||
- Exposing web services.
|
||||
- Assigning fixed external ports to sub-users.
|
||||
|
||||
Port mappings include:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| Name | A purpose label such as `ssh` or `web`. |
|
||||
| Protocol | `tcp` or `udp`. |
|
||||
| External port | The host port exposed to the outside. |
|
||||
| Internal port | The service port inside the container. |
|
||||
|
||||
## IPv6
|
||||
|
||||
IPv6 assignment requires the host to have a routable IPv6 prefix, plus correct routing, neighbor discovery, or proxy configuration.
|
||||
|
||||
```http
|
||||
GET /api/v1/ipv6/status
|
||||
POST /api/v1/containers/{id}/ipv6
|
||||
```
|
||||
|
||||
If the host has no public IPv6 or the upstream network is not routing the prefix correctly, assigned addresses will not be reachable from the public internet.
|
||||
|
||||
## Public IPv4
|
||||
|
||||
Public IPv4 assignment selects from public IPv4 addresses detected on the host, or from `public_ipv4s` specified through the API. Creation fields include:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `assign_nat` | Whether to enable NAT port mappings. |
|
||||
| `assign_ipv4` | Whether to assign public IPv4. |
|
||||
| `ipv4_count` | Number of public IPv4 addresses to allocate automatically. |
|
||||
| `public_ipv4s` | Explicit public IPv4 address list. |
|
||||
| `assign_ipv6` | Whether to assign IPv6. |
|
||||
| `ipv6_count` | Number of IPv6 addresses to allocate automatically. |
|
||||
| `ipv6_addresses` | Explicit IPv6 address list. |
|
||||
|
||||
Public address pool APIs:
|
||||
|
||||
```http
|
||||
GET /api/v1/routing
|
||||
PUT /api/v1/routing
|
||||
POST /api/v1/routing/ipv4-scan
|
||||
```
|
||||
|
||||
## Routing Status
|
||||
|
||||
```http
|
||||
GET /api/v1/routing
|
||||
```
|
||||
|
||||
This endpoint shows runtime status for NAT, IPv4, IPv6, and port capacity.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Security Alerts
|
||||
|
||||
CLICD includes lightweight security alerts based on connection behavior. It does not keep full normal connection logs; it focuses on abnormal behavior and high-risk patterns.
|
||||
|
||||
## Covered Scenarios
|
||||
|
||||
- Port scanning.
|
||||
- Lateral scanning.
|
||||
- Brute-force tendencies.
|
||||
- SMTP abuse.
|
||||
- UDP reflection risk.
|
||||
- Suspicious ports related to mining, proxies, VPNs, Tor, and similar services.
|
||||
|
||||
## APIs
|
||||
|
||||
```http
|
||||
GET /api/v1/security/alerts
|
||||
POST /api/v1/security/check
|
||||
GET /api/v1/security/logs?container={name}
|
||||
GET /api/v1/security/summary
|
||||
GET /api/v1/security/settings
|
||||
PUT /api/v1/security/settings
|
||||
```
|
||||
|
||||
## Automatic Shutdown
|
||||
|
||||
Security settings can enable automatic shutdown after alerts. Before enabling it, observe for a while and make sure the rules do not affect normal services.
|
||||
|
||||
## Logging Advice
|
||||
|
||||
Security alerts are risk signals. They should not replace a professional firewall, intrusion detection, or centralized logging system. For public services, still combine them with security groups, firewall rules, Fail2ban, and similar tools.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Snapshot Management
|
||||
|
||||
Snapshots save the current state of a container so it can be rolled back before upgrades, configuration changes, or delivery.
|
||||
|
||||
## Global Overview
|
||||
|
||||
```http
|
||||
GET /api/v1/snapshots
|
||||
```
|
||||
|
||||
Use this endpoint to view snapshot summaries for all containers.
|
||||
|
||||
## Container Snapshots
|
||||
|
||||
```http
|
||||
GET /api/v1/containers/{id}/snapshots
|
||||
POST /api/v1/containers/{id}/snapshots
|
||||
DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}
|
||||
POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore
|
||||
```
|
||||
|
||||
Restoring a snapshot changes container state. In production, confirm that the current workload can be interrupted first.
|
||||
|
||||
## Scheduled Snapshots and Quotas
|
||||
|
||||
```http
|
||||
POST /api/v1/containers/{id}/snapshots/schedule
|
||||
PUT /api/v1/containers/{id}/snapshots/quota
|
||||
```
|
||||
|
||||
Scheduled snapshots are useful for long-running containers. Quotas prevent snapshots from growing without limit and filling the host disk.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Sub-users
|
||||
|
||||
Sub-users let administrators grant specific container access to other users. They are useful for temporary delivery, shared-host allocation, teaching labs, or multi-user host scenarios.
|
||||
|
||||
## Create an Access Link
|
||||
|
||||
After selecting a container, the administrator can create a sub-user link:
|
||||
|
||||
```http
|
||||
POST /api/v1/sub-user/create
|
||||
```
|
||||
|
||||
The response may include a username, initial password, access code, or access link. When sharing externally, mask sensitive values and send the real values only to the intended user.
|
||||
|
||||
## Manage Sub-users
|
||||
|
||||
```http
|
||||
GET /api/v1/sub-users
|
||||
POST /api/v1/sub-users/{id}/rotate-password
|
||||
GET /api/v1/sub-users/{id}/audit-logs
|
||||
GET /api/v1/sub-users/{id}/login-logs
|
||||
```
|
||||
|
||||
Rotating the password invalidates old credentials. Audit logs and login logs help investigate mistakes or abnormal access.
|
||||
|
||||
## Permission Scope
|
||||
|
||||
Sub-users can only manage authorized containers. Global configuration, image management, security policy, API keys, and other administrator features are not exposed to sub-users.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Configuration
|
||||
|
||||
After installation, CLICD runs as a systemd service. Runtime configuration and the database are stored locally on the host. The exact path may vary with installer options, but the default installation should mainly be checked under `/root/.clicd/`.
|
||||
|
||||
## Common Settings
|
||||
|
||||
| Setting | Description |
|
||||
| --- | --- |
|
||||
| Web port | Defaults to `8999`, listening on `0.0.0.0:8999`. |
|
||||
| Administrator account | Used to log in to the web panel and manage API keys. |
|
||||
| Database | SQLite storage for container metadata, sub-users, audit logs, API keys, and more. |
|
||||
| NAT port range | Used for random ports and port mapping allocation. |
|
||||
| IPv6 prefixes | Used when the host has routable IPv6 prefixes. |
|
||||
| Security alerts | Policies such as automatic shutdown can be configured. |
|
||||
|
||||
## Service Commands
|
||||
|
||||
```bash
|
||||
systemctl status clicd
|
||||
systemctl restart clicd
|
||||
journalctl -u clicd -n 100 --no-pager
|
||||
```
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
- Do not expose the web panel directly to untrusted networks.
|
||||
- Use a strong administrator password and rotate it regularly.
|
||||
- Split API keys by purpose and avoid long-lived full-access keys.
|
||||
- WebSSH and WebVNC tickets are short-lived credentials and should not be written to logs or shared publicly.
|
||||
- Do not paste real IPs, passwords, API keys, or tickets into public docs, screenshots, or support tickets.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Installation
|
||||
|
||||
CLICD provides a one-line installer. By default, it installs the latest version from GitHub Releases. You can also pin a specific version with an environment variable.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Linux x86_64/amd64 or ARM64/aarch64 host.
|
||||
- Root privileges.
|
||||
- systemd.
|
||||
- Network access to GitHub Release downloads.
|
||||
- LXC runtime support if you want to use LXC.
|
||||
- KVM virtualization enabled with libvirt/QEMU installed if you want to use KVM.
|
||||
|
||||
## Install the Latest Version
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture.
|
||||
|
||||
## Install a Specific Version
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||
```
|
||||
|
||||
Replace `v1.1.6` with the release tag you want to install.
|
||||
|
||||
## Open the Panel
|
||||
|
||||
After installation, open:
|
||||
|
||||
```text
|
||||
http://YOUR_SERVER_IP:8999
|
||||
```
|
||||
|
||||
Use the administrator credentials printed by the installer for the first login. In production, restrict access at the firewall or reverse proxy layer and change the default username and password as soon as possible.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
```
|
||||
|
||||
Before uninstalling, decide whether you need to keep containers, image cache, database files, or configuration files.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Introduction
|
||||
|
||||
CLICD is a lightweight virtualization management panel for LXC and KVM. It brings common host operations into a web console and CLI, making it suitable for small VPS nodes, dedicated servers, and scenarios where container access needs to be distributed in batches.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Manage LXC containers and KVM virtual machines.
|
||||
- Create, start, stop, restart, reinstall, and delete containers.
|
||||
- Configure CPU, memory, disk, traffic limits, and expiration time.
|
||||
- Manage NAT4 port mappings, public IPv4 assignment, and public IPv6 assignment when the host network supports it.
|
||||
- Open WebSSH or WebVNC from the browser.
|
||||
- Manage image downloads, enablement, and local cache.
|
||||
- Create, restore, and delete snapshots, plus scheduled snapshots and quotas.
|
||||
- Generate security alerts based on connection behavior and keep audit logs.
|
||||
- Create sub-user access links for specific containers.
|
||||
- Integrate automation through API keys and `/api/v1`.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Quickly allocate multiple Linux containers on one host.
|
||||
- Give users temporary access to a container console, SSH, VNC, or NAT port management.
|
||||
- Automate container creation, resource changes, password resets, or resource cleanup through the API.
|
||||
- Use a panel that is clearer than pure CLI workflows without becoming a heavy platform.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
|
||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
|
||||
- Release: GitHub Actions builds Linux AMD64/ARM64 release artifacts. The installer fetches the latest release by default.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Quick Start
|
||||
|
||||
This is a common path from a fresh installation to your first container.
|
||||
|
||||
## 1. Log In
|
||||
|
||||
Open `http://YOUR_SERVER_IP:8999` and sign in with the administrator account.
|
||||
|
||||
After entering the panel, check:
|
||||
|
||||
- Whether the dashboard shows host resources.
|
||||
- Whether Image Management can list templates.
|
||||
- Whether NAT and IPv6 status in Routing match your host network.
|
||||
|
||||
## 2. Download an Image
|
||||
|
||||
Open Image Management, choose a template, and download it. On small hosts, lightweight images such as Alpine or Debian are a good first choice.
|
||||
|
||||
Image downloads run asynchronously. You can watch progress in the task queue.
|
||||
|
||||
## 3. Create a Container
|
||||
|
||||
Open Container Management and click Create:
|
||||
|
||||
- Select virtualization type and template.
|
||||
- Set CPU, memory, and disk.
|
||||
- Set traffic limits and expiration time.
|
||||
- If external access is required, add NAT port mappings or assign IPv6 from the container details page after creation.
|
||||
|
||||
## 4. Open a Terminal
|
||||
|
||||
After the container is created, open WebSSH from the details page. KVM virtual machines can use WebVNC for console access.
|
||||
|
||||
## 5. Share with a Sub-user
|
||||
|
||||
If another user needs to manage a container, create an access link in Sub-user Management. The sub-user only sees authorized containers and is limited by the operation scope configured by the administrator.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Upgrade
|
||||
|
||||
The CLICD installer and CLI are built around GitHub Release artifacts. Before upgrading, check the current version and back up configuration and database files.
|
||||
|
||||
## Check the Version
|
||||
|
||||
The current version is shown at the bottom of the web panel sidebar. You can also run:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8999/api/version
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"version": "1.1.6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Upgrade with the Installer
|
||||
|
||||
The installer uses the latest release by default:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
Install a specific version:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||
```
|
||||
|
||||
## Pre-upgrade Checklist
|
||||
|
||||
- Back up `/root/.clicd/` or the actual configuration directory.
|
||||
- Make sure no critical tasks are currently running.
|
||||
- If an image download or snapshot restore is running, wait for it to finish first.
|
||||
- After upgrading, check `systemctl status clicd` and the version shown in the web panel.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: CLICD
|
||||
text: Lightweight LXC/KVM Virtualization Panel
|
||||
tagline: Web console, CLI, container orchestration, NAT/IPv4/IPv6 routing, snapshots, security alerts, sub-users, and API automation.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Install
|
||||
link: /en/guide/installation
|
||||
- theme: alt
|
||||
text: API Reference
|
||||
link: /en/features/api
|
||||
|
||||
features:
|
||||
- title: Built for Small Hosts
|
||||
details: Manage LXC containers and KVM virtual machines on a single VPS or dedicated server.
|
||||
- title: Web and CLI Together
|
||||
details: Use the web panel for daily operations, or drop into the clicd CLI for maintenance tasks.
|
||||
- title: Automation Friendly
|
||||
details: /api/v1 exposes containers, images, snapshots, security, logs, sub-users, and API key management.
|
||||
---
|
||||
@@ -0,0 +1,46 @@
|
||||
# Deployment
|
||||
|
||||
CLICD can run directly on the host or behind a reverse proxy. In production, set up access control before exposing it to administrators.
|
||||
|
||||
## Service Exposure
|
||||
|
||||
The default web port is `8999`:
|
||||
|
||||
```text
|
||||
http://YOUR_SERVER_IP:8999
|
||||
```
|
||||
|
||||
Recommendations:
|
||||
|
||||
- Allow only fixed administrator IPs.
|
||||
- Use a reverse proxy with HTTPS.
|
||||
- Do not expose the real login URL in public docs or screenshots.
|
||||
|
||||
## systemd
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
systemctl status clicd
|
||||
systemctl restart clicd
|
||||
systemctl enable clicd
|
||||
journalctl -u clicd -f
|
||||
```
|
||||
|
||||
## Firewall
|
||||
|
||||
At minimum, confirm:
|
||||
|
||||
- The panel port is open only to trusted sources.
|
||||
- NAT mapped ports are opened only as needed.
|
||||
- The SSH management port does not conflict with container mappings.
|
||||
- IPv6 firewall rules are planned together with IPv4 rules.
|
||||
|
||||
## Backups
|
||||
|
||||
Back up regularly:
|
||||
|
||||
- CLICD configuration directory.
|
||||
- SQLite database.
|
||||
- Container configuration.
|
||||
- Snapshots or external data backups for important containers.
|
||||
@@ -0,0 +1,29 @@
|
||||
# FAQ
|
||||
|
||||
## Which version does the installer install by default?
|
||||
|
||||
It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 or ARM64 artifact from `releases/latest` according to the host architecture.
|
||||
|
||||
## Can I pin a specific version?
|
||||
|
||||
Yes:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
|
||||
```
|
||||
|
||||
## Can sub-users see every container?
|
||||
|
||||
No. Sub-users only see containers authorized by the administrator.
|
||||
|
||||
## Is an API key the same as the login password?
|
||||
|
||||
No. API keys are created on the API Integration page for programmatic access. The login password is used for the web panel.
|
||||
|
||||
## What happens after a container reaches its traffic limit?
|
||||
|
||||
The container is automatically shut down to avoid further overage. The administrator can adjust the limit or reset traffic usage.
|
||||
|
||||
## Why is IPv6 unreachable after assignment?
|
||||
|
||||
IPv6 reachability depends on the host and upstream network. Confirm that the host has a routable IPv6 prefix and that routing, firewall, neighbor discovery, or proxy configuration is correct.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Service Not Reachable
|
||||
|
||||
Check service status:
|
||||
|
||||
```bash
|
||||
systemctl status clicd
|
||||
journalctl -u clicd -n 100 --no-pager
|
||||
```
|
||||
|
||||
Check port listening:
|
||||
|
||||
```bash
|
||||
ss -lntp | grep 8999
|
||||
```
|
||||
|
||||
If a reverse proxy is used, check proxy logs and upstream address settings as well.
|
||||
|
||||
## Image Download Failed
|
||||
|
||||
- Make sure the host can access image sources and GitHub Releases.
|
||||
- Check disk space.
|
||||
- Review the failure reason in the task queue.
|
||||
- If a download is stuck, cancel it and start again.
|
||||
|
||||
## Container Cannot Access the Network
|
||||
|
||||
- Check host NAT and forwarding rules.
|
||||
- Confirm that the container IP was assigned successfully.
|
||||
- Check whether the firewall is blocking forwarded traffic.
|
||||
- For IPv6, confirm that the upstream network routes the prefix to the host.
|
||||
|
||||
## WebSSH or WebVNC Connection Failed
|
||||
|
||||
- Confirm that the container or virtual machine is running.
|
||||
- WebSSH requires SSH service inside the container.
|
||||
- WebVNC requires the KVM console to be reachable.
|
||||
- Tickets expire quickly. Create a new ticket after expiration.
|
||||
|
||||
## API Returns Unauthorized
|
||||
|
||||
- Confirm that the API key is not disabled.
|
||||
- Use `X-API-Key` or `Authorization: Bearer`.
|
||||
- Confirm that the key scope covers the target endpoint.
|
||||
- Do not use the panel login password as an API key.
|
||||
+785
-63
@@ -1,6 +1,6 @@
|
||||
# API 集成
|
||||
|
||||
CLICD 对外推荐使用 `/api/v1` 接口。旧版未带版本号的接口主要用于 Web 面板和兼容场景,新接入请优先使用 `/api/v1`。
|
||||
CLICD 继续兼容旧版 `/api` 接口,已有对接无需修改。新接入推荐使用 `/api/v1` 接口,下面的清单均为 v1;容器列表推荐 `GET /api/v1/containers`。
|
||||
|
||||
## 认证
|
||||
|
||||
@@ -14,8 +14,187 @@ curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/containers
|
||||
curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
|
||||
```
|
||||
|
||||
## 响应结构
|
||||
|
||||
所有接口保持统一响应包裹:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OK",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
对接时建议只读取业务所需字段。新增能力会优先追加可选字段,不会要求已有插件改掉现有字段名。
|
||||
|
||||
## 创建与重装
|
||||
|
||||
创建容器、批量创建、重装和批量重装已支持 NAT、公网 IPv4、IPv6 混合网络,以及 Linux SSH 登录方式配置。公网 IPv4/IPv6 地址池可通过 `GET /api/v1/routing` 查看,并可通过 `PUT /api/v1/routing` 更新。
|
||||
|
||||
创建容器示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "demo-lxc-01",
|
||||
"virtualization": "lxc",
|
||||
"template_id": "debian-bookworm",
|
||||
"vcpu": 1,
|
||||
"ram_mb": 512,
|
||||
"disk_gb": 10,
|
||||
"assign_nat": true,
|
||||
"port_mapping_count": 2,
|
||||
"assign_ipv4": false,
|
||||
"ipv4_count": 1,
|
||||
"public_ipv4s": [],
|
||||
"assign_ipv6": true,
|
||||
"ipv6_count": 1,
|
||||
"ipv6_addresses": [],
|
||||
"ssh_auth_mode": "auto_password",
|
||||
"ssh_password": "",
|
||||
"ssh_public_key": "",
|
||||
"expires_at": "",
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `assign_nat` | 是否分配 NAT 端口映射;不传时保持默认 NAT 行为。 |
|
||||
| `assign_ipv4` | 是否分配公网 IPv4。 |
|
||||
| `ipv4_count` | 自动分配公网 IPv4 数量。 |
|
||||
| `public_ipv4s` | 指定公网 IPv4 地址列表。 |
|
||||
| `assign_ipv6` | 是否分配 IPv6。 |
|
||||
| `ipv6_count` | 自动分配 IPv6 数量。 |
|
||||
| `ipv6_addresses` | 指定 IPv6 地址列表。 |
|
||||
| `ssh_auth_mode` | Linux 创建支持 `auto_password`、`password`、`key`;重装额外支持 `keep`。 |
|
||||
| `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 |
|
||||
| `ssh_public_key` | `key` 模式下的一行 SSH 公钥。 |
|
||||
| `network_down_mbps` | 可选;容器下行/下载带宽限制,单位 Mbps,`0` 表示不限制。 |
|
||||
| `network_up_mbps` | 可选;容器上行/上传带宽限制,单位 Mbps,`0` 表示不限制。 |
|
||||
| `io_read_mbps` | 可选;磁盘读取限速,单位 MB/s,`0` 表示不限制。 |
|
||||
| `io_write_mbps` | 可选;磁盘写入限速,单位 MB/s,`0` 表示不限制。 |
|
||||
| `network_bw_mbps` | 兼容旧字段;同时设置上下行对称带宽,新接入推荐使用拆分字段。 |
|
||||
| `io_speed_mbps` | 兼容旧字段;同时设置读写对称 IO 限速,新接入推荐使用拆分字段。 |
|
||||
|
||||
重装示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"template_id": "debian-bookworm",
|
||||
"ssh_auth_mode": "keep",
|
||||
"ssh_password": "",
|
||||
"ssh_public_key": ""
|
||||
}
|
||||
```
|
||||
|
||||
`keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。
|
||||
|
||||
## 资源限制与流量限制
|
||||
|
||||
`PUT /api/v1/containers/{id}/resource-limit` 支持按字段局部更新;未传的字段保持不变。
|
||||
|
||||
```json
|
||||
{
|
||||
"vcpu": 2,
|
||||
"ram_mb": 1024,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
旧版 `network_bw_mbps` 和 `io_speed_mbps` 仍可用,分别表示上下行对称带宽和读写对称 IO 限速。新接入建议使用拆分字段,以便分别控制下载/上传和读取/写入。
|
||||
|
||||
`PUT /api/v1/containers/{id}/traffic-limit` 请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"traffic_mode": "total",
|
||||
"monthly_traffic_gb": 1024,
|
||||
"traffic_in_gb": 0,
|
||||
"traffic_out_gb": 0
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `traffic_mode` | 流量限制模式;常用 `total` 表示总量限制,`split` 表示入站/出站分别限制。 |
|
||||
| `monthly_traffic_gb` | `total` 模式下的月总流量额度,单位 GB;`0` 表示不限制。 |
|
||||
| `traffic_in_gb` | `split` 模式下的月入站额度,单位 GB;`0` 表示不限制。 |
|
||||
| `traffic_out_gb` | `split` 模式下的月出站额度,单位 GB;`0` 表示不限制。 |
|
||||
|
||||
## 容器防火墙
|
||||
|
||||
容器防火墙通过 `GET /api/v1/containers/{id}/firewall` 读取,通过 `PUT /api/v1/containers/{id}/firewall` 更新。容器运行中更新时会立即应用规则。
|
||||
|
||||
更新示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"action": "ACCEPT",
|
||||
"network": "ipv4",
|
||||
"source_ip": "203.0.113.0/24",
|
||||
"port": "22,80,443",
|
||||
"description": "allow admin and web"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `enabled` | 是否启用容器防火墙。 |
|
||||
| `default_action` | 默认动作:`ACCEPT` 或 `DROP`。 |
|
||||
| `rules[].id` | 可选;新规则可省略,后端会自动生成。 |
|
||||
| `rules[].direction` | 方向:`in` 或 `out`。 |
|
||||
| `rules[].protocol` | 协议:`tcp`、`udp`、`icmp` 或 `all`。 |
|
||||
| `rules[].action` | 动作:`ACCEPT` 或 `DROP`。 |
|
||||
| `rules[].network` | 网络类型:`ipv4`、`ipv6` 或 `all`。 |
|
||||
| `rules[].source_ip` | 可选;源 IP、CIDR 或地址范围。 |
|
||||
| `rules[].port` | 可选;仅 `tcp`/`udp` 支持,可写 `22`、`80,443` 或 `8000-9000`。 |
|
||||
| `rules[].description` | 可选备注。 |
|
||||
|
||||
## API Key 创建与更新
|
||||
|
||||
`POST /api/v1/api-keys` 和 `PATCH /api/v1/api-keys/{id}` 使用相同的字段结构。创建时 `name` 必填;更新时根据需要覆盖字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Automation",
|
||||
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
|
||||
"scopes": ["dashboard:read", "container:read", "container:power"],
|
||||
"expires_at": "2026-12-31 23:59:59",
|
||||
"disabled": false,
|
||||
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `name` | API Key 名称;创建时必填。 |
|
||||
| `ip_whitelist` | 可选;允许的来源 IP/CIDR,多个值用逗号分隔;空值表示不限制。 |
|
||||
| `scopes` | 可选;权限范围。省略时使用默认只读范围,传 `*` 表示全部权限。 |
|
||||
| `expires_at` | 可选;过期时间,空值表示不过期。 |
|
||||
| `disabled` | 是否禁用该 Key。 |
|
||||
| `container_uuids` | 可选;限制该 Key 只能访问指定容器。 |
|
||||
|
||||
## Python 示例
|
||||
|
||||
获取容器列表:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
@@ -30,9 +209,7 @@ session.headers.update({
|
||||
|
||||
resp = session.get(f"{BASE_URL}/api/v1/containers", timeout=15)
|
||||
resp.raise_for_status()
|
||||
containers = resp.json()
|
||||
|
||||
print(containers)
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
创建端口映射:
|
||||
@@ -45,10 +222,10 @@ API_KEY = "YOUR_API_KEY"
|
||||
CONTAINER_ID = "example-vm"
|
||||
|
||||
payload = {
|
||||
"name": "web",
|
||||
"protocol": "tcp",
|
||||
"host_port": 18080,
|
||||
"container_port": 80,
|
||||
"description": "web",
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
@@ -61,76 +238,621 @@ resp.raise_for_status()
|
||||
print(resp.json())
|
||||
```
|
||||
|
||||
## 返回结构示例
|
||||
## 接口清单
|
||||
|
||||
容器列表:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 5,
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "example-vm",
|
||||
"status": "running",
|
||||
"ip": "10.0.3.25",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
"cpu_limit": 2,
|
||||
"memory_limit": 2048,
|
||||
"disk_limit": 20480,
|
||||
"traffic_limit": 107374182400,
|
||||
"expires_at": "2026-12-31 23:59:59"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
任务队列:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "task-13",
|
||||
"type": "restart",
|
||||
"status": "running",
|
||||
"created_at": "2026-06-09T10:00:00+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
WebSSH 票据:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"ticket": "***60秒有效票据***"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 常用接口
|
||||
### 总览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard` | 控制面板统计 |
|
||||
| GET | `/api/v1/host-info` | 主机资源 |
|
||||
| GET | `/api/v1/containers` | 容器列表 |
|
||||
| GET | `/api/v1/host-report` | 主机巡检报告 |
|
||||
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 |
|
||||
| PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 |
|
||||
| POST | `/api/v1/routing/ipv4-scan` | 扫描公网 IPv4 段 |
|
||||
| GET | `/api/v1/ipv6/status` | IPv6 状态 |
|
||||
| GET | `/api/v1/tasks` | 任务队列 |
|
||||
| DELETE | `/api/v1/tasks/{task_id}` | 删除任务 |
|
||||
|
||||
### 容器
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers` | 容器列表(推荐) |
|
||||
| GET | `/api/v1/containers/list` | 容器列表兼容 GET 写法 |
|
||||
| POST | `/api/v1/containers/list` | 容器列表兼容 POST 写法 |
|
||||
| POST | `/api/v1/containers` | 创建容器 |
|
||||
| GET | `/api/v1/containers/{id\|uuid\|name}` | 容器详情 |
|
||||
| POST | `/api/v1/containers/{id}/start` | 开机 |
|
||||
| POST | `/api/v1/containers/{id}/stop` | 关机 |
|
||||
| POST | `/api/v1/containers/{id}/restart` | 重启 |
|
||||
| POST | `/api/v1/containers/{id}/reinstall` | 重装 |
|
||||
| DELETE | `/api/v1/containers/{id}/delete` | 删除 |
|
||||
| GET | `/api/v1/tasks` | 任务队列 |
|
||||
| GET | `/api/v1/containers/{id}/usage` | 资源用量 |
|
||||
| GET | `/api/v1/containers/{id}/traffic` | 流量统计 |
|
||||
| POST | `/api/v1/containers/{id}/traffic-reset` | 重置流量 |
|
||||
| PUT | `/api/v1/containers/{id}/traffic-limit` | 调整流量限制 |
|
||||
| PUT | `/api/v1/containers/{id}/resource-limit` | 调整资源限制 |
|
||||
| PUT | `/api/v1/containers/{id}/expiry` | 调整到期时间 |
|
||||
| POST | `/api/v1/containers/{id}/reset-password` | 重置 SSH 密码 |
|
||||
| POST | `/api/v1/containers/{id}/ipv6` | 分配 IPv6 |
|
||||
|
||||
### 端口与快照
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口;可传 `host_ip` 查询指定宿主机 IP |
|
||||
| POST | `/api/v1/containers/{id}/port-mappings` | 添加端口映射 |
|
||||
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | 更新端口映射 |
|
||||
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | 删除端口映射 |
|
||||
| GET | `/api/v1/containers/{id}/firewall` | 获取容器防火墙设置 |
|
||||
| PUT | `/api/v1/containers/{id}/firewall` | 更新容器防火墙设置 |
|
||||
| GET | `/api/v1/snapshots` | 快照总览 |
|
||||
| GET | `/api/v1/containers/{id}/snapshots` | 容器快照 |
|
||||
| POST | `/api/v1/containers/{id}/snapshots` | 创建快照 |
|
||||
| DELETE | `/api/v1/containers/{id}/snapshots/{snapshot_id}` | 删除快照 |
|
||||
| POST | `/api/v1/containers/{id}/snapshots/{snapshot_id}/restore` | 恢复快照 |
|
||||
| POST | `/api/v1/containers/{id}/snapshots/schedule` | 计划快照 |
|
||||
| PUT | `/api/v1/containers/{id}/snapshots/quota` | 快照配额 |
|
||||
|
||||
### 平台管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/templates` | 模板列表 |
|
||||
| GET | `/api/v1/images` | 镜像管理列表 |
|
||||
| GET | `/api/v1/snapshots` | 快照总览 |
|
||||
| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` |
|
||||
| POST | `/api/v1/images/download` | 下载镜像 |
|
||||
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
|
||||
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
|
||||
| PUT | `/api/v1/images/toggle` | 启用/禁用镜像 |
|
||||
| GET | `/api/v1/security/alerts` | 安全告警 |
|
||||
| GET | `/api/v1/audit-logs` | 操作日志 |
|
||||
| GET | `/api/v1/api-keys` | API Key 列表 |
|
||||
| POST | `/api/v1/security/check` | 立即安全检查 |
|
||||
| GET | `/api/v1/security/logs?container={name}` | 安全连接日志 |
|
||||
| GET | `/api/v1/security/summary` | 安全汇总 |
|
||||
| GET | `/api/v1/security/settings` | 安全设置 |
|
||||
| PUT | `/api/v1/security/settings` | 更新安全设置 |
|
||||
| GET | `/api/v1/swap` | Swap 信息 |
|
||||
| POST | `/api/v1/swap` | 调整 Swap |
|
||||
| GET | `/api/v1/language` | 当前面板语言 |
|
||||
| POST/PUT | `/api/v1/language` | 更新面板语言 |
|
||||
| GET | `/api/v1/ssl` | SSL 设置(需管理员权限 / `admin:access`) |
|
||||
| PUT | `/api/v1/ssl` | 更新 SSL 设置(需管理员权限 / `admin:access`) |
|
||||
| GET | `/api/v1/webssh-origins` | WebSSH Origin 白名单(需管理员权限 / `admin:access`) |
|
||||
| PUT | `/api/v1/webssh-origins` | 更新 WebSSH Origin 白名单(需管理员权限 / `admin:access`) |
|
||||
| POST | `/api/v1/batch-create` | 批量创建容器 |
|
||||
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
|
||||
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
|
||||
| POST | `/api/v1/vnc-ticket` | 创建 WebVNC 票据 |
|
||||
|
||||
完整接口清单请以面板内“API 集成”页面为准。
|
||||
### 账号与日志
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/v1/sub-user/create` | 创建子用户链接 |
|
||||
| GET | `/api/v1/sub-users` | 子用户列表 |
|
||||
| POST | `/api/v1/sub-users/{id}/rotate-password` | 轮换子用户密码 |
|
||||
| GET | `/api/v1/sub-users/{id}/audit-logs` | 子用户操作日志 |
|
||||
| GET | `/api/v1/sub-users/{id}/login-logs` | 子用户登录日志 |
|
||||
| GET | `/api/v1/audit-logs` | 操作日志 |
|
||||
| GET | `/api/v1/login-logs` | 登录日志 |
|
||||
| GET | `/api/v1/api-keys` | API Key 列表 |
|
||||
| POST | `/api/v1/api-keys` | 创建 API Key |
|
||||
| PATCH | `/api/v1/api-keys/{id}` | 更新 API Key |
|
||||
| DELETE | `/api/v1/api-keys/{id}` | 删除 API Key |
|
||||
|
||||
## 返回样例
|
||||
|
||||
以下样例按接口路径分组。真实环境中的资源数值、任务 ID、容器 ID、时间、IP 和密钥会不同,示例中的密码、票据和 API Key 均已脱敏。
|
||||
|
||||
### 总览
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/dashboard": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"running": 31,
|
||||
"stopped": 0,
|
||||
"total_containers": 31
|
||||
}
|
||||
},
|
||||
"GET /api/v1/host-info": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"cpu": { "cores": 8, "usage_pct": 1.16 },
|
||||
"ram": { "total_mb": 31825, "used_mb": 1275, "free_mb": 30550 },
|
||||
"disk": { "total_gb": 1750.49, "used_gb": 123.98, "free_gb": 1626.51 },
|
||||
"network": {
|
||||
"public_ipv4": "203.0.113.10",
|
||||
"public_ipv4_interface": "eth0",
|
||||
"public_ipv6": "2001:db8:100::2",
|
||||
"public_ipv6_interface": "eth0"
|
||||
},
|
||||
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/host-report": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"generated_at": "2026-06-12 10:00:00",
|
||||
"summary": { "status": "ok", "warnings": 0 },
|
||||
"host": { "hostname": "node-1", "kernel": "6.8.0" },
|
||||
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
|
||||
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/routing": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"nat4": { "used": 62, "remaining": "45474", "total": "45536" },
|
||||
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||
"ipv6": { "used": 31, "remaining": "large", "total": "large" },
|
||||
"public_ipv4_addresses": [
|
||||
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||
],
|
||||
"ipv4_assignments": [
|
||||
{ "container_id": 5, "container_name": "example-vm", "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||
],
|
||||
"nat4_mappings": [
|
||||
{ "container_id": 5, "container_name": "example-vm", "status": "running", "ip": "10.0.0.10", "host_port": 22004, "container_port": 22, "protocol": "tcp" }
|
||||
],
|
||||
"ipv6_assignments": [
|
||||
{ "container_id": 5, "container_name": "example-vm", "address": "2001:db8:100::1005", "prefix_len": 64, "interface": "eth0" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/routing": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
|
||||
"public_ipv4_addresses": [
|
||||
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
|
||||
],
|
||||
"ipv6_prefixes": [
|
||||
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"POST /api/v1/routing/ipv4-scan": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1", "status": "available", "usable": true, "reason": "" }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/ipv6/status": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"available": true,
|
||||
"reachable": true,
|
||||
"reason": "usable public IPv6 prefix detected",
|
||||
"prefixes": [
|
||||
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"GET /api/v1/tasks": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"DELETE /api/v1/tasks/{task_id}": {
|
||||
"success": true,
|
||||
"message": "Task deleted"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 容器
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/containers": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 5,
|
||||
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "example-vm",
|
||||
"virtualization": "lxc",
|
||||
"template": "debian-bullseye",
|
||||
"vcpu": 1,
|
||||
"ram_mb": 512,
|
||||
"disk_gb": 10,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80,
|
||||
"status": "running",
|
||||
"ip": "10.0.0.10",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
"ssh_port": 22004,
|
||||
"ssh_password": "***",
|
||||
"port_mappings": [
|
||||
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||
{ "container_port": 20000, "host_port": 20000, "protocol": "tcp", "description": "Port-20000" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"GET /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/containers": {
|
||||
"success": true,
|
||||
"message": "Container created successfully"
|
||||
},
|
||||
"GET /api/v1/containers/{id|uuid|name}": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 5,
|
||||
"uuid": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "example-vm",
|
||||
"status": "running",
|
||||
"ip": "10.0.0.10",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
"ssh_port": 22004,
|
||||
"ssh_password": "***",
|
||||
"policy_blocked": false
|
||||
}
|
||||
},
|
||||
"POST /api/v1/containers/{id}/start": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "start" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/stop": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "stop" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/restart": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "restart" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/reinstall": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "reinstall" }
|
||||
},
|
||||
"DELETE /api/v1/containers/{id}/delete": {
|
||||
"success": true,
|
||||
"message": "Task queued",
|
||||
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "delete" }
|
||||
},
|
||||
"GET /api/v1/containers/{id}/usage": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"cpu_usage_pct": 0,
|
||||
"cpu_usage_usec": 3908852,
|
||||
"memory_usage_bytes": 29331456,
|
||||
"disk_usage_bytes": 515100672,
|
||||
"network_rx_bytes": 131232,
|
||||
"network_tx_bytes": 16828,
|
||||
"load1": 0.1,
|
||||
"load5": 0.06,
|
||||
"load15": 0.01
|
||||
}
|
||||
},
|
||||
"GET /api/v1/containers/{id}/traffic": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"mode": "total",
|
||||
"limit_gb": 1024,
|
||||
"in_limit_gb": 0,
|
||||
"out_limit_gb": 0,
|
||||
"total_used_bytes": 142082,
|
||||
"rx_used_bytes": 127212,
|
||||
"tx_used_bytes": 14870,
|
||||
"used_pct": 0,
|
||||
"reset_date": "2026-06"
|
||||
}
|
||||
},
|
||||
"POST /api/v1/containers/{id}/traffic-reset": {
|
||||
"success": true,
|
||||
"message": "Traffic reset"
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/traffic-limit": {
|
||||
"success": true,
|
||||
"message": "Traffic limit updated"
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/resource-limit": {
|
||||
"success": true,
|
||||
"message": "Resource limits updated"
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/expiry": {
|
||||
"success": true,
|
||||
"message": "Expiry updated"
|
||||
},
|
||||
"POST /api/v1/containers/{id}/reset-password": {
|
||||
"success": true,
|
||||
"message": "SSH password reset successfully",
|
||||
"data": { "password": "***" }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/ipv6": {
|
||||
"success": true,
|
||||
"message": "IPv6 assigned",
|
||||
"data": { "id": 5, "name": "example-vm", "ipv6": "2001:db8:100::1005" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 端口与快照
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
|
||||
"success": true,
|
||||
"data": { "port": 61320 }
|
||||
},
|
||||
"POST /api/v1/containers/{id}/port-mappings": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
|
||||
{ "container_port": 8080, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||
]
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/port-mappings/{index}": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "container_port": 8081, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
|
||||
]
|
||||
},
|
||||
"DELETE /api/v1/containers/{id}/port-mappings/{index}": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"message": "Firewall updated",
|
||||
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
|
||||
},
|
||||
"GET /api/v1/snapshots": {
|
||||
"success": true,
|
||||
"data": null
|
||||
},
|
||||
"GET /api/v1/containers/{id}/snapshots": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"quota": 1,
|
||||
"schedule": { "enabled": false, "interval_hours": 0, "last_run": "", "next_run": "", "time": "", "created_by": "" },
|
||||
"snapshots": []
|
||||
}
|
||||
},
|
||||
"POST /api/v1/containers/{id}/snapshots": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "snap-20260608-001",
|
||||
"container_id": 5,
|
||||
"container_name": "example-vm",
|
||||
"created_at": "2026-06-08 16:00:00",
|
||||
"created_by": "api:Automation",
|
||||
"scheduled": false,
|
||||
"size_bytes": 10485760
|
||||
}
|
||||
},
|
||||
"DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}": {
|
||||
"success": true,
|
||||
"message": "Snapshot deleted"
|
||||
},
|
||||
"POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore": {
|
||||
"success": true,
|
||||
"message": "Snapshot restored"
|
||||
},
|
||||
"POST /api/v1/containers/{id}/snapshots/schedule": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"container": { "id": 5, "name": "example-vm", "snapshot_schedule_enabled": true, "snapshot_schedule_interval_hours": 24, "snapshot_schedule_time": "03:00" }
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/snapshots/quota": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"quota": 2,
|
||||
"container": { "id": 5, "name": "example-vm", "snapshot_limit": 2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 平台管理
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/templates": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "description": "Ubuntu 24.04 LTS" },
|
||||
{ "id": "debian-bookworm", "name": "Debian 12", "distro": "debian", "release": "bookworm", "arch": "amd64", "description": "Debian 12 (Bookworm)" }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/images": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/images/enabled?type=lxc": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/images/download": {
|
||||
"success": true,
|
||||
"message": "Already downloaded"
|
||||
},
|
||||
"POST /api/v1/images/cancel": {
|
||||
"success": true,
|
||||
"message": "Cancel requested"
|
||||
},
|
||||
"DELETE /api/v1/images/delete": {
|
||||
"success": true,
|
||||
"message": "Deleted"
|
||||
},
|
||||
"PUT /api/v1/images/toggle": {
|
||||
"success": true,
|
||||
"message": "OK"
|
||||
},
|
||||
"GET /api/v1/security/alerts": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"POST /api/v1/security/check": {
|
||||
"success": true,
|
||||
"message": "Security check completed"
|
||||
},
|
||||
"GET /api/v1/security/logs?container={name}": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/security/summary": {
|
||||
"success": true,
|
||||
"data": { "critical": 0, "high": 0, "medium": 0, "low": 0, "total_alerts": 0 }
|
||||
},
|
||||
"GET /api/v1/security/settings": {
|
||||
"success": true,
|
||||
"data": { "auto_shutdown": false }
|
||||
},
|
||||
"PUT /api/v1/security/settings": {
|
||||
"success": true,
|
||||
"data": { "auto_shutdown": false }
|
||||
},
|
||||
"GET /api/v1/swap": {
|
||||
"success": true,
|
||||
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||
},
|
||||
"POST /api/v1/swap": {
|
||||
"success": true,
|
||||
"message": "SWAP 已调整为 16384 MB",
|
||||
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||
},
|
||||
"GET /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "zh" }
|
||||
},
|
||||
"PUT /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "en" }
|
||||
},
|
||||
"GET /api/v1/ssl": {
|
||||
"success": true,
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
|
||||
},
|
||||
"PUT /api/v1/ssl": {
|
||||
"success": true,
|
||||
"message": "SSL settings saved",
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
|
||||
},
|
||||
"GET /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"PUT /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"message": "Origin allowlist saved",
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"POST /api/v1/batch-create": {
|
||||
"success": true,
|
||||
"data": ["task-12"]
|
||||
},
|
||||
"POST /api/v1/batch-action": {
|
||||
"success": true,
|
||||
"data": ["task-13"]
|
||||
},
|
||||
"POST /api/v1/ssh-ticket": {
|
||||
"success": true,
|
||||
"data": { "ticket": "***60秒有效票据***" }
|
||||
},
|
||||
"POST /api/v1/vnc-ticket": {
|
||||
"success": true,
|
||||
"data": { "ticket": "***60秒有效票据***" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 账号与日志
|
||||
|
||||
```json
|
||||
{
|
||||
"POST /api/v1/sub-user/create": {
|
||||
"success": true,
|
||||
"message": "Sub-user created",
|
||||
"data": {
|
||||
"id": "sub-xxxxxxxx",
|
||||
"username": "user-xxxxxxxx",
|
||||
"password": "***",
|
||||
"container_names": ["example-vm"],
|
||||
"access_code": "********",
|
||||
"created_at": "2026-06-08 16:00:00"
|
||||
}
|
||||
},
|
||||
"GET /api/v1/sub-users": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"POST /api/v1/sub-users/{id}/rotate-password": {
|
||||
"success": true,
|
||||
"data": { "username": "user-xxxxxxxx", "password": "***", "access_code": "********" }
|
||||
},
|
||||
"GET /api/v1/sub-users/{id}/audit-logs": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/sub-users/{id}/login-logs": {
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/audit-logs": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "time": "2026-06-08 15:44:40", "action": "apikey.create", "target": "Test", "detail": "scopes=*", "user": "admin", "success": true }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/login-logs": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "time": "2026-06-08 08:24:00 UTC", "username": "admin", "ip": "198.51.100.23", "user_agent": "Mozilla/5.0 ...", "success": true }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"message": "API key created. Save this key now - it won't be shown again.",
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"PATCH /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"DELETE /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
"message": "API key deleted"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -25,6 +25,14 @@ POST /api/v1/containers
|
||||
POST /api/v1/batch-create
|
||||
```
|
||||
|
||||
Linux 容器和 Linux KVM 虚拟机创建时支持配置 SSH 登录方式:
|
||||
|
||||
- `auto_password`:自动生成 root SSH 密码。
|
||||
- `password`:使用自定义 `ssh_password`。
|
||||
- `key`:写入一行 `ssh_public_key`,仍会保留可用于 WebSSH 的密码。
|
||||
|
||||
网络分配可以按需组合 NAT、公网 IPv4 和 IPv6。API 字段保持为 `assign_nat`、`assign_ipv4`、`public_ipv4s`、`assign_ipv6`、`ipv6_addresses` 等可选字段,未传时沿用默认行为。
|
||||
|
||||
## 生命周期操作
|
||||
|
||||
```http
|
||||
@@ -37,6 +45,8 @@ DELETE /api/v1/containers/{id}/delete
|
||||
|
||||
开关机、重装、删除等操作会进入任务队列。调用后可通过 `GET /api/v1/tasks` 查看执行状态。
|
||||
|
||||
重装 Linux 系统时可传 `ssh_auth_mode`、`ssh_password`、`ssh_public_key`。`ssh_auth_mode=keep` 表示沿用当前 SSH 密码;不传这些字段时保持旧行为。
|
||||
|
||||
## 资源与流量
|
||||
|
||||
容器详情页支持查看资源用量,调整流量限制、资源限制和到期时间。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 网络与路由
|
||||
|
||||
CLICD 提供 NAT4 端口映射、随机可用端口、IPv6 状态检查和 IPv6 分配能力。
|
||||
CLICD 提供 NAT4 端口映射、随机可用端口、公网 IPv4 分配、IPv6 状态检查和 IPv6 分配能力。创建容器时可以只分配 NAT、只分配公网 IPv4、只分配 IPv6,或按需混合使用。
|
||||
|
||||
## NAT4
|
||||
|
||||
@@ -30,6 +30,28 @@ POST /api/v1/containers/{id}/ipv6
|
||||
|
||||
如果宿主机没有公网 IPv6 或上游没有正确路由,面板中分配出的地址也无法从公网访问。
|
||||
|
||||
## 公网 IPv4
|
||||
|
||||
公网 IPv4 分配会从主机检测到的可用公网 IPv4 中选择地址,或使用 API 指定的 `public_ipv4s`。创建容器时可使用:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `assign_nat` | 是否启用 NAT 端口映射。 |
|
||||
| `assign_ipv4` | 是否分配公网 IPv4。 |
|
||||
| `ipv4_count` | 自动分配公网 IPv4 数量。 |
|
||||
| `public_ipv4s` | 指定公网 IPv4 地址列表。 |
|
||||
| `assign_ipv6` | 是否分配 IPv6。 |
|
||||
| `ipv6_count` | 自动分配 IPv6 数量。 |
|
||||
| `ipv6_addresses` | 指定 IPv6 地址列表。 |
|
||||
|
||||
公网地址池相关接口:
|
||||
|
||||
```http
|
||||
GET /api/v1/routing
|
||||
PUT /api/v1/routing
|
||||
POST /api/v1/routing/ipv4-scan
|
||||
```
|
||||
|
||||
## 路由状态
|
||||
|
||||
```http
|
||||
|
||||
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Linux x86_64 宿主机。
|
||||
- Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
|
||||
- root 权限。
|
||||
- systemd。
|
||||
- 网络可访问 GitHub Release 下载地址。
|
||||
@@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`。
|
||||
脚本当前默认使用 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 或 `clicd-linux-arm64.tar.gz`。
|
||||
|
||||
## 安装指定版本
|
||||
|
||||
|
||||
@@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿
|
||||
|
||||
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
|
||||
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
|
||||
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。
|
||||
- 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 安装脚本默认安装哪个版本?
|
||||
|
||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
|
||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
|
||||
|
||||
## 可以固定安装某个版本吗?
|
||||
|
||||
|
||||
Generated
+245
-128
@@ -369,9 +369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -382,13 +382,13 @@
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -399,13 +399,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -416,13 +416,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -433,13 +433,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -450,13 +450,13 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -467,13 +467,13 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -484,13 +484,13 @@
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -501,13 +501,13 @@
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -518,13 +518,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -535,13 +535,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -552,13 +552,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -569,13 +569,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -586,13 +586,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -603,13 +603,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -620,13 +620,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -637,13 +637,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -654,13 +654,30 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -671,13 +688,30 @@
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -688,13 +722,30 @@
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -705,13 +756,13 @@
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -722,13 +773,13 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -739,13 +790,13 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -756,7 +807,7 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/simple-icons": {
|
||||
@@ -1706,9 +1757,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1716,32 +1767,35 @@
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
@@ -1751,6 +1805,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
|
||||
@@ -2037,6 +2109,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -2258,6 +2343,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -2373,21 +2475,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
@@ -2396,19 +2501,25 @@
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -2429,6 +2540,12 @@
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,5 +9,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "6.4.2",
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CLICD - LXC Container Manager</title>
|
||||
<title>CLICD - Container Manager</title>
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('clicd_theme');
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.19",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -1372,16 +1372,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.22",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { shouldTranslateText, translateText } from '../utils/i18n'
|
||||
|
||||
const translatedTitleAttr = 'data-i18n-title-original'
|
||||
const translatedPlaceholderAttr = 'data-i18n-placeholder-original'
|
||||
const translatedAriaLabelAttr = 'data-i18n-aria-label-original'
|
||||
|
||||
const attributeNames = ['title', 'placeholder', 'aria-label'] as const
|
||||
const translatedTextNodes = new Set<Text>()
|
||||
const textOriginals = new WeakMap<Text, string>()
|
||||
const wholeTextSelector = 'button,a,span,label,option,th,td,p,h1,h2,h3,h4,small'
|
||||
|
||||
export default function AutoTranslate() {
|
||||
const { language } = useLanguage()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (language === 'zh') {
|
||||
restoreTranslatedNodes(document.body)
|
||||
return
|
||||
}
|
||||
|
||||
translateNode(document.body)
|
||||
|
||||
const pending = new Set<Node>()
|
||||
let scheduled = false
|
||||
const flush = () => {
|
||||
scheduled = false
|
||||
const nodes = Array.from(pending)
|
||||
pending.clear()
|
||||
for (const node of nodes) {
|
||||
if (node.isConnected) translateNode(node)
|
||||
}
|
||||
}
|
||||
const schedule = (node: Node) => {
|
||||
pending.add(node)
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
window.requestAnimationFrame(flush)
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(schedule)
|
||||
} else {
|
||||
schedule(mutation.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: [...attributeNames],
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [language, location.pathname, location.search])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function translateNode(root: Node) {
|
||||
if (root.nodeType === Node.TEXT_NODE) {
|
||||
translateTextNode(root as Text)
|
||||
return
|
||||
}
|
||||
if (!(root instanceof Element)) return
|
||||
if (shouldSkipElement(root)) return
|
||||
|
||||
translateWholeTextElement(root)
|
||||
root.querySelectorAll<HTMLElement>(wholeTextSelector).forEach(translateWholeTextElement)
|
||||
translateElementAttributes(root)
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
if (!node.textContent || !shouldTranslateText(node.textContent)) return NodeFilter.FILTER_REJECT
|
||||
const parent = node.parentElement
|
||||
if (!parent || shouldSkipElement(parent)) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
})
|
||||
|
||||
const nodes: Text[] = []
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode as Text)
|
||||
for (const node of nodes) translateTextNode(node)
|
||||
root.querySelectorAll<HTMLElement>('[title], [placeholder], [aria-label]').forEach(translateElementAttributes)
|
||||
}
|
||||
|
||||
function translateTextNode(node: Text) {
|
||||
const original = node.textContent || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const parent = node.parentElement
|
||||
if (!parent || shouldSkipElement(parent)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
textOriginals.set(node, original)
|
||||
translatedTextNodes.add(node)
|
||||
node.textContent = translated
|
||||
}
|
||||
|
||||
function translateWholeTextElement(el: Element) {
|
||||
if (!(el instanceof HTMLElement) || shouldSkipElement(el) || !isSimpleTextElement(el)) return
|
||||
const original = el.textContent || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
|
||||
const textNodes = directTextNodes(el)
|
||||
if (textNodes.length === 0) return
|
||||
textNodes.forEach((node, index) => {
|
||||
textOriginals.set(node, node.textContent || '')
|
||||
translatedTextNodes.add(node)
|
||||
node.textContent = index === 0 ? translated : ''
|
||||
})
|
||||
}
|
||||
|
||||
function directTextNodes(el: HTMLElement) {
|
||||
return Array.from(el.childNodes).filter((node): node is Text => node.nodeType === Node.TEXT_NODE)
|
||||
}
|
||||
|
||||
function translateElementAttributes(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
translateAttribute(el, 'title', translatedTitleAttr)
|
||||
translateAttribute(el, 'placeholder', translatedPlaceholderAttr)
|
||||
translateAttribute(el, 'aria-label', translatedAriaLabelAttr)
|
||||
}
|
||||
|
||||
function restoreTranslatedNodes(root: ParentNode) {
|
||||
for (const node of Array.from(translatedTextNodes)) {
|
||||
if (!node.isConnected) {
|
||||
translatedTextNodes.delete(node)
|
||||
continue
|
||||
}
|
||||
if (root instanceof Document || root.contains(node)) {
|
||||
node.textContent = textOriginals.get(node) || node.textContent
|
||||
translatedTextNodes.delete(node)
|
||||
}
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>(`[${translatedTitleAttr}]`).forEach((el) => {
|
||||
el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '')
|
||||
el.removeAttribute(translatedTitleAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[${translatedPlaceholderAttr}]`).forEach((el) => {
|
||||
el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '')
|
||||
el.removeAttribute(translatedPlaceholderAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLElement>(`[${translatedAriaLabelAttr}]`).forEach((el) => {
|
||||
el.setAttribute('aria-label', el.getAttribute(translatedAriaLabelAttr) || '')
|
||||
el.removeAttribute(translatedAriaLabelAttr)
|
||||
})
|
||||
}
|
||||
|
||||
function translateAttribute(el: HTMLElement, attr: 'title' | 'placeholder' | 'aria-label', originalAttr: string) {
|
||||
const storedOriginal = el.getAttribute(originalAttr)
|
||||
const original = storedOriginal || el.getAttribute(attr) || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
if (!storedOriginal) {
|
||||
el.setAttribute(originalAttr, original)
|
||||
}
|
||||
if (el.getAttribute(attr) !== translated) {
|
||||
el.setAttribute(attr, translated)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipElement(el: Element) {
|
||||
return !!el.closest('script, style, code, pre, textarea, [data-no-translate]')
|
||||
}
|
||||
|
||||
function isSimpleTextElement(el: HTMLElement) {
|
||||
if (!el.matches(wholeTextSelector)) return false
|
||||
if (el.querySelector('input, textarea, select, button, table, pre, code, canvas, iframe')) return false
|
||||
const textNodes = directTextNodes(el)
|
||||
if (textNodes.length === 0) return false
|
||||
return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'svg')
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
export default function BrowserDialogTranslator() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
useEffect(() => {
|
||||
const originalAlert = window.alert
|
||||
const originalConfirm = window.confirm
|
||||
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
|
||||
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||
return () => {
|
||||
window.alert = originalAlert
|
||||
window.confirm = originalConfirm
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>{container.network_bw_mbps} Mbps</span>
|
||||
<span>{formatNetworkLimit(container)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,3 +140,10 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatNetworkLimit(container: { network_bw_mbps?: number; network_down_mbps?: number; network_up_mbps?: number }) {
|
||||
const down = Math.max(0, Number(container.network_down_mbps || container.network_bw_mbps || 0))
|
||||
const up = Math.max(0, Number(container.network_up_mbps || container.network_bw_mbps || 0))
|
||||
if (down === 0 && up === 0) return '不限速'
|
||||
return `下 ${down || '不限'} / 上 ${up || '不限'} Mbps`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { CalendarClock, X } from 'lucide-react'
|
||||
import { CalendarClock, RefreshCw, X } from 'lucide-react'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
||||
import { useDialog } from './Dialog'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||
|
||||
interface CreateContainerModalProps {
|
||||
isOpen: boolean
|
||||
@@ -19,20 +21,35 @@ const defaultForm: CreateContainerRequest = {
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 0,
|
||||
network_up_mbps: 0,
|
||||
monthly_traffic_gb: 0,
|
||||
traffic_mode: 'total',
|
||||
traffic_in_gb: 0,
|
||||
traffic_out_gb: 0,
|
||||
io_speed_mbps: 0,
|
||||
io_read_mbps: 0,
|
||||
io_write_mbps: 0,
|
||||
extra_ports: [],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
assign_ipv6: false,
|
||||
ipv6_count: 1,
|
||||
ipv6_addresses: [],
|
||||
ssh_auth_mode: 'auto_password',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||
const dialog = useDialog()
|
||||
const { language } = useLanguage()
|
||||
const networkText = createNetworkText[language]
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
@@ -74,16 +91,32 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
}, [isOpen, form.virtualization])
|
||||
|
||||
const ipv6Available = !!ipv6Status?.available
|
||||
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
|
||||
const ipv6Prefixes = ipv6Status?.prefixes || []
|
||||
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
|
||||
const publicIPv4s = hostInfo?.network.public_ipv4_addresses || []
|
||||
const ipv4Available = publicIPv4s.length > 0
|
||||
const manualIPv4s = form.public_ipv4s || []
|
||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
|
||||
|
||||
useEffect(() => {
|
||||
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))
|
||||
}
|
||||
}, [hostInfo, kvmAvailable, form.virtualization])
|
||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
const natEnabled = form.assign_nat !== false
|
||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
||||
const linuxTemplate = !isWindowsTemplate(form.template_id)
|
||||
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
||||
|
||||
const autoPorts = useMemo(() => {
|
||||
const count = Math.max(2, form.port_mapping_count)
|
||||
if (!natEnabled) return []
|
||||
const count = natPortCount
|
||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||
}, [form.port_mapping_count])
|
||||
}, [natEnabled, natPortCount])
|
||||
|
||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||
const sshPortPreview = 22000
|
||||
@@ -127,7 +160,19 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
|
||||
dialog.alert('提示', '请勾选任意一个可用网络')
|
||||
return
|
||||
}
|
||||
|
||||
const authError = validateSSHAuthInputs(form)
|
||||
if (authError) {
|
||||
dialog.alert('登录方式有误', authError)
|
||||
return
|
||||
}
|
||||
|
||||
const boundedForm = normalizeCreateForm(form)
|
||||
const wantsNAT = boundedForm.assign_nat !== false
|
||||
|
||||
// Build batch of containers
|
||||
const containers: CreateContainerRequest[] = []
|
||||
@@ -137,8 +182,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
containers.push({
|
||||
...boundedForm,
|
||||
name,
|
||||
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0,
|
||||
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
|
||||
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
|
||||
extra_ports: [],
|
||||
})
|
||||
}
|
||||
@@ -200,8 +248,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
disabled={!kvmAvailable}
|
||||
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||
onClick={() => {
|
||||
if (kvmAvailable) {
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))
|
||||
}
|
||||
}}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
KVM 虚拟机
|
||||
</button>
|
||||
@@ -229,21 +283,212 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
</Field>
|
||||
|
||||
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv6}
|
||||
disabled={!ipv6Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">Public IPv6</span>
|
||||
<span className="block text-xs text-gray-500 truncate">
|
||||
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
|
||||
{linuxTemplate && (
|
||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
||||
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['auto_password', '自动生成密码'],
|
||||
['password', '自定义密码'],
|
||||
['key', 'SSH Key'],
|
||||
] as Array<[SSHAuthMode, string]>).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, ssh_auth_mode: mode })}
|
||||
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${sshAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{sshAuthMode === 'password' && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={form.ssh_password || ''}
|
||||
onChange={(event) => setForm({ ...form, ssh_password: event.target.value })}
|
||||
className={inputClass}
|
||||
placeholder="RootPass123"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, ssh_password: generateSSHPassword() })}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
title="生成密码"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sshAuthMode === 'key' && (
|
||||
<textarea
|
||||
value={form.ssh_public_key || ''}
|
||||
onChange={(event) => setForm({ ...form, ssh_public_key: event.target.value })}
|
||||
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv4}
|
||||
disabled={!ipv4Available}
|
||||
onChange={(event) => setForm({
|
||||
...form,
|
||||
assign_ipv4: event.target.checked,
|
||||
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
|
||||
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [] } : {}),
|
||||
})}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicIPv4}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{ipv4Available ? formatAllocatableIPv4Count(publicIPv4s.length, language) : networkText.noAllocatableIPv4}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label>
|
||||
{form.assign_ipv4 && (
|
||||
<div className="mt-3 space-y-3 pl-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={manualIPv4s.length === 0}
|
||||
onChange={() => setForm({ ...form, public_ipv4s: [] })}
|
||||
/>
|
||||
Auto assign
|
||||
</label>
|
||||
<Field label="IPv4 count">
|
||||
<NumberInput
|
||||
value={form.ipv4_count || 1}
|
||||
min={1}
|
||||
max={Math.max(1, publicIPv4s.length)}
|
||||
onChange={(value) => setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={manualIPv4s.length > 0}
|
||||
onChange={() => setForm({ ...form, public_ipv4s: publicIPv4s[0]?.address ? [publicIPv4s[0].address] : [], ipv4_count: 1 })}
|
||||
/>
|
||||
Manual select
|
||||
</label>
|
||||
{manualIPv4s.length > 0 && (
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{publicIPv4s.map((ip) => (
|
||||
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualIPv4s.includes(ip.address)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? [...manualIPv4s, ip.address]
|
||||
: manualIPv4s.filter((value) => value !== ip.address)
|
||||
setForm({ ...form, public_ipv4s: next, ipv4_count: Math.max(1, next.length || 1) })
|
||||
}}
|
||||
/>
|
||||
<span className="truncate font-mono">{ip.address}</span>
|
||||
<span className="shrink-0 text-gray-400">{ip.interface}</span>
|
||||
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.assign_ipv6}
|
||||
disabled={!ipv6Available}
|
||||
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
||||
<span className="block text-xs text-gray-500 truncate">
|
||||
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{form.assign_ipv6 && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={form.ipv6_count || 1}
|
||||
min={1}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={natEnabled}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked
|
||||
setForm({
|
||||
...form,
|
||||
assign_nat: checked,
|
||||
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||
extra_ports: [],
|
||||
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0 } : {}),
|
||||
})
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicNAT}</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{natEnabled ? formatNATPortCount(natPortCount, language) : networkText.noNATPorts}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{natEnabled && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={natPortCount}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{natEnabled && (
|
||||
<div className="mt-2 pl-6">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="vCPU">
|
||||
@@ -270,7 +515,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<Field label="磁盘 (GB)">
|
||||
<NumberInput
|
||||
value={form.disk_gb}
|
||||
@@ -281,72 +526,63 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
/>
|
||||
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
|
||||
</Field>
|
||||
<Field label="带宽 (Mbps)">
|
||||
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
|
||||
</Field>
|
||||
<Field label="IO 速度 (MB/s)">
|
||||
<NumberInput value={form.io_speed_mbps} min={0} onChange={(value) => setForm({ ...form, io_speed_mbps: value })} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3 md:col-span-2">
|
||||
<Field label="下行带宽 (Mbps)">
|
||||
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
|
||||
</Field>
|
||||
<Field label="上行带宽 (Mbps)">
|
||||
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
|
||||
</Field>
|
||||
<Field label="读取 IO (MB/s)">
|
||||
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
|
||||
</Field>
|
||||
<Field label="写入 IO (MB/s)">
|
||||
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic control */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">月流量</label>
|
||||
<select
|
||||
value={form.traffic_mode}
|
||||
onChange={(e) => setForm({ ...form, traffic_mode: e.target.value })}
|
||||
className="h-8 px-2 border border-gray-300 rounded text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="total">双向统计</option>
|
||||
<option value="in_out">入/出分离</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Traffic control */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">月流量</label>
|
||||
<select
|
||||
value={form.traffic_mode}
|
||||
onChange={(e) => setForm({ ...form, traffic_mode: e.target.value })}
|
||||
className="h-8 px-2 border border-gray-300 rounded text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="total">双向统计</option>
|
||||
<option value="in_out">入/出分离</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.traffic_mode === 'total' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
|
||||
<span className="text-xs text-gray-400">GB (0=不限制)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="入站 (GB)">
|
||||
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
|
||||
</Field>
|
||||
<Field label="出站 (GB)">
|
||||
<NumberInput value={form.traffic_out_gb} min={0} onChange={(value) => setForm({ ...form, traffic_out_gb: value || 0 })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{form.traffic_mode === 'total' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
|
||||
<span className="text-xs text-gray-400">GB (0=不限制)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="入站 (GB)">
|
||||
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
|
||||
</Field>
|
||||
<Field label="出站 (GB)">
|
||||
<NumberInput value={form.traffic_out_gb} min={0} onChange={(value) => setForm({ ...form, traffic_out_gb: value || 0 })} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.snapshot_limit}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="NAT 端口映射数量">
|
||||
<NumberInput
|
||||
value={form.port_mapping_count}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.snapshot_limit}
|
||||
min={1}
|
||||
max={999}
|
||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="到期时间">
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
@@ -475,15 +711,41 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
const normalized = applyTemplateDefaults(form)
|
||||
const wantsIPv4 = !!normalized.assign_ipv4
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
// IPv4 and NAT are mutually exclusive
|
||||
const wantsNAT = wantsIPv4 ? false : normalized.assign_nat !== false
|
||||
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||
return {
|
||||
...normalized,
|
||||
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
||||
assign_ipv4: wantsIPv4,
|
||||
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
|
||||
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
||||
assign_ipv6: wantsIPv6,
|
||||
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
||||
ssh_auth_mode: sshAuthMode,
|
||||
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
|
||||
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
|
||||
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function validateSSHAuthInputs(form: CreateContainerRequest) {
|
||||
if (isWindowsTemplate(form.template_id)) return ''
|
||||
const mode = form.ssh_auth_mode || 'auto_password'
|
||||
if (mode === 'password') return sshPasswordError((form.ssh_password || '').trim())
|
||||
if (mode === 'key') return sshPublicKeyError(form.ssh_public_key || '')
|
||||
if (mode !== 'auto_password') return '请选择登录方式'
|
||||
return ''
|
||||
}
|
||||
|
||||
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||
if (!isWindowsTemplate(form.template_id)) return form
|
||||
return {
|
||||
@@ -509,5 +771,62 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||
return Math.min(Math.max(next, min), max ?? next)
|
||||
}
|
||||
|
||||
const createNetworkText = {
|
||||
zh: {
|
||||
publicIPv4: '公网 IPv4',
|
||||
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
||||
publicIPv6: '可分配 IPv6 前缀',
|
||||
use: '使用',
|
||||
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
||||
publicNAT: '公网 NAT',
|
||||
noNATPorts: '不分配 NAT 端口',
|
||||
},
|
||||
en: {
|
||||
publicIPv4: 'Public IPv4',
|
||||
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
||||
publicIPv6: 'Allocatable IPv6 Prefix',
|
||||
use: 'Use',
|
||||
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
||||
publicNAT: 'Public NAT',
|
||||
noNATPorts: 'No NAT ports will be assigned',
|
||||
},
|
||||
} as const
|
||||
|
||||
function formatIPv6StatusReason(reason: string | undefined, language: Language, fallback: string) {
|
||||
if (!reason) return fallback
|
||||
if (reason.includes('/128 single-address IPv6 is not assignable')) {
|
||||
return language === 'en'
|
||||
? 'No allocatable IPv6 prefix. The host only has a /128 single IPv6 address.'
|
||||
: '未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。'
|
||||
}
|
||||
if (reason.includes('outbound IPv6 connectivity test failed')) {
|
||||
return language === 'en'
|
||||
? reason
|
||||
: '宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。'
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
function formatAllocatableIPv4Count(count: number, language: Language) {
|
||||
return language === 'en'
|
||||
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
||||
: `检测到 ${count} 个可分配地址`
|
||||
}
|
||||
|
||||
function formatNATPortCount(count: number, language: Language) {
|
||||
return language === 'en'
|
||||
? `${count} NAT ports will be assigned`
|
||||
: `将分配 ${count} 个 NAT 端口`
|
||||
}
|
||||
|
||||
function symmetricLimit(a: number, b: number) {
|
||||
const left = Math.max(0, Number(a) || 0)
|
||||
const right = Math.max(0, Number(b) || 0)
|
||||
if (left === right) return left
|
||||
if (left === 0) return right
|
||||
if (right === 0) return left
|
||||
return Math.min(left, right)
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type DialogType = 'confirm' | 'alert'
|
||||
|
||||
@@ -20,6 +21,7 @@ const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
||||
const { t } = useLanguage()
|
||||
|
||||
const confirm = useCallback((title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -50,7 +52,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
}`}>
|
||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{dialog.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
||||
{dialog.type === 'alert' && (
|
||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -58,7 +60,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-gray-600">{dialog.message}</p>
|
||||
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
||||
{dialog.type === 'confirm' && (
|
||||
@@ -66,7 +68,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
取消
|
||||
{t('取消')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -77,7 +79,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
: 'bg-black text-white hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{dialog.type === 'confirm' ? '确认' : '确定'}
|
||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import { useState } from 'react'
|
||||
import AutoTranslate from './AutoTranslate'
|
||||
import BrowserDialogTranslator from './BrowserDialogTranslator'
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useId } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
@@ -9,17 +9,27 @@ export type ChartPoint = {
|
||||
value: number
|
||||
}
|
||||
|
||||
export type ResourceChartSeries = {
|
||||
label: string
|
||||
points: ChartPoint[]
|
||||
current?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type ResourceChartConfig = {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
points: ChartPoint[]
|
||||
current: number
|
||||
series?: ResourceChartSeries[]
|
||||
detail?: string
|
||||
max?: number
|
||||
unitLabel?: string
|
||||
formatValue: (value: number) => string
|
||||
}
|
||||
|
||||
const chartPalette = ['#2563eb', '#16a34a', '#d97706', '#dc2626']
|
||||
|
||||
const rangeLabels: Record<StatsRangeKey, string> = {
|
||||
'30m': '30分钟',
|
||||
'1h': '1小时',
|
||||
@@ -77,36 +87,52 @@ export default function ResourceStatsPanel({
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2">
|
||||
{charts.map((chart, index) => (
|
||||
<DetailedChart key={chart.title} chart={chart} className={chartBorderClass(index)} />
|
||||
<DetailedChart key={chart.title} chart={chart} range={range} className={chartBorderClass(index)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) {
|
||||
const values = chart.points.map((point) => point.value)
|
||||
const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
|
||||
const peak = values.length > 0 ? Math.max(...values) : 0
|
||||
function DetailedChart({ chart, range, className }: { chart: ResourceChartConfig; range: StatsRangeKey; className: string }) {
|
||||
const series = chart.series?.length
|
||||
? chart.series
|
||||
: [{ label: chart.title, points: chart.points, current: chart.current }]
|
||||
const primaryStats = getSeriesStats(series[0], chart.current)
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
|
||||
<span>{chart.title}</span>
|
||||
</div>
|
||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-right">
|
||||
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
||||
<Stat label="平均" value={chart.formatValue(avg)} />
|
||||
<Stat label="峰值" value={chart.formatValue(peak)} />
|
||||
</div>
|
||||
{series.length > 1 ? (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-right sm:shrink-0">
|
||||
{series.map((item, index) => (
|
||||
<SeriesStat
|
||||
key={item.label}
|
||||
color={item.color || chartPalette[index % chartPalette.length]}
|
||||
label={item.label}
|
||||
stats={getSeriesStats(item, item.current)}
|
||||
formatValue={chart.formatValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 text-right sm:shrink-0">
|
||||
<Stat label="当前" value={chart.formatValue(primaryStats.current)} />
|
||||
<Stat label="平均" value={chart.formatValue(primaryStats.avg)} />
|
||||
<Stat label="峰值" value={chart.formatValue(primaryStats.peak)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LineAreaChart
|
||||
points={chart.points}
|
||||
series={series}
|
||||
range={range}
|
||||
max={chart.max}
|
||||
formatValue={chart.formatValue}
|
||||
unitLabel={chart.unitLabel}
|
||||
@@ -115,6 +141,33 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
||||
)
|
||||
}
|
||||
|
||||
function SeriesStat({
|
||||
color,
|
||||
label,
|
||||
stats,
|
||||
formatValue,
|
||||
}: {
|
||||
color: string
|
||||
label: string
|
||||
stats: { current: number; avg: number; peak: number }
|
||||
formatValue: (value: number) => string
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-[104px]">
|
||||
<div className="flex items-center justify-end gap-1 text-[10px] text-gray-400 dark:text-gray-500">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">
|
||||
{formatValue(stats.current)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums whitespace-nowrap">
|
||||
均 {formatValue(stats.avg)} / 峰 {formatValue(stats.peak)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -124,19 +177,33 @@ function Stat({ label, value }: { label: string; value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getSeriesStats(series: ResourceChartSeries, fallbackCurrent = 0) {
|
||||
const values = series.points
|
||||
.map((point) => point.value)
|
||||
.filter((value) => Number.isFinite(value))
|
||||
const current = Number.isFinite(series.current) ? Number(series.current) : fallbackCurrent
|
||||
const samples = values.length > 0 ? values : [current]
|
||||
const avg = samples.reduce((sum, value) => sum + value, 0) / samples.length
|
||||
const peak = Math.max(current, ...samples, 0)
|
||||
return { current, avg, peak }
|
||||
}
|
||||
|
||||
function LineAreaChart({
|
||||
points,
|
||||
series,
|
||||
range,
|
||||
max,
|
||||
formatValue,
|
||||
unitLabel,
|
||||
}: {
|
||||
points: ChartPoint[]
|
||||
series: ResourceChartSeries[]
|
||||
range: StatsRangeKey
|
||||
max?: number
|
||||
formatValue: (value: number) => string
|
||||
unitLabel?: string
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
const gradientId = `resource-chart-fill-${useId().replace(/:/g, '')}`
|
||||
|
||||
const width = 520
|
||||
const height = 150
|
||||
@@ -146,21 +213,21 @@ function LineAreaChart({
|
||||
const bottom = 28
|
||||
const innerWidth = width - left - right
|
||||
const innerHeight = height - top - bottom
|
||||
const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }]
|
||||
const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1)
|
||||
const minTs = values[0]?.ts || Date.now()
|
||||
const maxTs = values[values.length - 1]?.ts || minTs + 1
|
||||
const span = Math.max(maxTs - minTs, 1)
|
||||
|
||||
const coords = values.map((point, index) => {
|
||||
const x = left + ((point.ts - minTs) / span) * innerWidth
|
||||
const y = top + innerHeight - (point.value / maxValue) * innerHeight
|
||||
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
|
||||
const now = Date.now()
|
||||
const chartSeries = series.map((item) => {
|
||||
const validPoints = item.points.filter((point) => Number.isFinite(point.ts) && Number.isFinite(point.value))
|
||||
return {
|
||||
...item,
|
||||
points: validPoints.length > 0
|
||||
? validPoints
|
||||
: [{ ts: now, value: Number.isFinite(item.current) ? Number(item.current) : 0 }],
|
||||
}
|
||||
})
|
||||
const fallbackX = left
|
||||
const fallbackY = top + innerHeight
|
||||
const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}`
|
||||
const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}`
|
||||
const allPoints = chartSeries.flatMap((item) => item.points)
|
||||
const maxValue = Math.max(max || 0, ...allPoints.map((point) => point.value), 1)
|
||||
const maxTs = now
|
||||
const minTs = now - statsRanges[range]
|
||||
const span = Math.max(maxTs - minTs, 1)
|
||||
const yTicks = [1, 0.5, 0]
|
||||
const xTicks = [0, 0.5, 1]
|
||||
|
||||
@@ -171,11 +238,13 @@ function LineAreaChart({
|
||||
const lineStroke = isDark ? '#f9fafb' : '#444'
|
||||
const gradientTop = isDark ? '#f9fafb' : '#555'
|
||||
const gradientBottom = isDark ? '#374151' : '#555'
|
||||
const primaryLine = buildLine(chartSeries[0]?.points || [{ ts: now, value: 0 }], minTs, span, left, top, innerWidth, innerHeight, maxValue)
|
||||
const area = `${left},${top + innerHeight} ${primaryLine} ${left + innerWidth},${top + innerHeight}`
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
@@ -214,12 +283,45 @@ function LineAreaChart({
|
||||
|
||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
|
||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
|
||||
<polygon points={area} fill="url(#resource-chart-fill)" />
|
||||
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
{chartSeries.length === 1 && <polygon points={area} fill={`url(#${gradientId})`} />}
|
||||
{chartSeries.map((item, index) => (
|
||||
<polyline
|
||||
key={item.label || index}
|
||||
points={buildLine(item.points, minTs, span, left, top, innerWidth, innerHeight, maxValue)}
|
||||
fill="none"
|
||||
stroke={item.color || (chartSeries.length === 1 ? lineStroke : chartPalette[index % chartPalette.length])}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function buildLine(
|
||||
points: ChartPoint[],
|
||||
minTs: number,
|
||||
span: number,
|
||||
left: number,
|
||||
top: number,
|
||||
innerWidth: number,
|
||||
innerHeight: number,
|
||||
maxValue: number,
|
||||
) {
|
||||
const coords = points.map((point) => {
|
||||
const x = left + ((point.ts - minTs) / span) * innerWidth
|
||||
const y = top + innerHeight - (point.value / maxValue) * innerHeight
|
||||
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
|
||||
})
|
||||
if (coords.length > 1) return coords.join(' ')
|
||||
|
||||
const [, yText] = (coords[0] || `${left},${top + innerHeight}`).split(',')
|
||||
const y = Number(yText)
|
||||
const safeY = Number.isFinite(y) ? y : top + innerHeight
|
||||
return `${left},${safeY} ${left + innerWidth},${safeY}`
|
||||
}
|
||||
|
||||
function chartBorderClass(index: number) {
|
||||
const right = index % 2 === 0 ? 'xl:border-r' : ''
|
||||
const top = index > 1 ? 'border-t' : ''
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { getVersion } from '../services/api'
|
||||
import AppIcon from './AppIcon'
|
||||
@@ -45,11 +46,23 @@ function GitHubIcon({ className = '' }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M128 170.6496A42.6496 42.6496 0 0 0 128 256V170.6496zM640 256a42.6496 42.6496 0 1 0 0-85.3504V256zM426.6496 128a42.6496 42.6496 0 0 0-85.2992 0h85.2992zM341.3504 213.3504a42.6496 42.6496 0 0 0 85.2992 0H341.3504z m56.6784 434.944a42.6496 42.6496 0 0 0 61.44-59.2896l-61.44 59.2896zM312.8832 367.4112a42.6496 42.6496 0 0 0-78.592 33.1776l78.592-33.1776z m220.4672 357.888a42.6496 42.6496 0 1 0 0 85.3504v-85.2992z m298.6496 85.3504a42.6496 42.6496 0 1 0 0-85.2992v85.2992z m-400.8448 66.2528a42.6496 42.6496 0 1 0 76.3392 38.1952l-76.288-38.1952z m251.4944-407.552l38.1952-19.0976a42.6496 42.6496 0 0 0-76.3392 0l38.144 19.0976z m175.2064 445.7472a42.6496 42.6496 0 1 0 76.288-38.1952l-76.288 38.1952zM586.1376 220.3648a42.6496 42.6496 0 1 0-84.1728-14.08l84.1728 14.08zM109.0048 735.2832a42.6496 42.6496 0 0 0 37.9904 76.4416l-37.9904-76.4416zM128 256h512V170.6496h-512V256z m213.3504-128v85.3504h85.2992V128H341.3504z m118.0672 461.0048a726.3232 726.3232 0 0 1-146.5344-221.5936l-78.592 33.1776a811.6224 811.6224 0 0 0 163.7376 247.7056l61.44-59.2896z m73.9328 221.696h298.6496v-85.3504h-298.6496v85.2992z m-25.856 104.3968l213.3504-426.7008-76.3392-38.144-213.3504 426.6496 76.3392 38.1952z m137.0112-426.7008l213.3504 426.7008 76.288-38.1952-213.2992-426.6496-76.3392 38.144zM501.9648 206.336C463.0016 438.6304 313.3952 633.7536 109.056 735.232l37.9904 76.4416c228.2496-113.4592 395.52-331.3152 439.1424-591.36L501.9648 206.336z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { logout, isSubUser } = useAuth()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { toggleLanguage, t } = useLanguage()
|
||||
const [version, setVersion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,7 +112,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||
title="切换侧边栏"
|
||||
title={t('切换侧边栏')}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -253,18 +266,29 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
<div className={collapsed ? 'space-y-1' : 'flex items-center gap-1'}>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`${collapsed ? 'w-full justify-center' : 'flex-1'} flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={t(theme === 'dark' ? '切换亮色模式' : '切换暗黑模式')}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className={`${collapsed ? 'w-full justify-center' : 'flex-1 justify-center'} flex items-center gap-2 rounded-md px-3 py-2.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title="Language"
|
||||
>
|
||||
<LanguageIcon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>Language</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
{version && (
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFB from '@novnc/novnc'
|
||||
import RFBModule from '@novnc/novnc/lib/rfb'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
type RFBConstructor = new (
|
||||
target: HTMLElement,
|
||||
url: string,
|
||||
options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }
|
||||
) => RFBInstance
|
||||
|
||||
interface RFBInstance extends EventTarget {
|
||||
scaleViewport: boolean
|
||||
resizeSession: boolean
|
||||
focusOnClick: boolean
|
||||
viewOnly: boolean
|
||||
qualityLevel: number
|
||||
compressionLevel: number
|
||||
background: string
|
||||
disconnect(): void
|
||||
sendCtrlAltDel(): void
|
||||
}
|
||||
|
||||
const RFB = resolveRFBConstructor(RFBModule)
|
||||
|
||||
function resolveRFBConstructor(moduleValue: unknown): RFBConstructor {
|
||||
if (typeof moduleValue === 'function') {
|
||||
return moduleValue as RFBConstructor
|
||||
}
|
||||
const maybeDefault = (moduleValue as { default?: unknown })?.default
|
||||
if (typeof maybeDefault === 'function') {
|
||||
return maybeDefault as RFBConstructor
|
||||
}
|
||||
throw new Error('noVNC RFB constructor is unavailable')
|
||||
}
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
@@ -10,7 +41,7 @@ interface WebVNCViewerProps {
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFB | null>(null)
|
||||
const rfbRef = useRef<RFBInstance | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
@@ -80,6 +111,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||
@@ -97,9 +129,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
rfb.qualityLevel = 6
|
||||
rfb.compressionLevel = 2
|
||||
rfb.background = '#050505'
|
||||
rfb.addEventListener('connect', () => {
|
||||
setStatus('connected')
|
||||
})
|
||||
rfb.addEventListener('connect', () => setStatus('connected'))
|
||||
rfb.addEventListener('disconnect', (event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||
@@ -133,35 +163,31 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}, [containerName])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-600" />
|
||||
<Monitor className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||
{status === 'connected' && <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||
title="发送 Ctrl+Alt+Del"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<button onClick={() => rfbRef.current?.sendCtrlAltDel()} className="inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-500 hover:bg-gray-200" title="发送 Ctrl+Alt+Del">
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<button onClick={connect} className="rounded p-1.5 text-xs text-gray-500 hover:bg-gray-200" title="重新连接">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
|
||||
<X className="w-4 h-4" />
|
||||
<button onClick={onClose} className="rounded p-1.5 text-gray-500 hover:bg-gray-200" title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { translateText } from '../utils/i18n'
|
||||
import { getLanguage, updateLanguage } from '../services/api'
|
||||
|
||||
export type Language = 'zh' | 'en'
|
||||
|
||||
interface LanguageContextValue {
|
||||
language: Language
|
||||
setLanguage: (language: Language) => void
|
||||
toggleLanguage: () => Promise<void>
|
||||
t: (value: string) => string
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined)
|
||||
function initialLanguage(): Language {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(initialLanguage)
|
||||
|
||||
const setLanguageLocal = (next: Language) => {
|
||||
setLanguageState(next)
|
||||
}
|
||||
|
||||
const setLanguage = (next: Language) => {
|
||||
setLanguageLocal(next)
|
||||
updateLanguage(next).catch(() => {})
|
||||
}
|
||||
|
||||
const value = useMemo<LanguageContextValue>(() => ({
|
||||
language,
|
||||
setLanguage,
|
||||
toggleLanguage: async () => {
|
||||
const next = language === 'zh' ? 'en' : 'zh'
|
||||
setLanguageLocal(next)
|
||||
try {
|
||||
const res = await updateLanguage(next)
|
||||
setLanguageLocal(res.data.data?.language || next)
|
||||
} catch {
|
||||
setLanguageLocal(language)
|
||||
}
|
||||
},
|
||||
t: (text: string) => language === 'en' ? translateText(text) : text,
|
||||
}), [language])
|
||||
|
||||
useEffect(() => {
|
||||
getLanguage()
|
||||
.then((res) => {
|
||||
const serverLanguage = res.data.data?.language
|
||||
if (serverLanguage === 'zh' || serverLanguage === 'en') {
|
||||
setLanguageLocal(serverLanguage)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language === 'en' ? 'en' : 'zh-CN'
|
||||
document.documentElement.dataset.language = language
|
||||
}, [language])
|
||||
|
||||
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within LanguageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { LanguageProvider } from './contexts/LanguageContext'
|
||||
import { DialogProvider } from './components/Dialog'
|
||||
import './index.css'
|
||||
|
||||
@@ -11,11 +12,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module '@novnc/novnc' {
|
||||
declare module '@novnc/novnc/lib/rfb' {
|
||||
export default class RFB extends EventTarget {
|
||||
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
|
||||
scaleViewport: boolean
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import api, { APIResponse, Container } from '../services/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
interface ApiKeyItem {
|
||||
@@ -63,8 +64,10 @@ const scopeGroups = [
|
||||
['dashboard:read', '控制面板'],
|
||||
['host:read', '主机资源'],
|
||||
['routing:read', '路由信息'],
|
||||
['routing:write', '路由配置'],
|
||||
['ipv6:read', 'IPv6 状态'],
|
||||
['task:read', '任务列表'],
|
||||
['task:delete', '删除任务'],
|
||||
['image:read', '镜像列表'],
|
||||
],
|
||||
},
|
||||
@@ -137,7 +140,9 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
endpoints: [
|
||||
['GET', '/api/v1/dashboard', '控制面板统计'],
|
||||
['GET', '/api/v1/host-info', '主机资源'],
|
||||
['GET', '/api/v1/routing', 'NAT/IPv6 路由'],
|
||||
['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'],
|
||||
['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'],
|
||||
['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'],
|
||||
['GET', '/api/v1/ipv6/status', 'IPv6 状态'],
|
||||
['GET', '/api/v1/tasks', '任务队列'],
|
||||
['DELETE', '/api/v1/tasks/{task_id}', '删除任务'],
|
||||
@@ -147,7 +152,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
title: '容器',
|
||||
endpoints: [
|
||||
['GET', '/api/v1/containers', '容器列表'],
|
||||
['POST', '/api/v1/containers/list', '容器列表(兼容旧接口)'],
|
||||
['POST', '/api/v1/containers/list', '容器列表(兼容 POST 写法)'],
|
||||
['POST', '/api/v1/containers', '创建容器'],
|
||||
['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'],
|
||||
['POST', '/api/v1/containers/{id}/start', '开机'],
|
||||
@@ -172,6 +177,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'],
|
||||
['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'],
|
||||
['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'],
|
||||
['GET', '/api/v1/containers/{id}/firewall', '获取防火墙设置'],
|
||||
['PUT', '/api/v1/containers/{id}/firewall', '更新防火墙设置'],
|
||||
['GET', '/api/v1/snapshots', '快照总览'],
|
||||
['GET', '/api/v1/containers/{id}/snapshots', '容器快照'],
|
||||
['POST', '/api/v1/containers/{id}/snapshots', '创建快照'],
|
||||
@@ -232,6 +239,7 @@ const emptyForm = (): ApiKeyForm => ({
|
||||
})
|
||||
|
||||
export default function ApiIntegration() {
|
||||
const { t } = useLanguage()
|
||||
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
||||
const [containers, setContainers] = useState<Container[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -323,7 +331,7 @@ export default function ApiIntegration() {
|
||||
}
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
if (!window.confirm('确定删除这个 API Key 吗?')) return
|
||||
if (!window.confirm(t('确定删除这个 API Key 吗?'))) return
|
||||
try {
|
||||
await api.delete(`/api-keys/${id}`)
|
||||
setKeys(prev => prev.filter(k => k.id !== id))
|
||||
@@ -501,7 +509,6 @@ export default function ApiIntegration() {
|
||||
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
|
||||
<div>curl -X GET {BASE_URL}/api/v1/containers -H "X-API-Key: clicd_sk_xxxx"</div>
|
||||
<div className="mt-2 text-gray-400">curl -X GET {BASE_URL}/api/v1/dashboard -H "Authorization: Bearer clicd_sk_xxxx"</div>
|
||||
<div className="mt-2 text-amber-300">旧版 /api/containers/list 已兼容,但新接入请使用 GET /api/v1/containers</div>
|
||||
</div>
|
||||
|
||||
{endpointGroups.map(group => (
|
||||
@@ -725,18 +732,36 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 100,
|
||||
network_up_mbps: 20,
|
||||
monthly_traffic_gb: 0,
|
||||
traffic_mode: 'total',
|
||||
traffic_in_gb: 0,
|
||||
traffic_out_gb: 0,
|
||||
io_speed_mbps: 0,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
extra_ports: [8080],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
assign_ipv6: true,
|
||||
ipv6_count: 1,
|
||||
ipv6_addresses: [],
|
||||
ssh_auth_mode: 'auto_password',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
expires_at: '',
|
||||
},
|
||||
'POST /api/v1/containers/{id}/reinstall': { template_id: 'debian-bookworm' },
|
||||
'POST /api/v1/containers/{id}/reinstall': {
|
||||
template_id: 'debian-bookworm',
|
||||
ssh_auth_mode: 'keep',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/traffic-limit': {
|
||||
traffic_mode: 'total',
|
||||
monthly_traffic_gb: 100,
|
||||
@@ -746,8 +771,12 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'PUT /api/v1/containers/{id}/resource-limit': {
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
io_speed_mbps: 0,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 100,
|
||||
network_up_mbps: 20,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
network_bw_mbps: 20,
|
||||
io_speed_mbps: 30,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
||||
@@ -773,7 +802,42 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
|
||||
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
|
||||
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
|
||||
'PUT /api/v1/routing': {
|
||||
items: [
|
||||
{
|
||||
address: '203.0.113.10',
|
||||
interface: 'eth0',
|
||||
prefix_len: 32,
|
||||
gateway: '203.0.113.1',
|
||||
},
|
||||
],
|
||||
ipv6_prefixes: [
|
||||
{
|
||||
address: '2001:db8:100::2',
|
||||
prefix: '2001:db8:100::/64',
|
||||
prefix_len: 64,
|
||||
interface: 'eth0',
|
||||
gateway: '2001:db8:100::1',
|
||||
},
|
||||
],
|
||||
},
|
||||
'POST /api/v1/routing/ipv4-scan': {
|
||||
cidr: '203.0.113.0/29',
|
||||
interface: 'eth0',
|
||||
gateway: '203.0.113.1',
|
||||
verify: true,
|
||||
limit: 64,
|
||||
},
|
||||
'POST /api/v1/security/check': { container_name: 'example-vm' },
|
||||
'PUT /api/v1/containers/{id}/firewall': {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: '', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: '', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: '', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/security/settings': { auto_shutdown: false },
|
||||
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
|
||||
'POST /api/v1/batch-create': {
|
||||
@@ -785,13 +849,28 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
assign_nat: true,
|
||||
port_mapping_count: 2,
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
assign_ipv6: true,
|
||||
ipv6_count: 1,
|
||||
ipv6_addresses: [],
|
||||
ssh_auth_mode: 'key',
|
||||
ssh_public_key: 'ssh-ed25519 AAAA... user@example',
|
||||
},
|
||||
],
|
||||
},
|
||||
'POST /api/v1/batch-action': { action: 'restart', containers: [5], template_id: '' },
|
||||
'POST /api/v1/batch-action': {
|
||||
action: 'reinstall',
|
||||
containers: [5],
|
||||
template_id: 'debian-bookworm',
|
||||
ssh_auth_mode: 'keep',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
},
|
||||
'POST /api/v1/ssh-ticket': { container_name: 'example-vm' },
|
||||
'POST /api/v1/vnc-ticket': { container_name: 'kvm-demo' },
|
||||
'POST /api/v1/sub-user/create': { container_name: 'example-vm' },
|
||||
@@ -835,13 +914,33 @@ const responseSamples: Record<string, unknown> = {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
ipv6: { used: 31, remaining: 'large', total: 'large' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
ipv4_assignments: [{ container_id: 5, container_name: 'example-vm', address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
nat4_mappings: [
|
||||
{ container_id: 5, container_name: 'example-vm', status: 'running', ip: '10.0.0.10', host_port: 22004, container_port: 22, protocol: 'tcp' },
|
||||
],
|
||||
ipv6_assignments: [{ container_id: 5, container_name: 'example-vm', address: '2001:db8:100::1005', prefix_len: 64, interface: 'eth0' }],
|
||||
},
|
||||
},
|
||||
'PUT /api/v1/routing': {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
|
||||
},
|
||||
},
|
||||
'POST /api/v1/routing/ipv4-scan': {
|
||||
success: true,
|
||||
data: [
|
||||
{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1', status: 'available', usable: true, reason: '' },
|
||||
],
|
||||
},
|
||||
'GET /api/v1/ipv6/status': {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -950,6 +1049,31 @@ const responseSamples: Record<string, unknown> = {
|
||||
data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }],
|
||||
},
|
||||
'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] },
|
||||
'GET /api/v1/containers/{id}/firewall': {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/firewall': {
|
||||
success: true,
|
||||
message: 'Firewall updated',
|
||||
data: {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/snapshots': { success: true, data: null },
|
||||
'GET /api/v1/containers/{id}/snapshots': {
|
||||
success: true,
|
||||
@@ -987,11 +1111,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/swap': { success: true, message: 'SWAP 已调整为 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/swap': { success: true, message: 'SWAP adjusted to 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/batch-create': { success: true, data: ['task-12'] },
|
||||
'POST /api/v1/batch-action': { success: true, data: ['task-13'] },
|
||||
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
|
||||
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
|
||||
'POST /api/v1/sub-user/create': {
|
||||
success: true,
|
||||
message: 'Sub-user created',
|
||||
@@ -1054,10 +1178,36 @@ function examplePathFor(path: string) {
|
||||
}
|
||||
|
||||
function endpointNoteFor(key: string) {
|
||||
if (key.includes('/vnc-ticket')) return 'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。'
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) return '该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。'
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) return '样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。'
|
||||
return ''
|
||||
const notes: string[] = []
|
||||
if (key === 'POST /api/v1/containers') {
|
||||
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
||||
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/reinstall') {
|
||||
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-create') {
|
||||
notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
||||
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/firewall') {
|
||||
notes.push('Backward compatible: default_action is optional; if omitted, the existing policy is kept. rule.network is optional; if omitted, it is treated as ipv4. default_action: DROP=deny unmatched traffic, ACCEPT=allow unmatched traffic. network: ipv4=IPv4 NAT/public IPv4, ipv6=IPv6, all=apply to both IPv4 and IPv6. For NAT inbound rules, port is the container internal port, not the host public port.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-action') {
|
||||
notes.push('When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/routing') {
|
||||
notes.push('Updating NAT4 port range and public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
|
||||
}
|
||||
if (key === 'POST /api/v1/routing/ipv4-scan') {
|
||||
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||
}
|
||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
|
||||
return notes.join(' ')
|
||||
}
|
||||
|
||||
function defaultResponseFor(method: HttpMethod) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -390,14 +390,13 @@ export default function Containers() {
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pageContainers.map((container) => {
|
||||
const isRunning = container.status === 'running'
|
||||
const isInitializing = container.status === 'initializing'
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||
const isPlaceholder = !!container.isPlaceholder
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const usage = usageByName[container.name]
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
|
||||
const cpuPct = isRunning
|
||||
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
|
||||
? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))
|
||||
: 0
|
||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||
? usage.memory_total_bytes
|
||||
@@ -437,7 +436,7 @@ export default function Containers() {
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top">
|
||||
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
|
||||
<StatusBadge running={isRunning} initializing={isInitializing} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
|
||||
</td>
|
||||
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -483,7 +482,7 @@ export default function Containers() {
|
||||
try {
|
||||
const { default: api } = await import('../services/api')
|
||||
await api.delete(`/tasks/${task.id}`)
|
||||
fetchData()
|
||||
await Promise.all([fetchData(), fetchTasks()])
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-red-200 text-[11px] text-red-600 hover:bg-red-50 transition-colors whitespace-nowrap"
|
||||
@@ -581,7 +580,7 @@ type DisplayContainer = Container & {
|
||||
createTask?: Task
|
||||
}
|
||||
|
||||
function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
||||
if (policyBlocked) {
|
||||
return (
|
||||
@@ -640,6 +639,15 @@ function StatusBadge({ running, task, placeholder, policyBlocked }: { running: b
|
||||
)
|
||||
}
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
@@ -694,6 +702,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
ram_mb: cfg.ram_mb,
|
||||
disk_gb: cfg.disk_gb,
|
||||
network_bw_mbps: cfg.network_bw_mbps,
|
||||
network_down_mbps: cfg.network_down_mbps,
|
||||
network_up_mbps: cfg.network_up_mbps,
|
||||
monthly_traffic_gb: cfg.monthly_traffic_gb,
|
||||
traffic_mode: cfg.traffic_mode || 'total',
|
||||
traffic_in_gb: cfg.traffic_in_gb || 0,
|
||||
@@ -702,16 +712,23 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
traffic_used_tx: 0,
|
||||
traffic_reset_date: '',
|
||||
io_speed_mbps: cfg.io_speed_mbps,
|
||||
io_read_mbps: cfg.io_read_mbps,
|
||||
io_write_mbps: cfg.io_write_mbps,
|
||||
status: 'creating',
|
||||
ip: '',
|
||||
public_ipv4s: [],
|
||||
ipv6: '',
|
||||
ipv6_prefix_len: 0,
|
||||
ipv6_interface: '',
|
||||
ipv6_addresses: [],
|
||||
vnc_port: 0,
|
||||
ssh_port: 0,
|
||||
ssh_password: '',
|
||||
port_mappings: [],
|
||||
port_mapping_limit: 2,
|
||||
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||
firewall_enabled: false,
|
||||
firewall_default_action: 'DROP',
|
||||
firewall_rules: [],
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
expires_at: cfg.expires_at,
|
||||
|
||||
@@ -13,8 +13,12 @@ type HostMetricPoint = {
|
||||
ts: number
|
||||
cpu: number
|
||||
memory: number
|
||||
network: number
|
||||
diskIO: number
|
||||
network?: number
|
||||
networkRx?: number
|
||||
networkTx?: number
|
||||
diskIO?: number
|
||||
diskRead?: number
|
||||
diskWrite?: number
|
||||
}
|
||||
|
||||
const hostHistoryKey = 'clicd_host_metric_history_v2'
|
||||
@@ -58,6 +62,10 @@ export default function Dashboard() {
|
||||
|
||||
const filtered = filterHistory(history, range)
|
||||
const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0
|
||||
const networkRxBps = host?.network.rx_bps || 0
|
||||
const networkTxBps = host?.network.tx_bps || 0
|
||||
const diskReadBps = host?.disk_io.read_bps || 0
|
||||
const diskWriteBps = host?.disk_io.write_bps || 0
|
||||
const networkBps = (host?.network.rx_bps || 0) + (host?.network.tx_bps || 0)
|
||||
const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0)
|
||||
|
||||
@@ -85,16 +93,24 @@ export default function Dashboard() {
|
||||
icon: <Network className="w-5 h-5" />,
|
||||
current: networkBps,
|
||||
points: toChartPoints(filtered, 'network'),
|
||||
series: [
|
||||
{ label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' },
|
||||
{ label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' },
|
||||
],
|
||||
formatValue: formatRate,
|
||||
detail: `入 ${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`,
|
||||
detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)}`,
|
||||
},
|
||||
{
|
||||
title: '磁盘IO',
|
||||
icon: <HardDrive className="w-5 h-5" />,
|
||||
current: diskIOBps,
|
||||
points: toChartPoints(filtered, 'diskIO'),
|
||||
series: [
|
||||
{ label: '读', points: toChartPoints(filtered, 'diskRead'), current: diskReadBps, color: '#d97706' },
|
||||
{ label: '写', points: toChartPoints(filtered, 'diskWrite'), current: diskWriteBps, color: '#dc2626' },
|
||||
],
|
||||
formatValue: formatRate,
|
||||
detail: `读 ${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`,
|
||||
detail: `读 ${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)}`,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -157,12 +173,20 @@ function SummaryCard({
|
||||
}
|
||||
|
||||
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
|
||||
const networkRx = host.network.rx_bps || 0
|
||||
const networkTx = host.network.tx_bps || 0
|
||||
const diskRead = host.disk_io.read_bps || 0
|
||||
const diskWrite = host.disk_io.write_bps || 0
|
||||
const point: HostMetricPoint = {
|
||||
ts: Date.now(),
|
||||
cpu: clamp(host.cpu.usage_pct),
|
||||
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
|
||||
network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0),
|
||||
diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0),
|
||||
network: networkRx + networkTx,
|
||||
networkRx,
|
||||
networkTx,
|
||||
diskIO: diskRead + diskWrite,
|
||||
diskRead,
|
||||
diskWrite,
|
||||
}
|
||||
|
||||
setHistory((prev) => {
|
||||
@@ -190,8 +214,11 @@ function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) {
|
||||
return history.filter((point) => point.ts >= cutoff)
|
||||
}
|
||||
|
||||
function toChartPoints<T extends keyof Omit<HostMetricPoint, 'ts'>>(history: HostMetricPoint[], key: T): ChartPoint[] {
|
||||
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
|
||||
function toChartPoints(history: HostMetricPoint[], key: keyof Omit<HostMetricPoint, 'ts'>): ChartPoint[] {
|
||||
return history.flatMap((point) => {
|
||||
const value = Number(point[key])
|
||||
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function clamp(value: number) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user