Compare commits

...

31 Commits

Author SHA1 Message Date
MengMengCode 0e3c059236 ci: fix release workflow checkout 2026-07-16 16:58:19 +08:00
MengMengCode 61137b837d release: v1.1.22 2026-07-16 16:58:19 +08:00
Meng Meng 596bf86477 Merge pull request #27 from MengMengCode/dependabot/go_modules/backend/golang.org/x/crypto-0.52.0
build(deps): bump golang.org/x/crypto from 0.45.0 to 0.52.0 in /backend
2026-07-16 15:15:39 +08:00
MengMengCode 47a09aa177 release: v1.1.21 2026-07-16 15:13:53 +08:00
MengMengCode 2456b65ce2 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-07-16 15:13:40 +08:00
MengMengCode 292686a19a Fix some problems. 2026-07-16 15:13:35 +08:00
dependabot[bot] 9eb7c322cf build(deps): bump golang.org/x/crypto from 0.45.0 to 0.52.0 in /backend
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.45.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 11:56:11 +00:00
Meng Meng 5ec62ca732 Merge pull request #26 from StarVM-OpenSource/main
从UTF8-BOM转UTF8以修复魔方财务使用此模块后无法正常被下游拉取信息的问题
2026-06-29 09:06:10 +08:00
a79df0d2dd Merge branch 'MengMengCode:main' into main 2026-06-26 22:20:52 +08:00
cc8fdbfede 从UTF8-BOM转UTF8以修复魔方财务使用此模块后无法正常被下游拉取信息的问题 2026-06-26 20:44:29 +08:00
MengMengCode 702d6975e5 release: v1.1.20 2026-06-17 21:08:13 +08:00
MengMengCode 84d98e40c6 FIX #16 2026-06-17 21:06:58 +08:00
MengMengCode fd974d95b9 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-17 20:41:14 +08:00
MengMengCode cd258fd6ac FIX #20 2026-06-17 20:41:11 +08:00
Meng Meng 0c2dd457d4 Merge pull request #22 from MengMengCode/dependabot/npm_and_yarn/frontend/form-data-4.0.6
build(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend
2026-06-17 20:03:18 +08:00
MengMengCode 92e846eecc IPV6 fix 2026-06-17 19:56:09 +08:00
dependabot[bot] a1d9ce8b1c build(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 11:22:14 +00:00
MengMengCode 49d8093f45 网络流量、磁盘 IO 图表显示优化 2026-06-17 19:16:28 +08:00
MengMengCode 98ed716225 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-17 18:42:19 +08:00
MengMengCode 78276d303b chore(docs): 升级 esbuild 至 0.28.1 并更新忽略规则 2026-06-17 18:42:12 +08:00
Meng Meng 30d2a4f4da Merge pull request #15 from StarVM-OpenSource/main
更新一键安装certbot脚本+增加更完整的webssh逻辑
2026-06-14 12:51:18 +08:00
a54e03b924 更新一键安装certbot脚本+增加更完整的webssh逻辑 2026-06-14 03:15:02 +08:00
Meng Meng 4cdc6e68ba Merge pull request #14 from MengMengCode/alert-autofix-29
Potential fix for code scanning alert no. 29: DOM text reinterpreted as HTML
2026-06-13 21:04:48 +08:00
Meng Meng 2ed42992ed Potential fix for code scanning alert no. 29: DOM text reinterpreted as HTML
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-13 21:04:10 +08:00
Meng Meng 4dfd7c0885 Merge pull request #13 from StarVM-OpenSource/main
修复防火墙无法创建 获取规则问题 优化防火墙创建规则UI
2026-06-13 12:01:43 +08:00
5ed5b4509d Merge branch 'main' of https://github.com/StarVM-OpenSource/CLICD-MoFang 2026-06-13 02:52:47 +08:00
18f297b988 修复防火墙功能 2026-06-13 02:49:23 +08:00
d5a236943b 修复防火墙至不支持的方法 等待Claude修复 2026-06-13 00:52:19 +08:00
MengMengCode c54f92f892 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-12 21:55:17 +08:00
MengMengCode 86f0d079ab update docs 2026-06-12 21:55:15 +08:00
Meng Meng 3a65d5d24a Remove version number from README title
Removed version number from the title in README.md
2026-06-12 16:03:38 +08:00
64 changed files with 4127 additions and 967 deletions
+42 -10
View File
@@ -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
@@ -55,16 +61,21 @@ jobs:
fi
- name: Build CLICD
env:
CLICD_GOARCH: ${{ matrix.goarch }}
run: bash build.sh
- 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
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: |
@@ -87,13 +98,34 @@ jobs:
- 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 }}
GH_REPO: ${{ github.repository }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
+3
View File
@@ -13,6 +13,7 @@ backend/internal/server/web/*
# Build artifacts
/build/
/dist/
Mofang/*.zip
*.exe
*.dll
@@ -68,3 +69,5 @@ linux.txt
push-release.ps1
deploy.ps1
backend/clicd
api.md
deploy-arm.ps1
+176 -2
View File
@@ -10,6 +10,7 @@ README.md
handlers/
webssh.php
templates/
firewall.html
info.html
nat.html
```
@@ -93,11 +94,12 @@ Content-Type: application/json
## 客户区页面
模块提供个客户区选项卡:
模块提供个客户区选项卡:
```text
实例信息
NAT转发
防火墙
```
客户区按钮提供:
@@ -197,6 +199,65 @@ DELETE /api/v1/containers/{id}/port-mappings/{index}
}
```
## 防火墙
防火墙是独立客户区页面,支持:
- 查看防火墙启用状态、默认动作和规则列表
- 启用 / 停用防火墙
- 设置默认动作:未匹配拒绝或未匹配放行
- 添加规则
- 编辑规则
- 删除规则
- 单独启用 / 停用某条规则
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 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 按钮会调用:
@@ -252,6 +313,8 @@ https://www.example.com
| 变更资源 | `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 权限
@@ -269,6 +332,7 @@ container:password
container:traffic
container:resize
container:port
container:firewall
task:read
ssh-ticket:create
```
@@ -316,6 +380,22 @@ curl --location --request PUT \
--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
@@ -336,6 +416,41 @@ curl --location --request POST \
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 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
@@ -344,7 +459,66 @@ CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
### WebSSH 打不开或提示不安全 WebSocket
### 防火墙
防火墙是独立客户区页面,支持:
- 查看防火墙启用状态、默认动作和规则列表
- 启用 / 停用防火墙
- 设置默认动作:未匹配拒绝或未匹配放行
- 添加规则
- 编辑规则
- 删除规则
- 单独启用 / 停用某条规则
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 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:
+29 -8
View File
@@ -1,4 +1,4 @@
<?php
<?php
use think\Db;
@@ -40,7 +40,7 @@ function clicd_MetaData()
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5 and DeepSeek V4',
'APIVersion' => '1.1',
'HelpDoc' => 'https://github.com/MengMengCode/CLICD',
'version' => '1.0.5',
'version' => '1.0.11',
];
}
@@ -365,7 +365,10 @@ function clicd_webssh_url($params, $ticket, $containerName)
$host = parse_url($baseUrl, PHP_URL_HOST);
$port = parse_url($baseUrl, PHP_URL_PORT);
$wsBase = $scheme . '://' . $host . ($port ? ':' . $port : '');
$wsUrl = $wsBase . '/api/ssh?container=' . rawurlencode((string)$containerName);
$wsUrl = $wsBase
. '/api/ssh?container=' . rawurlencode((string)$containerName)
. '&container_name=' . rawurlencode((string)$containerName)
. '&ticket=' . rawurlencode((string)$ticket);
$siteScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$siteHost = $_SERVER['HTTP_HOST'] ?? '';
@@ -374,6 +377,7 @@ function clicd_webssh_url($params, $ticket, $containerName)
return $handler
. '?ws=' . rawurlencode($wsUrl)
. '&protocol=' . rawurlencode('clicd-ticket.' . (string)$ticket)
. '&ticket=' . rawurlencode((string)$ticket)
. '&container=' . rawurlencode((string)$containerName);
}
@@ -626,9 +630,24 @@ function clicd_request_value($key, $default = '')
function clicd_json_input()
{
$input = [];
if (!empty($_POST) && is_array($_POST)) {
$input = $_POST;
}
$raw = file_get_contents('php://input');
$data = json_decode((string)$raw, true);
return is_array($data) ? $data : [];
if (is_array($data)) {
return array_merge($input, $data);
}
$form = [];
parse_str((string)$raw, $form);
if (!empty($form) && is_array($form)) {
return array_merge($input, $form);
}
return $input;
}
function clicd_param_value($data, $key, $default = '')
@@ -1404,7 +1423,13 @@ function clicd_ClientButton($params)
function clicd_webssh($params)
{
$container = [];
$containerName = clicd_container_name($params);
clicd_container_api_id($params, $container);
if (!empty($container['name'])) {
$containerName = (string)$container['name'];
}
$res = clicd_request($params, '/api/v1/ssh-ticket', ['container_name' => $containerName], 'POST', 30);
if (!clicd_success($res)) {
return ['status' => 'error', 'msg' => clicd_message($res, 'WebSSH ticket create failed')];
@@ -1689,7 +1714,3 @@ function clicd_ClientAreaOutput($params, $key)
],
];
}
+18 -3
View File
@@ -1,9 +1,14 @@
<?php
<?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 ($ws === '' || $protocol === '') {
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";
@@ -64,6 +69,7 @@ if ($ws === '' || $protocol === '') {
(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');
@@ -189,8 +195,17 @@ if ($ws === '' || $protocol === '') {
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
}
function websocketProtocolValue(value) {
value = String(value || '');
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value) ? value : '';
}
try {
socket = new WebSocket(wsUrl, protocol);
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');
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
<style>
<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}
+1 -1
View File
@@ -1,4 +1,4 @@
<style>
<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}
+1 -1
View File
@@ -2,7 +2,7 @@
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
</p>
<h1 align="center">CLICD <sub><sup>v1.1.18</sup></sub></h1>
<h1 align="center">CLICD <sub></sub></h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
+4 -6
View File
@@ -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
View File
@@ -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=
+8 -3
View File
@@ -582,9 +582,14 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
return
}
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
// Try random ports
for tries := 0; tries < 100; tries++ {
port := 10000 + (int(time.Now().UnixNano()) % 55535)
start, end := config.NATPortRange()
capacity := end - start + 1
offset := 0
if capacity > 0 {
offset = int(time.Now().UnixNano() % int64(capacity))
}
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
+355 -17
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
@@ -219,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
@@ -397,7 +401,7 @@ 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.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
@@ -437,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 {
@@ -574,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") {
@@ -582,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
}
@@ -607,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
@@ -724,7 +915,7 @@ func isVirtualBlockDevice(name, model, vendor string) bool {
}
for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft",
"amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume",
} {
if strings.Contains(lower, token) {
return true
@@ -1153,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 {
@@ -1301,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 {
@@ -1368,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"
}
@@ -1375,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"
}
@@ -1394,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,
@@ -1446,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", ""),
@@ -1454,7 +1779,7 @@ 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", ""),
@@ -1467,6 +1792,19 @@ 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"
+34
View File
@@ -41,3 +41,37 @@ func TestCertbotVersionAtLeast54(t *testing.T) {
}
}
}
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)
}
}
}
+26 -2
View File
@@ -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.
+30 -7
View File
@@ -17,6 +17,11 @@ 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"`
@@ -53,6 +58,7 @@ type ipv6Route struct {
type routingResponse struct {
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"`
@@ -64,9 +70,10 @@ type routingResponse struct {
}
type routingPoolsRequest struct {
Addresses *[]string `json:"addresses"`
Items *[]config.PublicIPv4Assignment `json:"items"`
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
Addresses *[]string `json:"addresses"`
Items *[]config.PublicIPv4Assignment `json:"items"`
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
NAT4PortRange *nat4PortRange `json:"nat4_port_range"`
}
type publicIPv4ScanRequest struct {
@@ -120,13 +127,12 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
ipv4Assignments := make([]ipv4Route, 0)
ipv6Assignments := make([]ipv6Route, 0)
const nat4StartPort = 20000
const nat4EndPort = 65535
nat4StartPort, nat4EndPort := config.NATPortRange()
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{
@@ -189,7 +195,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
totalNAT4Ports := config.NATPortCapacity()
nat4Used := len(usedPorts)
nat4Remaining := totalNAT4Ports - nat4Used
if nat4Remaining < 0 {
@@ -216,6 +222,10 @@ func handleRoutingGet(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),
@@ -246,6 +256,19 @@ func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
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 {
+127 -48
View File
@@ -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
@@ -232,13 +236,17 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
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),
}
}
@@ -264,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),
"")
}
}
@@ -323,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),
"")
}
}
@@ -356,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),
"")
}
@@ -404,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),
"")
@@ -645,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
@@ -660,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 {
@@ -699,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"})
+148
View File
@@ -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{},
}
}
+67 -19
View File
@@ -213,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()
@@ -230,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}.
@@ -324,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) {
@@ -336,27 +369,32 @@ 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:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
}
@@ -370,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, "")
@@ -391,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
@@ -817,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)
+53 -37
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
@@ -125,32 +126,34 @@ var cliTranslations = map[string]string{
"检查仓库": "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",
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级": "The latest release does not contain clicd-linux-amd64.tar.gz, so 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.",
"最新版本": "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.",
@@ -557,11 +560,16 @@ func cliUpgradeSystem(reader *bufio.Reader) {
if repo == "" {
repo = version.Repo
}
assetName, err := releaseArchiveAssetName(runtime.GOARCH)
if err != nil {
cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
return
}
current := version.Current()
cliPrintf("当前版本: %s\n", current)
cliPrintf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo)
release, err := fetchLatestRelease(repo, assetName)
if err != nil {
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
return
@@ -576,9 +584,9 @@ func cliUpgradeSystem(reader *bufio.Reader) {
cliPrintf("发布页面: %s\n", release.HTMLURL)
}
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
assetURL := findReleaseAsset(release, assetName)
if assetURL == "" {
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
return
}
@@ -597,7 +605,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
}
}
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
if err := upgradeFromReleaseAsset(assetURL, latest, assetName); err != nil {
cliPrintf("升级失败: %v\n", err)
return
}
@@ -605,7 +613,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
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 {
@@ -617,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
@@ -627,7 +635,7 @@ 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 {
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
} else {
@@ -645,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
@@ -667,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,
@@ -708,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 != "" {
@@ -717,14 +733,14 @@ 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")
archivePath := filepath.Join(tmpDir, assetName)
cliPrintln("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil {
return err
+20
View File
@@ -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",
+107 -9
View File
@@ -372,6 +372,8 @@ type ClicdConfig struct {
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"`
@@ -394,6 +396,11 @@ var AppConfig *ClicdConfig
const DefaultSnapshotLimit = 3
const (
DefaultNATPortStart = 20000
DefaultNATPortEnd = 65535
)
func getConfigPath() string {
if configPath != "" {
return configPath
@@ -509,6 +516,8 @@ func InitConfig() (*ClicdConfig, error) {
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
NATPortStart: DefaultNATPortStart,
NATPortEnd: DefaultNATPortEnd,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
@@ -552,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
@@ -1168,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)
+46
View File
@@ -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)
}
}
+4
View File
@@ -524,6 +524,8 @@ 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"],
@@ -651,6 +653,8 @@ 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),
+153 -36
View File
@@ -20,6 +20,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
@@ -454,7 +455,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
winAdminPassword = generateWindowsPassword()
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil {
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
return nil, err
}
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
@@ -487,7 +488,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPort := 0
portMappings := []config.PortMapping{}
if allocatePorts && cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
sshPort, err = config.AllocateSSHPort()
if err != nil {
return nil, err
}
if IsWindowsImage(image.ID) {
// Windows: RDP (3389) instead of SSH (22)
portMappings = []config.PortMapping{{
@@ -1535,7 +1539,13 @@ func (m *Manager) validateHost(skipCloudInit bool) error {
return err
}
}
if err := requireCommand(kvmEmulatorCommand()); err != nil {
return err
}
if skipCloudInit {
if runtime.GOARCH != "amd64" {
return fmt.Errorf("Windows KVM is currently supported only on x86_64/amd64 hosts")
}
if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil {
return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err)
}
@@ -1569,6 +1579,40 @@ func requireAnyCommand(names ...string) error {
return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", "))
}
func kvmLibvirtArch() string {
switch runtime.GOARCH {
case "arm64":
return "aarch64"
default:
return "x86_64"
}
}
func kvmMachineType() string {
switch runtime.GOARCH {
case "arm64":
return "virt"
default:
return "pc"
}
}
func kvmEmulatorCommand() string {
switch runtime.GOARCH {
case "arm64":
return "qemu-system-aarch64"
default:
return "qemu-system-x86_64"
}
}
func kvmEmulatorPath() string {
if path, err := exec.LookPath(kvmEmulatorCommand()); err == nil {
return path
}
return "/usr/bin/" + kvmEmulatorCommand()
}
func ensureDefaultNetwork() error {
// Ensure libvirtd is running
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
@@ -1680,7 +1724,7 @@ func createEmptyDisk(target string, diskGB int) error {
return nil
}
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string, ipv4s []string) error {
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error {
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
if tool == "" {
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
@@ -1706,13 +1750,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []st
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil {
return err
}
_ = os.Remove(target)
@@ -1822,7 +1866,7 @@ exit /b 0
`
}
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []string) string {
func windowsFirstLogonPowerShell(adminPassword, mac string, ipv6s []string, ipv4s []string) string {
commands := []string{
"$ErrorActionPreference='Continue'",
"$ProgressPreference='SilentlyContinue'",
@@ -1832,9 +1876,9 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
"net user Administrator " + shellQuoteWindows(adminPassword) + " /active:yes",
"Set-LocalUser -Name 'Administrator' -PasswordNeverExpires $true -ErrorAction SilentlyContinue",
"Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force",
"$iface=$null",
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
"$iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1",
windowsAdapterDiscoveryPowerShell(mac),
"$iface=Wait-ClicdNetworkAdapter",
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
"if ($iface) { Set-NetIPInterface -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -Dhcp Enabled -ErrorAction SilentlyContinue }",
"if ($iface) { Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ResetServerAddresses -ErrorAction SilentlyContinue }",
"Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue",
@@ -1852,17 +1896,23 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
}
networkCommands := []string{}
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) > 0 {
commands = append(commands,
windowsIPv6PowerShell(ipv6s),
)
networkCommands = append(networkCommands, windowsIPv6PowerShell(ipv6s, mac))
}
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) > 0 {
commands = append(commands,
windowsIPv4PowerShell(ipv4s),
)
networkCommands = append(networkCommands, windowsIPv4PowerShell(ipv4s, mac))
}
if len(networkCommands) > 0 {
networkScript := strings.Join(append([]string{
"$ErrorActionPreference='Continue'",
"$ProgressPreference='SilentlyContinue'",
"New-Item -ItemType Directory -Force -Path 'C:\\CLICD' | Out-Null",
}, networkCommands...), "\r\n") + "\r\n"
commands = append(commands, windowsPersistentNetworkTaskPowerShell(networkScript))
commands = append(commands, networkCommands...)
}
commands = append(commands,
"New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null",
@@ -1871,18 +1921,58 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
return strings.Join(commands, "\r\n") + "\r\n"
}
func windowsIPv6PowerShell(ipv6s []string) string {
func windowsPersistentNetworkTaskPowerShell(script string) string {
return strings.Join([]string{
"$clicdNetworkScript=@'",
strings.TrimRight(script, "\r\n"),
"'@",
"Set-Content -Path 'C:\\CLICD\\ApplyNetwork.ps1' -Value $clicdNetworkScript -Encoding UTF8",
"$clicdNetworkAction=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\\CLICD\\ApplyNetwork.ps1'",
"$clicdNetworkTrigger=New-ScheduledTaskTrigger -AtStartup",
"Register-ScheduledTask -TaskName 'CLICD Network Init' -Action $clicdNetworkAction -Trigger $clicdNetworkTrigger -RunLevel Highest -Force -ErrorAction SilentlyContinue | Out-Null",
}, "\r\n")
}
func windowsAdapterDiscoveryPowerShell(mac string) string {
targetMAC := strings.ToUpper(strings.NewReplacer(":", "", "-", "", " ", "").Replace(strings.TrimSpace(mac)))
return strings.Join([]string{
"$clicdTargetMac=" + powerShellSingleQuote(targetMAC),
"function Get-ClicdNetworkAdapter {",
" $adapters=@(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Disabled' })",
" if ($clicdTargetMac) {",
" $matched=$adapters | Where-Object { (($_.MacAddress -replace '[-:]','').ToUpperInvariant()) -eq $clicdTargetMac } | Sort-Object ifIndex | Select-Object -First 1",
" if ($matched) { return $matched }",
" }",
" $up=$adapters | Where-Object { $_.Status -eq 'Up' } | Sort-Object ifIndex | Select-Object -First 1",
" if ($up) { return $up }",
" return $adapters | Sort-Object ifIndex | Select-Object -First 1",
"}",
"function Wait-ClicdNetworkAdapter {",
" param([int]$Retries=90,[int]$DelaySeconds=4)",
" for ($i=0; $i -lt $Retries; $i++) {",
" $adapter=Get-ClicdNetworkAdapter",
" if ($adapter) { return $adapter }",
" Start-Sleep -Seconds $DelaySeconds",
" }",
" return $null",
"}",
}, "\r\n")
}
func windowsIPv6PowerShell(ipv6s []string, mac string) string {
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv6s))
for _, ipv6 := range ipv6s {
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
quoted = append(quoted, powerShellSingleQuote(ipv6))
}
return strings.Join([]string{
windowsAdapterDiscoveryPowerShell(mac),
"if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }",
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
// Reuse $iface already found by the main script
"if ($iface) {",
" foreach ($ip in $clicdIPv6) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
@@ -1895,18 +1985,20 @@ func windowsIPv6PowerShell(ipv6s []string) string {
}, "\r\n")
}
func windowsIPv4PowerShell(ipv4s []string) string {
func windowsIPv4PowerShell(ipv4s []string, mac string) string {
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv4s))
for _, ipv4 := range ipv4s {
quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'")
quoted = append(quoted, powerShellSingleQuote(ipv4))
}
return strings.Join([]string{
windowsAdapterDiscoveryPowerShell(mac),
"if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }",
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
"$clicdIPv4=@(" + strings.Join(quoted, ",") + ")",
// Reuse $iface already found by the main script
"if ($iface) {",
" foreach ($ip in $clicdIPv4) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
@@ -1930,6 +2022,10 @@ func normalizeKVMIPv4List(values []string) []string {
return result
}
func powerShellSingleQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
@@ -2131,6 +2227,26 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
input = "\n\t <input type='tablet' bus='usb'/>"
}
osAttrs := ""
features := "<features><acpi/><apic/></features>"
if runtime.GOARCH == "arm64" {
osAttrs = " firmware='efi'"
features = "<features><acpi/><gic version='3'/></features>"
}
seedDisk := fmt.Sprintf(`<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>`, xmlEscape(seedPath))
if runtime.GOARCH == "arm64" {
seedDisk = fmt.Sprintf(`<disk type='file' device='disk'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='vdb' bus='virtio'/>
<readonly/>
</disk>`, xmlEscape(seedPath))
}
return fmt.Sprintf(`<domain type='kvm'>
<name>%s</name>
%s
@@ -2138,29 +2254,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<currentMemory unit='MiB'>%d</currentMemory>
<vcpu placement='static' current='%d'>%d</vcpu>
<cputune><shares>2048</shares></cputune>
<os>
<type arch='x86_64' machine='pc'>hvm</type>
<os%s>
<type arch='%s' machine='%s'>hvm</type>
<boot dev='hd'/>
</os>
<features><acpi/><apic/></features>
%s
<cpu mode='host-passthrough' check='none'/>
<clock offset='utc'/>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>restart</on_crash>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<emulator>%s</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2' cache='none'/>
<source file='%s'/>
<target dev='vda' bus='virtio'/>%s
</disk>
<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<source file='%s'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>
%s
<interface type='network'>
<mac address='%s'/>
<source network='default'/>
@@ -2177,7 +2288,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
%s
</devices>
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, osAttrs, kvmLibvirtArch(), kvmMachineType(), features, xmlEscape(kvmEmulatorPath()), xmlEscape(diskPath), iotune, seedDisk, xmlEscape(mac), bandwidth, input, video)
}
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string {
@@ -2354,7 +2465,11 @@ func normalizeKVMManagementPortMapping(c *config.Container) {
}
hostPort := c.SSHPort
if hostPort <= 0 {
hostPort = config.AllocateSSHPort()
allocated, err := config.AllocateSSHPort()
if err != nil {
return
}
hostPort = allocated
c.SSHPort = hostPort
}
desiredPort := 22
@@ -3488,13 +3603,14 @@ func (m *Manager) applyGuestIPv6(c *config.Container) error {
}
func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
if err := qemuGuestPing(c.VirshName()); err != nil {
return err
}
script := windowsIPv6PowerShell(c.IPv6AddressStrings())
c.NormalizeNetworkAssignments()
script := windowsIPv6PowerShell(c.IPv6AddressStrings(), c.MACAddress)
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
}
@@ -3855,7 +3971,8 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
}
}
ports := make([]int, 0, count)
for next := 20000; next <= 65535 && len(ports) < count; next++ {
start, end := config.NATPortRange()
for next := start; next <= end && len(ports) < count; next++ {
if !used[next] {
ports = append(ports, next)
}
+57
View File
@@ -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 {
+3
View File
@@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status {
}
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 {
return configured
}
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
}
+93 -2
View File
@@ -381,7 +381,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
sshPort := 0
portMappings := []config.PortMapping{}
if cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
sshPort, err = config.AllocateSSHPort()
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
// Setup default port mappings (SSH only)
portMappings = SetupDefaultPortMappings(sshPort)
@@ -1013,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 {
+45
View File
@@ -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)
}
}
}
+6 -6
View File
@@ -395,6 +395,10 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
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 {
@@ -444,16 +448,12 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
}
}
ports := make([]int, 0, count)
next := 20000
for len(ports) < count {
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
}
+21 -9
View File
@@ -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",
Distro: "archlinux", Release: "current", Arch: arch,
Description: "Arch Linux (Rolling)",
},
{
ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64",
Distro: "fedora", Release: "44", Arch: arch,
Description: "Fedora 44",
},
{
ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64",
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()
-1
View File
@@ -1 +0,0 @@

+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.19"
Version = "1.1.22"
Repo = "MengMengCode/CLICD"
)
+41 -3
View File
@@ -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"
+7
View File
@@ -110,6 +110,13 @@ export default defineConfig({
head: [
['link', { rel: 'icon', href: '/favicon.svg' }],
],
vite: {
esbuild: {
supported: {
destructuring: true,
},
},
},
locales: {
root: {
label: '简体中文',
+19
View File
@@ -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
+3 -1
View File
@@ -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
```
## 安装脚本行为
+19
View File
@@ -30,6 +30,25 @@ 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
+3 -1
View File
@@ -12,16 +12,18 @@ Check the version in:
## Release Artifacts
The installer first tries to download the Linux AMD64 archive:
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
+194 -10
View File
@@ -53,7 +53,11 @@ Create container example:
"ssh_auth_mode": "auto_password",
"ssh_password": "",
"ssh_public_key": "",
"expires_at": ""
"expires_at": "",
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
@@ -71,6 +75,12 @@ Field notes:
| `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:
@@ -85,6 +95,102 @@ Reinstall example:
`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:
@@ -140,6 +246,7 @@ print(resp.json())
| --- | --- | --- |
| 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 |
@@ -151,10 +258,11 @@ print(resp.json())
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/containers` | Container list |
| 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 |
| 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 |
@@ -173,10 +281,12 @@ print(resp.json())
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/containers/{id}/random-port` | Random available port |
| 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 |
@@ -191,6 +301,7 @@ print(resp.json())
| --- | --- | --- |
| 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 |
@@ -203,6 +314,12 @@ print(resp.json())
| 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 |
@@ -255,6 +372,16 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"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": {
@@ -331,6 +458,10 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"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",
@@ -343,6 +474,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
}
]
},
"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": [
@@ -410,7 +547,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"success": true,
"data": {
"mode": "total",
"limit_gb": 0,
"limit_gb": 1024,
"in_limit_gb": 0,
"out_limit_gb": 0,
"total_used_bytes": 142082,
@@ -453,7 +590,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
```json
{
"GET /api/v1/containers/{id}/random-port": {
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
"success": true,
"data": { "port": 61320 }
},
@@ -474,6 +611,21 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"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
@@ -539,6 +691,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
{ "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"
@@ -585,9 +743,35 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
},
"POST /api/v1/swap": {
"success": true,
"message": "SWAP 已调整为 16384 MB",
"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"]
@@ -654,17 +838,17 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"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": ["*"], "last_used_ip": "198.51.100.23" }
{ "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...", "scopes": ["dashboard:read", "container:read"] }
"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"], "disabled": false }
"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,
+2 -2
View File
@@ -4,7 +4,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
## Requirements
- Linux x86_64 host.
- Linux x86_64/amd64 or ARM64/aarch64 host.
- Root privileges.
- systemd.
- Network access to GitHub Release downloads.
@@ -17,7 +17,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
The script defaults to `CLICD_VERSION=latest`, which downloads `clicd-linux-amd64.tar.gz` from `releases/latest`.
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
+1 -1
View File
@@ -26,4 +26,4 @@ CLICD is a lightweight virtualization management panel for LXC and KVM. It bring
- 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 release artifacts. The installer fetches the latest release by default.
- Release: GitHub Actions builds Linux AMD64/ARM64 release artifacts. The installer fetches the latest release by default.
+1 -1
View File
@@ -2,7 +2,7 @@
## 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 artifact from `releases/latest`.
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?
+193 -9
View File
@@ -53,7 +53,11 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
"ssh_auth_mode": "auto_password",
"ssh_password": "",
"ssh_public_key": "",
"expires_at": ""
"expires_at": "",
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
@@ -71,6 +75,12 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
| `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 限速,新接入推荐使用拆分字段。 |
重装示例:
@@ -85,6 +95,102 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
`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 示例
获取容器列表:
@@ -140,6 +246,7 @@ print(resp.json())
| --- | --- | --- |
| GET | `/api/v1/dashboard` | 控制面板统计 |
| GET | `/api/v1/host-info` | 主机资源 |
| 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 段 |
@@ -151,10 +258,11 @@ print(resp.json())
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/containers` | 容器列表 |
| 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}` | 容器详情 |
| 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` | 重启 |
@@ -173,10 +281,12 @@ print(resp.json())
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口 |
| 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` | 创建快照 |
@@ -191,6 +301,7 @@ print(resp.json())
| --- | --- | --- |
| GET | `/api/v1/templates` | 模板列表 |
| GET | `/api/v1/images` | 镜像管理列表 |
| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` |
| POST | `/api/v1/images/download` | 下载镜像 |
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
@@ -203,6 +314,12 @@ print(resp.json())
| 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 票据 |
@@ -255,6 +372,16 @@ print(resp.json())
"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": {
@@ -331,6 +458,10 @@ print(resp.json())
"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",
@@ -343,6 +474,12 @@ print(resp.json())
}
]
},
"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": [
@@ -410,7 +547,7 @@ print(resp.json())
"success": true,
"data": {
"mode": "total",
"limit_gb": 0,
"limit_gb": 1024,
"in_limit_gb": 0,
"out_limit_gb": 0,
"total_used_bytes": 142082,
@@ -453,7 +590,7 @@ print(resp.json())
```json
{
"GET /api/v1/containers/{id}/random-port": {
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
"success": true,
"data": { "port": 61320 }
},
@@ -474,6 +611,21 @@ print(resp.json())
"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
@@ -539,6 +691,12 @@ print(resp.json())
{ "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"
@@ -588,6 +746,32 @@ print(resp.json())
"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"]
@@ -654,17 +838,17 @@ print(resp.json())
"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": ["*"], "last_used_ip": "198.51.100.23" }
{ "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...", "scopes": ["dashboard:read", "container:read"] }
"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"], "disabled": false }
"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,
+2 -2
View File
@@ -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`
## 安装指定版本
+1 -1
View File
@@ -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。
+1 -1
View File
@@ -2,7 +2,7 @@
## 安装脚本默认安装哪个版本?
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
## 可以固定安装某个版本吗?
+107 -107
View File
@@ -369,9 +369,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"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"
],
@@ -386,9 +386,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -403,9 +403,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -420,9 +420,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"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"
],
@@ -437,9 +437,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"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"
],
@@ -454,9 +454,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -471,9 +471,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"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"
],
@@ -488,9 +488,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"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"
],
@@ -505,9 +505,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"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"
],
@@ -522,9 +522,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"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"
],
@@ -539,9 +539,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"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"
],
@@ -556,9 +556,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"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"
],
@@ -573,9 +573,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -590,9 +590,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"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"
],
@@ -607,9 +607,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -624,9 +624,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"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"
],
@@ -641,9 +641,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"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"
],
@@ -658,9 +658,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"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"
],
@@ -675,9 +675,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -692,9 +692,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"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"
],
@@ -709,9 +709,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"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"
],
@@ -726,9 +726,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -743,9 +743,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"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"
],
@@ -760,9 +760,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"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"
],
@@ -777,9 +777,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"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"
],
@@ -794,9 +794,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"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"
],
@@ -1757,9 +1757,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"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",
@@ -1770,32 +1770,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
"@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": {
+2 -1
View File
@@ -11,6 +11,7 @@
"vitepress": "^1.6.4"
},
"overrides": {
"vite": "6.4.2"
"vite": "6.4.2",
"esbuild": "0.28.1"
}
}
+7 -7
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.19",
"version": "1.1.22",
"type": "module",
"scripts": {
"dev": "vite",
@@ -98,6 +98,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
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
@@ -241,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>
@@ -408,7 +421,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<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}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
</span>
</span>
</label>
@@ -762,7 +775,7 @@ const createNetworkText = {
zh: {
publicIPv4: '公网 IPv4',
noAllocatableIPv4: '未检测到可分配公网 IPv4',
publicIPv6: '公网 IPv6',
publicIPv6: '可分配 IPv6 前缀',
use: '使用',
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
publicNAT: '公网 NAT',
@@ -771,7 +784,7 @@ const createNetworkText = {
en: {
publicIPv4: 'Public IPv4',
noAllocatableIPv4: 'No allocatable public IPv4 detected',
publicIPv6: 'Public IPv6',
publicIPv6: 'Allocatable IPv6 Prefix',
use: 'Use',
checkingIPv6Prefix: 'Checking IPv6 prefix...',
publicNAT: 'Public NAT',
@@ -779,6 +792,21 @@ const createNetworkText = {
},
} 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`
+135 -33
View File
@@ -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' : ''
+4 -1
View File
@@ -914,6 +914,7 @@ 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' }],
@@ -927,6 +928,8 @@ const responseSamples: Record<string, unknown> = {
'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' }],
@@ -1196,7 +1199,7 @@ function endpointNoteFor(key: string) {
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 public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
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.')
+41 -17
View File
@@ -89,8 +89,12 @@ type MetricPoint = {
ts: number
cpu: number
memory: number
network: number
diskIO: number
network?: number
networkRx?: number
networkTx?: number
diskIO?: number
diskRead?: number
diskWrite?: number
}
type MappingDraft = {
index: number | null
@@ -214,15 +218,21 @@ export default function ContainerDetail() {
const memoryPct = memoryTotalBytes > 0
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
: 0
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
const networkRx = nextUsage.network_rx_bps || 0
const networkTx = nextUsage.network_tx_bps || 0
const diskRead = nextUsage.disk_read_bps || 0
const diskWrite = nextUsage.disk_write_bps || 0
const point: MetricPoint = {
ts: Date.now(),
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
memory: clamp(memoryPct),
network: networkBps,
diskIO: diskIOBps,
network: networkRx + networkTx,
networkRx,
networkTx,
diskIO: diskRead + diskWrite,
diskRead,
diskWrite,
}
setHistory((prev) => {
@@ -907,20 +917,23 @@ export default function ContainerDetail() {
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
const rx = usage?.network_rx_bps || 0
const networkRxBps = usage?.network_rx_bps || 0
const networkTxBps = usage?.network_tx_bps || 0
const networkBps = networkRxBps + networkTxBps
const networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps)
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
const netPct = Math.max(
directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000),
directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 125000, 125000000),
directionUsagePercent(networkRxBps, networkDownLimit, 125000, 125000000),
directionUsagePercent(networkTxBps, networkUpLimit, 125000, 125000000),
)
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
const diskReadBps = usage?.disk_read_bps || 0
const diskWriteBps = usage?.disk_write_bps || 0
const diskIOBps = diskReadBps + diskWriteBps
const ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps)
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
const diskIOPct = Math.max(
directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
directionUsagePercent(diskReadBps, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
directionUsagePercent(diskWriteBps, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
)
const mappingCount = container.port_mappings?.length || 0
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
@@ -967,16 +980,24 @@ export default function ContainerDetail() {
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(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`,
detail: `${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`,
},
{
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(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`,
detail: `${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`,
},
]
@@ -2514,8 +2535,11 @@ function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLa
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
}
function toChartPoints<T extends keyof Omit<MetricPoint, 'ts'>>(history: MetricPoint[], key: T): ChartPoint[] {
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
function toChartPoints(history: MetricPoint[], key: keyof Omit<MetricPoint, 'ts'>): ChartPoint[] {
return history.flatMap((point) => {
const value = Number(point[key])
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
})
}
function formatPercent(value: number): string {
+1 -3
View File
@@ -395,10 +395,8 @@ export default function Containers() {
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
+35 -8
View File
@@ -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) {
+3 -2
View File
@@ -205,7 +205,7 @@ const hostReportText = {
ipv4Address: 'IPv4 地址',
ipv4Prefix: 'IPv4 段',
ipv6Address: 'IPv6 地址',
ipv6Prefix: 'IPv6 ',
ipv6Prefix: '可分配 IPv6 前缀',
gateway: '网关',
memoryModules: '内存条',
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
@@ -277,7 +277,7 @@ const hostReportText = {
ipv4Address: 'IPv4 Addresses',
ipv4Prefix: 'IPv4 Prefixes',
ipv6Address: 'IPv6 Addresses',
ipv6Prefix: 'IPv6 Prefixes',
ipv6Prefix: 'Allocatable IPv6 Prefixes',
gateway: 'Gateway',
memoryModules: 'Memory Modules',
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
@@ -511,6 +511,7 @@ function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boole
}
function gpuTypeLabel(value: string, language: Language) {
if (value === 'virtual') return language === 'en' ? 'Virtual' : '虚拟'
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
return value || '-'
+13 -11
View File
@@ -148,17 +148,19 @@ export default function ImageManagement() {
onToggle={handleToggle}
/>
<ImageTable
title="KVM 虚拟机镜像"
images={kvmImages}
actionLoading={actionLoading}
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length}
onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
{kvmImages.length > 0 && (
<ImageTable
title="KVM 虚拟机镜像"
images={kvmImages}
actionLoading={actionLoading}
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
totalCount={kvmImages.length}
onDownload={handleDownload}
onCancelDownload={handleCancelDownload}
onDelete={handleDelete}
onToggle={handleToggle}
/>
)}
</div>
)
}
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.19</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.22</p>
</div>
</div>
)
+247 -6
View File
@@ -4,9 +4,13 @@ import { useNavigate } from 'react-router-dom'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import {
getRoutingInfo,
updateRoutingIPv6Prefixes,
updateRoutingIPv4Pool,
updateRoutingPools,
type IPv4Route,
type IPv6Route,
type IPv6PrefixInfo,
type NAT4PortRange,
type NAT4Route,
type PublicIPv4Info,
type RoutingInfo,
@@ -25,6 +29,12 @@ export default function Routing() {
const [savingIPv4, setSavingIPv4] = useState(false)
const [ipv4Draft, setIPv4Draft] = useState<(PublicIPv4Info & { _id: number })[]>([])
const nextDraftId = useRef(0)
const [editingNAT4, setEditingNAT4] = useState(false)
const [savingNAT4, setSavingNAT4] = useState(false)
const [nat4Draft, setNAT4Draft] = useState<NAT4PortRange>({ start: 20000, end: 65535 })
const [editingIPv6, setEditingIPv6] = useState(false)
const [savingIPv6, setSavingIPv6] = useState(false)
const [ipv6Draft, setIPv6Draft] = useState<(IPv6PrefixInfo & { _id: number })[]>([])
const [nat4Page, setNat4Page] = useState(1)
const [ipv6Page, setIPv6Page] = useState(1)
const [nat4Search, setNat4Search] = useState('')
@@ -49,9 +59,12 @@ export default function Routing() {
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Prefixes = routing?.ipv6_prefixes || []
const ipv6Assignments = routing?.ipv6_assignments || []
const nat4Range = routing?.nat4_port_range || { start: 20000, end: 65535 }
const defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0'
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32
const defaultIPv6Interface = ipv6Prefixes[0]?.interface || defaultIPv4Interface
const defaultIPv6Gateway = ipv6Prefixes[0]?.gateway || ''
useEffect(() => {
if (!editingIPv4) {
@@ -139,6 +152,81 @@ export default function Routing() {
}
}
const startEditNAT4 = () => {
setNAT4Draft({ start: nat4Range.start || 20000, end: nat4Range.end || 65535 })
setEditingNAT4(true)
}
const saveNAT4Range = async () => {
const start = Math.round(Number(nat4Draft.start || 0))
const end = Math.round(Number(nat4Draft.end || 0))
if (start < 1 || start > 65535 || end < 1 || end > 65535 || start > end) {
alert(text.nat4RangeInvalid)
return
}
setSavingNAT4(true)
try {
const res = await updateRoutingPools({ nat4_port_range: { start, end } })
setRouting(res.data.data || null)
setEditingNAT4(false)
} catch (err: any) {
alert(err?.response?.data?.message || text.saveNAT4RangeFailed)
} finally {
setSavingNAT4(false)
}
}
const startEditIPv6 = () => {
setIPv6Draft(ipv6Prefixes.map((prefix) => ({ ...prefix, _id: nextDraftId.current++ })))
setEditingIPv6(true)
}
const addIPv6Row = () => {
setIPv6Draft((items) => [
...items,
{
_id: nextDraftId.current++,
prefix: '',
address: '',
prefix_len: 64,
interface: defaultIPv6Interface,
gateway: defaultIPv6Gateway,
source: 'manual',
},
])
}
const updateIPv6Draft = (index: number, patch: Partial<IPv6PrefixInfo>) => {
setIPv6Draft((items) => items.map((item, i) => (i === index ? { ...item, ...patch } : item)))
}
const saveIPv6Prefixes = async () => {
setSavingIPv6(true)
try {
const items = ipv6Draft
.map(({ _id, ...item }) => ({
...item,
prefix: (item.prefix || '').trim(),
address: (item.address || '').trim(),
interface: (item.interface || defaultIPv6Interface).trim(),
gateway: (item.gateway || '').trim(),
prefix_len: Number(item.prefix_len || 0),
}))
.filter((item) => item.prefix || item.address)
if (items.some((item) => !item.interface)) {
alert(text.ipv6InterfaceRequired)
return
}
const res = await updateRoutingIPv6Prefixes(items)
setRouting(res.data.data || null)
setEditingIPv6(false)
} catch (err: any) {
alert(err?.response?.data?.message || text.saveIPv6PrefixesFailed)
} finally {
setSavingIPv6(false)
}
}
const filteredNat4 = useMemo(() => {
const q = nat4Search.toLowerCase().trim()
if (!q) return nat4Mappings
@@ -189,11 +277,45 @@ export default function Routing() {
</div>
<div className="grid gap-4 md:grid-cols-3">
<CapacityCard title={text.nat4Ports} watermark="NAT4" remaining={routing?.nat4.remaining || '0'} total={routing?.nat4.total || '0'} used={routing?.nat4.used || 0} label={text.remainingTotal} usedLabel={text.used} />
<CapacityCard
title={text.nat4Ports}
watermark="NAT4"
remaining={routing?.nat4.remaining || '0'}
total={routing?.nat4.total || '0'}
used={routing?.nat4.used || 0}
label={text.remainingTotal}
usedLabel={text.used}
detail={formatNATRange(nat4Range, language)}
action={
<button onClick={startEditNAT4} className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-black" title={text.editNAT4Range}>
<Pencil className="h-4 w-4" />
</button>
}
/>
<CapacityCard title={text.publicIPv4} watermark="IPv4" remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
<CapacityCard title="IPv6" watermark="IPv6" remaining={formatCapacity(routing?.ipv6.remaining || '0', language)} total={formatCapacity(routing?.ipv6.total || '0', language)} used={routing?.ipv6.used || 0} label={formatDetectedPrefixCount(ipv6Prefixes.length, language)} usedLabel={text.used} />
</div>
{editingNAT4 && (
<RouteModal title={text.editNAT4Range} onClose={() => setEditingNAT4(false)}>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<LabeledNumberInput label={text.rangeStart} value={nat4Draft.start} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, start: value }))} min={1} max={65535} />
<LabeledNumberInput label={text.rangeEnd} value={nat4Draft.end} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, end: value }))} min={1} max={65535} />
</div>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setEditingNAT4(false)} disabled={savingNAT4} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
{text.cancel}
</button>
<button onClick={saveNAT4Range} disabled={savingNAT4} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
{savingNAT4 ? text.saving : text.save}
</button>
</div>
</div>
</RouteModal>
)}
<Panel
title={text.publicIPv4Pool}
subtitle={formatIPv4PoolSubtitle(publicIPv4s.length, ipv4Assignments.length, language)}
@@ -328,8 +450,19 @@ export default function Routing() {
</RouteModal>
)}
{ipv6Prefixes.length > 0 && (
<Panel title={text.detectedIPv6Prefixes} subtitle={formatPrefixCount(ipv6Prefixes.length, language)}>
<Panel
title={text.detectedIPv6Prefixes}
subtitle={formatPrefixCount(ipv6Prefixes.length, language)}
action={
<button onClick={startEditIPv6} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
<Pencil className="h-3.5 w-3.5" />
{text.editPrefixes}
</button>
}
>
{ipv6Prefixes.length === 0 ? (
<EmptyState text={text.noIPv6Prefixes} icon={<Router className="h-7 w-7" />} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
@@ -354,7 +487,58 @@ export default function Routing() {
</tbody>
</table>
</div>
</Panel>
)}
</Panel>
{editingIPv6 && (
<RouteModal title={text.editIPv6Prefixes} onClose={() => setEditingIPv6(false)} wide>
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-3 py-2 text-left font-medium">{text.prefix}</th>
<th className="px-3 py-2 text-left font-medium">{text.hostAddress}</th>
<th className="px-3 py-2 text-left font-medium">{text.interface}</th>
<th className="px-3 py-2 text-left font-medium">{text.gateway}</th>
<th className="px-3 py-2 text-right font-medium">{text.action}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{ipv6Draft.map((item, index) => (
<tr key={item._id}>
<td className="px-3 py-2"><input value={item.prefix || ''} onChange={(e) => updateIPv6Draft(index, { prefix: e.target.value })} placeholder="2001:db8:100::/64" className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv6Draft(index, { address: e.target.value })} placeholder="2001:db8:100::1" className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv6Draft(index, { interface: e.target.value })} placeholder={defaultIPv6Interface} className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv6Draft(index, { gateway: e.target.value })} placeholder={text.gateway} className={smallInputClass} /></td>
<td className="px-3 py-2 text-right">
<button onClick={() => setIPv6Draft((items) => items.filter((_, i) => i !== index))} className="inline-flex items-center justify-center rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600">
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
{ipv6Draft.length === 0 && <EmptyRow colSpan={5} text={text.noIPv6Prefixes} />}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<button onClick={addIPv6Row} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
<Plus className="h-3.5 w-3.5" />
{text.addIPv6Prefix}
</button>
<div className="flex items-center gap-2">
<button onClick={() => setEditingIPv6(false)} disabled={savingIPv6} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
{text.cancel}
</button>
<button onClick={saveIPv6Prefixes} disabled={savingIPv6} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
{savingIPv6 ? text.saving : text.save}
</button>
</div>
</div>
</div>
</RouteModal>
)}
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
@@ -527,7 +711,7 @@ function Pagination({ page, totalPages, totalItems, pageSize, onPageChange, lang
)
}
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel }: {
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel, detail, action }: {
title: string
watermark: string
remaining: string
@@ -535,6 +719,8 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
used: number
label: string
usedLabel: string
detail?: string
action?: ReactNode
}) {
return (
<div className="relative overflow-hidden rounded-lg border border-gray-200 bg-white p-4">
@@ -542,20 +728,46 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
{watermark}
</div>
<div className="relative z-10">
<div>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="mt-2 flex items-end gap-2">
<span className="text-2xl font-semibold text-black">{remaining}</span>
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
</div>
</div>
{action}
</div>
</div>
<div className="relative z-10 mt-3 text-xs text-gray-500">{label}</div>
<div className="relative z-10 mt-1 text-xs text-gray-400">{usedLabel} {used}</div>
{detail && <div className="relative z-10 mt-1 font-mono text-xs text-gray-400">{detail}</div>}
</div>
)
}
function LabeledNumberInput({ label, value, onChange, min, max }: {
label: string
value: number
onChange: (value: number) => void
min: number
max: number
}) {
return (
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500">{label}</span>
<input
type="number"
min={min}
max={max}
value={value || ''}
onChange={(event) => onChange(Number(event.target.value))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:ring-1 focus:ring-black"
/>
</label>
)
}
function EmptyState({ icon, text }: { icon: ReactNode; text: string }) {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
@@ -632,6 +844,11 @@ const routingText = {
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
refresh: '刷新',
nat4Ports: 'NAT4 端口',
editNAT4Range: '编辑 NAT4 范围',
rangeStart: '起始端口',
rangeEnd: '结束端口',
nat4RangeInvalid: 'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口',
saveNAT4RangeFailed: '保存 NAT4 范围失败',
remainingTotal: '剩余 / 总数',
publicIPv4: '公网 IPv4',
publicIPv4Pool: '公网 IPv4 池',
@@ -661,10 +878,17 @@ const routingText = {
save: '保存',
saving: '保存中...',
detectedIPv6Prefixes: '检测到的 IPv6 前缀',
editPrefixes: '编辑前缀',
editIPv6Prefixes: '编辑 IPv6 前缀',
addIPv6Prefix: '添加 IPv6 前缀',
noIPv6Prefixes: '暂无 IPv6 前缀',
ipv6InterfaceRequired: 'IPv6 网卡不能为空',
saveIPv6PrefixesFailed: '保存 IPv6 前缀失败',
prefix: '前缀',
hostAddress: '宿主地址',
source: '来源',
local: '本机',
manual: '手动',
ipv4NAT: 'IPv4 NAT',
searchNAT: '搜索 NAT...',
noIPv4NATMappings: '暂无 IPv4 NAT 映射',
@@ -691,6 +915,11 @@ const routingText = {
pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments',
refresh: 'Refresh',
nat4Ports: 'NAT4 ports',
editNAT4Range: 'Edit NAT4 range',
rangeStart: 'Start port',
rangeEnd: 'End port',
nat4RangeInvalid: 'NAT4 range must be 1-65535, and start cannot be greater than end',
saveNAT4RangeFailed: 'Save NAT4 range failed',
remainingTotal: 'remaining / total',
publicIPv4: 'Public IPv4',
publicIPv4Pool: 'Public IPv4 pool',
@@ -720,10 +949,17 @@ const routingText = {
save: 'Save',
saving: 'Saving...',
detectedIPv6Prefixes: 'Detected IPv6 prefixes',
editPrefixes: 'Edit prefixes',
editIPv6Prefixes: 'Edit IPv6 prefixes',
addIPv6Prefix: 'Add IPv6 prefix',
noIPv6Prefixes: 'No IPv6 prefixes',
ipv6InterfaceRequired: 'IPv6 interface is required',
saveIPv6PrefixesFailed: 'Save IPv6 prefixes failed',
prefix: 'Prefix',
hostAddress: 'Host address',
source: 'Source',
local: 'local',
manual: 'manual',
ipv4NAT: 'IPv4 NAT',
searchNAT: 'Search NAT...',
noIPv4NATMappings: 'No IPv4 NAT mappings',
@@ -763,6 +999,10 @@ function formatDetectedPrefixCount(count: number, language: Language) {
: `检测到 ${count} 个前缀`
}
function formatNATRange(range: NAT4PortRange, language: Language) {
return language === 'en' ? `range ${range.start}-${range.end}` : `范围 ${range.start}-${range.end}`
}
function formatPrefixCount(count: number, language: Language) {
return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀`
}
@@ -801,6 +1041,7 @@ function formatContainerStatus(status: string, language: Language) {
function formatSource(source: string | undefined, language: Language) {
if (!source || source === 'local') return routingText[language].local
if (source === 'manual') return routingText[language].manual
return source
}
+15 -1
View File
@@ -235,6 +235,14 @@ export interface HostInfo {
}
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
load: { load1: number; load5: number; load15: number }
runtime?: {
lxc_available: boolean
kvm_available: boolean
dev_kvm: boolean
nested_virtualization: boolean
nested_detail: string
support_mode: string
}
}
export interface HostProbeReport {
@@ -535,6 +543,11 @@ export interface RouteCapacity {
total: string
}
export interface NAT4PortRange {
start: number
end: number
}
export interface NAT4Route {
container_id: number
container_name: string
@@ -571,6 +584,7 @@ export interface IPv6Route {
export interface RoutingInfo {
nat4: RouteCapacity
nat4_port_range: NAT4PortRange
ipv4: RouteCapacity
ipv6: RouteCapacity
host_public_ipv4?: PublicIPv4Info
@@ -590,7 +604,7 @@ export interface PublicIPv4ScanResult extends PublicIPv4Info {
export const getRoutingInfo = () =>
api.get<APIResponse<RoutingInfo>>('/routing')
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[] }) =>
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[]; nat4_port_range?: NAT4PortRange }) =>
api.put<APIResponse<RoutingInfo>>('/routing', payload)
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -e
echo "=============================="
echo " Certbot (Snap) Auto Installer"
echo "=============================="
# 检测系统
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VER=$VERSION_ID
else
echo "无法识别系统版本"
exit 1
fi
echo "检测到系统: $OS"
install_snap_debian() {
apt update -y
apt install -y snapd
systemctl enable --now snapd.socket || true
# 修复 snap 路径
ln -sf /var/lib/snapd/snap /snap
# 安装 certbot
snap install --classic certbot
# 软链
ln -sf /snap/bin/certbot /usr/bin/certbot
}
install_snap_rhel() {
# 启用 EPEL(部分系统需要)
if command -v dnf >/dev/null 2>&1; then
dnf install -y epel-release || true
dnf install -y snapd
systemctl enable --now snapd.socket || true
else
yum install -y epel-release || true
yum install -y snapd
systemctl enable --now snapd.socket || true
fi
# snap 经典路径
ln -sf /var/lib/snapd/snap /snap
# 安装 certbot
snap install --classic certbot
# 软链
ln -sf /snap/bin/certbot /usr/bin/certbot
}
case "$OS" in
ubuntu|debian)
install_snap_debian
;;
centos|rhel|almalinux|rocky)
install_snap_rhel
;;
fedora)
dnf install -y snapd
systemctl enable --now snapd.socket || true
ln -sf /var/lib/snapd/snap /snap
snap install --classic certbot
ln -sf /snap/bin/certbot /usr/bin/certbot
;;
*)
echo "不支持的系统: $OS"
exit 1
;;
esac
echo "=============================="
echo "安装完成!验证版本:"
certbot --version || true
echo "=============================="
+169 -47
View File
@@ -3,7 +3,6 @@ set -eu
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
ASSET="clicd-linux-amd64.tar.gz"
ACTION="${1:-install}"
ACTION_CONFIRM="${2:-}"
ISSUE_URL="https://github.com/${REPO}/issues"
@@ -11,6 +10,80 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
normalize_clicd_arch() {
arch="$1"
case "$(printf '%s' "$arch" | tr 'A-Z' 'a-z')" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
*) echo "" ;;
esac
}
HOST_ARCH_RAW="$(uname -m 2>/dev/null || echo unknown)"
CLICD_ARCH_NORMALIZED="$(normalize_clicd_arch "${CLICD_ARCH:-$HOST_ARCH_RAW}")"
ASSET_DIR="clicd-linux-${CLICD_ARCH_NORMALIZED:-unknown}"
ASSET="${ASSET_DIR}.tar.gz"
BINARY_ASSET="$ASSET_DIR"
kvm_supported_arch() {
[ "$CLICD_ARCH_NORMALIZED" = "amd64" ] || [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]
}
warn_kvm_unsupported_arch() {
if ! kvm_supported_arch; then
warn "当前架构 ${CLICD_ARCH_NORMALIZED:-unknown} 已适配 CLICD/LXCKVM 功能当前支持 x86_64/amd64 和 aarch64/arm64,将跳过 KVM 专用依赖。"
fi
}
qemu_system_package_apk() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-x86_64 ;;
esac
}
qemu_system_package_apt() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-arm ;;
*) echo qemu-system-x86 ;;
esac
}
qemu_system_package_rpm() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-kvm ;;
esac
}
qemu_emulator_cmd() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-x86_64 ;;
esac
}
qemu_efi_package_apt() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo qemu-efi-aarch64 ;;
*) echo ovmf ;;
esac
}
qemu_efi_package_apk() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo edk2-aarch64 ;;
*) echo ovmf ;;
esac
}
qemu_efi_package_rpm() {
case "$CLICD_ARCH_NORMALIZED" in
arm64) echo edk2-aarch64 ;;
*) echo edk2-ovmf ;;
esac
}
normalize_lang() {
lang="$1"
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
@@ -286,14 +359,8 @@ run_step() {
}
check_os_compatibility() {
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)"
case "$(uname -m 2>/dev/null || echo unknown)" in
x86_64|amd64)
;;
*)
die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)"
;;
esac
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=${HOST_ARCH_RAW} CLICD_ARCH=${CLICD_ARCH_NORMALIZED:-unsupported}"
[ -n "$CLICD_ARCH_NORMALIZED" ] || die "当前安装包支持 x86_64/amd64 和 aarch64/arm64,当前架构:${HOST_ARCH_RAW}"
if ! is_systemd && ! is_openrc; then
die "未检测到 systemd 或 OpenRC,无法安装服务。"
fi
@@ -476,7 +543,16 @@ remove_clicd_lxc_image_cache() {
"centos 9-Stream amd64" \
"archlinux current amd64" \
"fedora 44 amd64" \
"rockylinux 10 amd64"
"rockylinux 10 amd64" \
"ubuntu noble arm64" \
"ubuntu jammy arm64" \
"debian bookworm arm64" \
"debian bullseye arm64" \
"alpine 3.21 arm64" \
"centos 9-Stream arm64" \
"archlinux current arm64" \
"fedora 44 arm64" \
"rockylinux 10 arm64"
do
set -- $image
distro="$1"
@@ -950,15 +1026,21 @@ install_apk() {
iproute2 \
iptables \
dnsmasq \
dbus \
qemu-system-x86_64 \
dbus
if kvm_supported_arch; then
apk add --no-cache \
"$(qemu_system_package_apk)" \
qemu-img \
libvirt \
libvirt-daemon \
libvirt-client \
libvirt-qemu
else
warn_kvm_unsupported_arch
fi
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools "$(qemu_efi_package_apk)"; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -985,18 +1067,39 @@ install_apt() {
quota \
e2fsprogs \
xfsprogs \
dnsmasq-base \
qemu-kvm \
qemu-system-x86 \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
ovmf
dnsmasq-base
if kvm_supported_arch; then
if [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]; then
apt-get install -y \
"$(qemu_system_package_apt)" \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
"$(qemu_efi_package_apt)"
else
apt-get install -y \
qemu-kvm \
"$(qemu_system_package_apt)" \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
"$(qemu_efi_package_apt)"
fi
else
warn_kvm_unsupported_arch
apt-get install -y qemu-utils genisoimage xorriso smartmontools >/dev/null 2>&1 || true
fi
}
enable_el_repos() {
@@ -1032,8 +1135,11 @@ install_dnf() {
quota \
e2fsprogs \
xfsprogs \
dnsmasq \
qemu-kvm \
dnsmasq
if kvm_supported_arch; then
dnf install -y \
"$(qemu_system_package_rpm)" \
qemu-img \
libvirt \
libvirt-daemon-kvm \
@@ -1041,8 +1147,12 @@ install_dnf() {
virt-install \
cloud-utils \
genisoimage
else
warn_kvm_unsupported_arch
dnf install -y qemu-img genisoimage >/dev/null 2>&1 || true
fi
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -1067,8 +1177,11 @@ install_yum() {
quota \
e2fsprogs \
xfsprogs \
dnsmasq \
qemu-kvm \
dnsmasq
if kvm_supported_arch; then
yum install -y \
"$(qemu_system_package_rpm)" \
qemu-img \
libvirt \
libvirt-daemon-kvm \
@@ -1076,8 +1189,12 @@ install_yum() {
virt-install \
cloud-utils \
genisoimage
else
warn_kvm_unsupported_arch
yum install -y qemu-img genisoimage >/dev/null 2>&1 || true
fi
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -1117,14 +1234,19 @@ install_dependencies() {
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
fi
if [ ! -e /dev/kvm ]; then
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
if kvm_supported_arch; then
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
has_cmd "$(qemu_emulator_cmd)" || die "依赖安装后仍未找到 $(qemu_emulator_cmd),请检查 QEMU 安装。"
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
fi
if [ ! -e /dev/kvm ]; then
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
fi
else
warn_kvm_unsupported_arch
fi
}
@@ -1384,7 +1506,7 @@ download_release_if_needed() {
if [ "$archive_ok" = "1" ]; then
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
else
binary_asset="clicd-linux-amd64"
binary_asset="$BINARY_ASSET"
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
else
@@ -1402,9 +1524,9 @@ download_release_if_needed() {
[ -n "$url" ] || continue
log "Trying release binary: $url"
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
mkdir -p "$tmp_dir/clicd-linux-amd64"
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
mkdir -p "$tmp_dir/$ASSET_DIR"
cp "$binary_path" "$tmp_dir/$ASSET_DIR/clicd"
chmod +x "$tmp_dir/$ASSET_DIR/clicd"
binary_ok=1
break
fi
@@ -1414,8 +1536,8 @@ download_release_if_needed() {
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
fi
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
[ -d "$tmp_dir/$ASSET_DIR" ] || die "Release package layout is invalid: missing $ASSET_DIR directory"
[ -f "$tmp_dir/$ASSET_DIR/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
}
install_binary() {
@@ -1430,8 +1552,8 @@ install_binary() {
download_dir=""
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
bin_src="$download_dir/clicd-linux-amd64/clicd"
if [ -n "$download_dir" ] && [ -f "$download_dir/$ASSET_DIR/clicd" ]; then
bin_src="$download_dir/$ASSET_DIR/clicd"
fi
fi
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"