mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2fa2449e9 | |||
| 53d56be8f9 | |||
| ec38ab9136 | |||
| fdcd7df9e9 | |||
| d6d46296fe | |||
| 3f44c7565f | |||
| 61d842d94c | |||
| ca303d33f6 | |||
| 6bdeafccf2 | |||
| 04cefe0cf1 | |||
| f28117bc5e | |||
| 2324494dd7 | |||
| ebba97f1d6 | |||
| 3dabd93d2f | |||
| 8bad52bd9e | |||
| d05ca8cc4c | |||
| 48fa14f8a7 | |||
| 5c6d6eafc9 | |||
| 2ecdb5c26f | |||
| ae02241370 | |||
| fdd83977fc | |||
| 58d86b5d08 | |||
| 7307255130 | |||
| 0e3c059236 | |||
| 61137b837d | |||
| 596bf86477 | |||
| 47a09aa177 | |||
| 2456b65ce2 | |||
| 292686a19a | |||
| 9eb7c322cf | |||
| 5ec62ca732 | |||
| a79df0d2dd | |||
| cc8fdbfede | |||
| 702d6975e5 | |||
| 84d98e40c6 | |||
| fd974d95b9 | |||
| cd258fd6ac | |||
| 0c2dd457d4 | |||
| 92e846eecc | |||
| a1d9ce8b1c | |||
| 49d8093f45 | |||
| 98ed716225 | |||
| 78276d303b | |||
| 30d2a4f4da | |||
| a54e03b924 | |||
| 4cdc6e68ba | |||
| 2ed42992ed | |||
| 4dfd7c0885 | |||
| 5ed5b4509d | |||
| 18f297b988 | |||
| d5a236943b | |||
| c54f92f892 | |||
| 86f0d079ab | |||
| 3a65d5d24a | |||
| cbe9339316 | |||
| 79d6dad684 | |||
| 55c7a9796c | |||
| f4a15a0d90 | |||
| c7742319b2 | |||
| 01c14ecba6 | |||
| 989e1b6645 | |||
| da5eea5193 | |||
| 4de86c458f | |||
| baf213e769 | |||
| 819a79e00d | |||
| 18bee369c1 | |||
| 2463715e32 |
+42
-10
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ backend/internal/server/web/*
|
||||
|
||||
# Build artifacts
|
||||
/build/
|
||||
/dist/
|
||||
Mofang/*.zip
|
||||
*.exe
|
||||
*.dll
|
||||
@@ -68,3 +69,6 @@ linux.txt
|
||||
push-release.ps1
|
||||
deploy.ps1
|
||||
backend/clicd
|
||||
api.md
|
||||
deploy-arm.ps1
|
||||
deploy-dhcp.ps1
|
||||
|
||||
+176
-2
@@ -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:
|
||||
|
||||
|
||||
+379
-31
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
use think\Db;
|
||||
|
||||
@@ -37,10 +37,10 @@ function clicd_json_response($payload)
|
||||
function clicd_MetaData()
|
||||
{
|
||||
return [
|
||||
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5',
|
||||
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5 and DeepSeek V4',
|
||||
'APIVersion' => '1.1',
|
||||
'HelpDoc' => 'https://github.com/MengMengCode/CLICD',
|
||||
'version' => '1.0.1',
|
||||
'version' => '1.0.11',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -59,10 +59,19 @@ function clicd_ConfigOptions()
|
||||
['type' => 'text', 'name' => '入站流量 GB', 'description' => 'in_out 模式下入站流量限制,0 表示不限制', 'default' => '0', 'key' => 'traffic_in_gb'],
|
||||
['type' => 'text', 'name' => '出站流量 GB', 'description' => 'in_out 模式下出站流量限制,0 表示不限制', 'default' => '0', 'key' => 'traffic_out_gb'],
|
||||
['type' => 'text', 'name' => 'IO 速度 MB/s', 'description' => '磁盘 IO 限制,0 表示不限制', 'default' => '0', 'key' => 'io_speed_mbps'],
|
||||
['type' => 'dropdown', 'name' => '分配 NAT', 'description' => '开通时是否分配 NAT 端口映射', 'default' => 'true', 'key' => 'assign_nat', 'options' => ['true' => '启用', 'false' => '禁用']],
|
||||
['type' => 'text', 'name' => 'NAT 端口数量', 'description' => '开通时分配的端口映射数量,最小 2', 'default' => '2', 'key' => 'port_mapping_count'],
|
||||
['type' => 'text', 'name' => '快照配额', 'description' => '每台实例允许保留的快照数量', 'default' => '3', 'key' => 'snapshot_limit'],
|
||||
['type' => 'text', 'name' => '额外端口', 'description' => '逗号分隔的容器端口,例如 80,443', 'default' => '', 'key' => 'extra_ports'],
|
||||
['type' => 'dropdown', 'name' => '自动公网 IPv4', 'description' => '开通时是否从 CLICD 公网 IPv4 池分配独立 IPv4', 'default' => 'false', 'key' => 'assign_ipv4', 'options' => ['true' => '启用', 'false' => '禁用']],
|
||||
['type' => 'text', 'name' => '公网 IPv4 数量', 'description' => '自动分配公网 IPv4 的数量,通常填写 1', 'default' => '1', 'key' => 'ipv4_count'],
|
||||
['type' => 'text', 'name' => '指定公网 IPv4', 'description' => '指定分配的公网 IPv4,多个用逗号分隔;留空则从地址池自动分配', 'default' => '', 'key' => 'public_ipv4s'],
|
||||
['type' => 'dropdown', 'name' => '自动 IPv6', 'description' => '开通时自动分配 IPv6', 'default' => 'false', 'key' => 'assign_ipv6', 'options' => ['true' => '启用', 'false' => '禁用']],
|
||||
['type' => 'text', 'name' => 'IPv6 数量', 'description' => '自动分配 IPv6 的数量,通常填写 1', 'default' => '1', 'key' => 'ipv6_count'],
|
||||
['type' => 'text', 'name' => '指定 IPv6', 'description' => '指定分配的 IPv6 地址,多个用逗号分隔;留空则从地址池自动分配', 'default' => '', 'key' => 'ipv6_addresses'],
|
||||
['type' => 'dropdown', 'name' => 'SSH 鉴权模式', 'description' => 'auto_password=自动生成密码,password=使用指定密码,key=使用 SSH 公钥', 'default' => 'auto_password', 'key' => 'ssh_auth_mode', 'options' => ['auto_password' => '自动密码', 'password' => '指定密码', 'key' => 'SSH 公钥']],
|
||||
['type' => 'text', 'name' => '指定 SSH 密码', 'description' => 'SSH 鉴权模式为 password 时使用;其他模式留空', 'default' => '', 'key' => 'ssh_password'],
|
||||
['type' => 'text', 'name' => 'SSH 公钥', 'description' => 'SSH 鉴权模式为 key 时使用;填写完整 public key', 'default' => '', 'key' => 'ssh_public_key'],
|
||||
['type' => 'dropdown', 'name' => '同步到期时间', 'description' => '开通/续费时把魔方到期日期同步到 CLICD,格式会转换为 YYYY-MM-DD', 'default' => 'true', 'key' => 'sync_expiry', 'options' => ['true' => '启用', 'false' => '禁用']],
|
||||
];
|
||||
}
|
||||
@@ -203,16 +212,101 @@ function clicd_container_name($params)
|
||||
return trim($name, '-.');
|
||||
}
|
||||
|
||||
function clicd_public_host($params, $container = [])
|
||||
function clicd_host_id($params)
|
||||
{
|
||||
foreach (['hostid', 'id', 'serviceid', 'service_id', 'relid'] as $key) {
|
||||
if (!empty($params[$key]) && is_numeric($params[$key])) {
|
||||
return (int)$params[$key];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function clicd_first_string($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $item) {
|
||||
if (is_array($item)) {
|
||||
foreach (['address', 'ip', 'ipv4', 'public_ip', 'public_ipv4'] as $key) {
|
||||
if (!empty($item[$key])) {
|
||||
$itemValue = trim((string)$item[$key]);
|
||||
if ($itemValue !== '') {
|
||||
return $itemValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemValue = trim((string)$item);
|
||||
if ($itemValue !== '') {
|
||||
return $itemValue;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = trim((string)$value);
|
||||
return $value;
|
||||
}
|
||||
|
||||
function clicd_public_host_from_container($container = [])
|
||||
{
|
||||
if (is_array($container)) {
|
||||
foreach (['nat_public_ip', 'public_ip', 'host_ip', 'external_ip', 'node_ip', 'nat_host'] as $key) {
|
||||
foreach (['public_ipv4s', 'public_ipv4', 'public_ip', 'ipv4_addresses', 'ipv4', 'nat_public_ip', 'host_ip', 'external_ip', 'node_ip', 'nat_host'] as $key) {
|
||||
if (!empty($container[$key])) {
|
||||
return trim((string)$container[$key]);
|
||||
$value = clicd_first_string($container[$key]);
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function clicd_public_ipv4_from_routing($params, $container = [])
|
||||
{
|
||||
if (!is_array($container)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$containerId = isset($container['id']) ? (string)$container['id'] : '';
|
||||
$containerName = isset($container['name']) ? (string)$container['name'] : clicd_container_name($params);
|
||||
|
||||
$res = clicd_request($params, '/api/v1/routing', [], 'GET', 30);
|
||||
if (!clicd_success($res) || empty($res['data']['ipv4_assignments']) || !is_array($res['data']['ipv4_assignments'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($res['data']['ipv4_assignments'] as $assignment) {
|
||||
if (!is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
$matchId = $containerId !== '' && isset($assignment['container_id']) && (string)$assignment['container_id'] === $containerId;
|
||||
$matchName = $containerName !== '' && isset($assignment['container_name']) && (string)$assignment['container_name'] === $containerName;
|
||||
if ($matchId || $matchName) {
|
||||
return clicd_first_string($assignment['address'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function clicd_public_host($params, $container = [], $useRouting = false)
|
||||
{
|
||||
$fromContainer = clicd_public_host_from_container($container);
|
||||
if ($fromContainer !== '') {
|
||||
return $fromContainer;
|
||||
}
|
||||
|
||||
if ($useRouting) {
|
||||
$fromRouting = clicd_public_ipv4_from_routing($params, $container);
|
||||
if ($fromRouting !== '') {
|
||||
return $fromRouting;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['server_ip', 'ip'] as $key) {
|
||||
if (!empty($params[$key])) {
|
||||
$value = trim((string)$params[$key]);
|
||||
@@ -271,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'] ?? '';
|
||||
@@ -280,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);
|
||||
}
|
||||
|
||||
@@ -403,6 +501,24 @@ function clicd_extra_ports($value)
|
||||
return array_values(array_unique($ports));
|
||||
}
|
||||
|
||||
function clicd_csv_values($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$parts = $value;
|
||||
} else {
|
||||
$parts = preg_split('/[,;\s]+/', (string)$value);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($parts as $part) {
|
||||
$part = trim((string)$part);
|
||||
if ($part !== '') {
|
||||
$result[] = $part;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($result));
|
||||
}
|
||||
|
||||
function clicd_expiry_from_params($params)
|
||||
{
|
||||
$options = $params['configoptions'] ?? [];
|
||||
@@ -435,6 +551,21 @@ function clicd_container_payload($params)
|
||||
{
|
||||
$options = $params['configoptions'] ?? [];
|
||||
$trafficMode = $options['traffic_mode'] ?? 'total';
|
||||
$assignNat = clicd_bool_option($options['assign_nat'] ?? 'true', true);
|
||||
$assignIpv4 = clicd_bool_option($options['assign_ipv4'] ?? 'false', false);
|
||||
$assignIpv6 = clicd_bool_option($options['assign_ipv6'] ?? 'false', false);
|
||||
$publicIpv4s = clicd_csv_values($options['public_ipv4s'] ?? '');
|
||||
$ipv6Addresses = clicd_csv_values($options['ipv6_addresses'] ?? '');
|
||||
if (!empty($publicIpv4s)) {
|
||||
$assignIpv4 = true;
|
||||
}
|
||||
if (!empty($ipv6Addresses)) {
|
||||
$assignIpv6 = true;
|
||||
}
|
||||
$sshAuthMode = strtolower(trim((string)($options['ssh_auth_mode'] ?? 'auto_password')));
|
||||
if (!in_array($sshAuthMode, ['auto_password', 'password', 'key'], true)) {
|
||||
$sshAuthMode = 'auto_password';
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => clicd_container_name($params),
|
||||
@@ -451,9 +582,18 @@ function clicd_container_payload($params)
|
||||
'traffic_out_gb' => clicd_int_option($options, 'traffic_out_gb', 0),
|
||||
'io_speed_mbps' => clicd_int_option($options, 'io_speed_mbps', 0),
|
||||
'extra_ports' => clicd_extra_ports($options['extra_ports'] ?? ''),
|
||||
'port_mapping_count' => max(2, clicd_int_option($options, 'port_mapping_count', 2)),
|
||||
'port_mapping_count' => $assignNat ? max(2, clicd_int_option($options, 'port_mapping_count', 2)) : 0,
|
||||
'assign_nat' => $assignNat,
|
||||
'assign_ipv4' => $assignIpv4,
|
||||
'ipv4_count' => max(1, clicd_int_option($options, 'ipv4_count', 1)),
|
||||
'public_ipv4s' => $publicIpv4s,
|
||||
'snapshot_limit' => max(1, clicd_int_option($options, 'snapshot_limit', 3)),
|
||||
'assign_ipv6' => clicd_bool_option($options['assign_ipv6'] ?? 'false', false),
|
||||
'assign_ipv6' => $assignIpv6,
|
||||
'ipv6_count' => max(1, clicd_int_option($options, 'ipv6_count', 1)),
|
||||
'ipv6_addresses' => $ipv6Addresses,
|
||||
'ssh_auth_mode' => $sshAuthMode,
|
||||
'ssh_password' => (string)($options['ssh_password'] ?? ''),
|
||||
'ssh_public_key' => trim((string)($options['ssh_public_key'] ?? '')),
|
||||
'expires_at' => clicd_expiry_from_params($params),
|
||||
];
|
||||
}
|
||||
@@ -477,6 +617,9 @@ function clicd_request_value($key, $default = '')
|
||||
{
|
||||
if (function_exists('input')) {
|
||||
$value = input('param.' . $key);
|
||||
if ($value === null) {
|
||||
$value = input('*.' . $key);
|
||||
}
|
||||
return $value === null ? $default : $value;
|
||||
}
|
||||
if (isset($_POST[$key])) {
|
||||
@@ -487,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 = '')
|
||||
@@ -821,16 +979,35 @@ function clicd_info_ajax($params)
|
||||
];
|
||||
}
|
||||
|
||||
function clicd_domain_status_from_container($container)
|
||||
{
|
||||
if (!is_array($container)) {
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
if (!empty($container['policy_blocked'])) {
|
||||
return 'Suspended';
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string)($container['status'] ?? '')));
|
||||
if (in_array($status, ['suspended', 'blocked', 'policy_blocked', 'disabled'], true)) {
|
||||
return 'Suspended';
|
||||
}
|
||||
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
function clicd_update_host_from_container($params, $container)
|
||||
{
|
||||
if (empty($params['hostid']) || !is_array($container)) {
|
||||
$hostId = clicd_host_id($params);
|
||||
if ($hostId <= 0 || !is_array($container)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$update = [
|
||||
'domainstatus' => (($container['status'] ?? '') === 'running') ? 'Active' : 'Suspended',
|
||||
'domainstatus' => clicd_domain_status_from_container($container),
|
||||
'username' => 'root',
|
||||
'dedicatedip' => clicd_public_host($params, $container),
|
||||
'dedicatedip' => clicd_public_host($params, $container, true),
|
||||
];
|
||||
|
||||
$sshPort = clicd_container_ssh_port($container);
|
||||
@@ -844,7 +1021,7 @@ function clicd_update_host_from_container($params, $container)
|
||||
}
|
||||
|
||||
try {
|
||||
Db::name('host')->where('id', $params['hostid'])->update($update);
|
||||
Db::name('host')->where('id', $hostId)->update($update);
|
||||
} catch (\Exception $e) {
|
||||
clicd_debug('host update failed', $e->getMessage());
|
||||
}
|
||||
@@ -879,21 +1056,24 @@ function clicd_CreateAccount($params)
|
||||
return ['status' => 'error', 'msg' => clicd_message($res, '开通失败')];
|
||||
}
|
||||
|
||||
$detail = clicd_find_container($params);
|
||||
if (clicd_success($detail) && isset($detail['data'])) {
|
||||
clicd_update_host_from_container($params, $detail['data']);
|
||||
} elseif (!empty($params['hostid'])) {
|
||||
$hostId = clicd_host_id($params);
|
||||
if ($hostId > 0) {
|
||||
try {
|
||||
Db::name('host')->where('id', $params['hostid'])->update([
|
||||
Db::name('host')->where('id', $hostId)->update([
|
||||
'domainstatus' => 'Active',
|
||||
'username' => 'root',
|
||||
'dedicatedip' => clicd_public_host($params),
|
||||
'dedicatedip' => clicd_public_ipv4_from_routing($params) ?: clicd_public_host($params),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return ['status' => 'error', 'msg' => '开通成功但同步魔方数据库失败: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
$detail = clicd_find_container($params);
|
||||
if (clicd_success($detail) && isset($detail['data'])) {
|
||||
clicd_update_host_from_container($params, $detail['data']);
|
||||
}
|
||||
|
||||
return ['status' => 'success', 'msg' => clicd_message($res, '开通成功')];
|
||||
}
|
||||
|
||||
@@ -1001,9 +1181,10 @@ function clicd_CrackPassword($params, $new_pass)
|
||||
}
|
||||
|
||||
$password = $res['data']['ssh_password'] ?? $res['data']['password'] ?? $new_pass;
|
||||
if (!empty($params['hostid'])) {
|
||||
$hostId = clicd_host_id($params);
|
||||
if ($hostId > 0) {
|
||||
try {
|
||||
Db::name('host')->where('id', $params['hostid'])->update(['password' => clicd_store_password($password)]);
|
||||
Db::name('host')->where('id', $hostId)->update(['password' => clicd_store_password($password)]);
|
||||
$detail = clicd_find_container($params);
|
||||
if (clicd_success($detail) && isset($detail['data'])) {
|
||||
clicd_update_host_from_container($params, $detail['data']);
|
||||
@@ -1242,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')];
|
||||
@@ -1262,19 +1449,163 @@ function clicd_webssh($params)
|
||||
];
|
||||
}
|
||||
|
||||
function clicd_firewallList($params)
|
||||
{
|
||||
$container = [];
|
||||
$containerId = clicd_container_api_id($params, $container);
|
||||
$res = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', [], 'GET', 30);
|
||||
if (!clicd_success($res) || empty($res['data'])) {
|
||||
return ['status' => 'error', 'msg' => clicd_message($res, '获取防火墙设置失败')];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 200,
|
||||
'msg' => '获取成功',
|
||||
'data' => $res['data'],
|
||||
];
|
||||
}
|
||||
|
||||
function clicd_firewallUpdate($params)
|
||||
{
|
||||
$input = clicd_json_input();
|
||||
$enabled = clicd_param_value($input, 'enabled', 'true');
|
||||
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
|
||||
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
|
||||
$rules = clicd_param_value($input, 'rules', '[]');
|
||||
|
||||
if (is_string($rules)) {
|
||||
$decodedRules = json_decode($rules, true);
|
||||
if (is_array($decodedRules)) {
|
||||
$rules = $decodedRules;
|
||||
}
|
||||
}
|
||||
if (!is_array($rules)) {
|
||||
$rules = [];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'enabled' => $enabled,
|
||||
'rules' => $rules,
|
||||
];
|
||||
if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) {
|
||||
$payload['default_action'] = $defaultAction;
|
||||
}
|
||||
|
||||
$container = [];
|
||||
$containerId = clicd_container_api_id($params, $container);
|
||||
$res = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30);
|
||||
if (!clicd_success($res)) {
|
||||
return ['status' => 'error', 'msg' => clicd_message($res, '更新防火墙设置失败')];
|
||||
}
|
||||
|
||||
// GET after PUT to confirm the actual state after CLICD processes it
|
||||
$getRes = clicd_request($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', [], 'GET', 30);
|
||||
$actualData = [];
|
||||
if (clicd_success($getRes) && !empty($getRes['data']) && is_array($getRes['data'])) {
|
||||
$actualData = $getRes['data'];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 200,
|
||||
'msg' => clicd_message($res, '防火墙设置已更新'),
|
||||
'data' => $actualData,
|
||||
];
|
||||
}
|
||||
|
||||
function clicd_firewall_ajax($params)
|
||||
{
|
||||
$input = clicd_json_input();
|
||||
$action = strtolower(trim((string)clicd_param_value($input, 'action', '')));
|
||||
$debug = [clicd_debug_entry('Firewall ajax received', [
|
||||
'action' => $action,
|
||||
'input' => $input,
|
||||
'query' => $_GET,
|
||||
])];
|
||||
|
||||
$container = [];
|
||||
$containerId = clicd_container_api_id($params, $container);
|
||||
$debug[] = clicd_debug_entry('Container resolved', [
|
||||
'container_id' => $containerId,
|
||||
'container' => [
|
||||
'id' => $container['id'] ?? null,
|
||||
'uuid' => $container['uuid'] ?? null,
|
||||
'name' => $container['name'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
if (!in_array($action, ['list', 'update'], true)) {
|
||||
return ['status' => 'error', 'msg' => '未知防火墙操作', 'debug' => $debug];
|
||||
}
|
||||
|
||||
if ($action === 'list') {
|
||||
$call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', [], 'GET', 30);
|
||||
$debug[] = $call['debug'];
|
||||
$res = $call['response'];
|
||||
if (!clicd_success($res) || empty($res['data'])) {
|
||||
return ['status' => 'error', 'msg' => clicd_message($res, '获取防火墙设置失败'), 'debug' => $debug];
|
||||
}
|
||||
return [
|
||||
'status' => 'success',
|
||||
'msg' => '获取成功',
|
||||
'data' => $res['data'],
|
||||
'debug' => $debug,
|
||||
];
|
||||
}
|
||||
|
||||
// update
|
||||
$enabled = clicd_param_value($input, 'enabled', 'true');
|
||||
$enabled = filter_var($enabled, FILTER_VALIDATE_BOOLEAN);
|
||||
$defaultAction = strtoupper(trim((string)clicd_param_value($input, 'default_action', '')));
|
||||
$rules = clicd_param_value($input, 'rules', '[]');
|
||||
|
||||
if (is_string($rules)) {
|
||||
$decodedRules = json_decode($rules, true);
|
||||
if (is_array($decodedRules)) {
|
||||
$rules = $decodedRules;
|
||||
}
|
||||
}
|
||||
if (!is_array($rules)) {
|
||||
$rules = [];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'enabled' => $enabled,
|
||||
'rules' => $rules,
|
||||
];
|
||||
if (in_array($defaultAction, ['ACCEPT', 'DROP'], true)) {
|
||||
$payload['default_action'] = $defaultAction;
|
||||
}
|
||||
|
||||
$call = clicd_request_debug($params, '/api/v1/containers/' . rawurlencode($containerId) . '/firewall', $payload, 'PUT', 30);
|
||||
$debug[] = $call['debug'];
|
||||
$res = $call['response'];
|
||||
|
||||
if (!clicd_success($res)) {
|
||||
return ['status' => 'error', 'msg' => clicd_message($res, '更新防火墙设置失败'), 'debug' => $debug];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'msg' => clicd_message($res, '防火墙设置已更新'),
|
||||
'data' => $res['data'] ?? [],
|
||||
'debug' => $debug,
|
||||
];
|
||||
}
|
||||
|
||||
function clicd_AllowFunction()
|
||||
{
|
||||
return [
|
||||
'client' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh'],
|
||||
'admin' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh'],
|
||||
'client' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh', 'firewallList', 'firewallUpdate'],
|
||||
'admin' => ['TrafficReset', 'randomPort', 'addNat', 'updateNat', 'deleteNat', 'natList', 'infoData', 'webssh', 'firewallList', 'firewallUpdate'],
|
||||
];
|
||||
}
|
||||
|
||||
function clicd_ClientArea($params)
|
||||
{
|
||||
return [
|
||||
'info' => ['name' => '实例信息'],
|
||||
'nat' => ['name' => 'NAT转发'],
|
||||
'info' => ['name' => '实例信息'],
|
||||
'nat' => ['name' => 'NAT转发'],
|
||||
'firewall' => ['name' => '防火墙'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1287,8 +1618,11 @@ function clicd_ClientAreaOutput($params, $key)
|
||||
if ($func === 'infoajax') {
|
||||
clicd_json_response(clicd_info_ajax($params));
|
||||
}
|
||||
if ($func === 'firewallajax') {
|
||||
clicd_json_response(clicd_firewall_ajax($params));
|
||||
}
|
||||
|
||||
if (!in_array($key, ['info', 'nat'], true)) {
|
||||
if (!in_array($key, ['info', 'nat', 'firewall'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1298,6 +1632,7 @@ function clicd_ClientAreaOutput($params, $key)
|
||||
}
|
||||
|
||||
$c = $res['data'];
|
||||
$publicHost = clicd_public_host($params, $c, true);
|
||||
|
||||
if ($key === 'nat') {
|
||||
$operation = clicd_handle_nat_post($params);
|
||||
@@ -1315,8 +1650,8 @@ function clicd_ClientAreaOutput($params, $key)
|
||||
'container' => $c,
|
||||
'container_name'=> $c['name'] ?? clicd_container_name($params),
|
||||
'ssh_port' => $c['ssh_port'] ?? '',
|
||||
'server_ip' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
|
||||
'nat_host' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
|
||||
'server_ip' => $publicHost,
|
||||
'nat_host' => $publicHost,
|
||||
'operation_msg' => $operationMsg,
|
||||
'service_id' => clicd_request_value('id', $params['hostid'] ?? ''),
|
||||
'area_key' => 'nat',
|
||||
@@ -1325,6 +1660,19 @@ function clicd_ClientAreaOutput($params, $key)
|
||||
];
|
||||
}
|
||||
|
||||
if ($key === 'firewall') {
|
||||
return [
|
||||
'template' => 'templates/firewall.html',
|
||||
'vars' => [
|
||||
'container' => $c,
|
||||
'container_name' => $c['name'] ?? clicd_container_name($params),
|
||||
'server_ip' => $publicHost,
|
||||
'service_id' => clicd_request_value('id', $params['hostid'] ?? ''),
|
||||
'area_key' => 'firewall',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$initialRxBytes = (int)($c['traffic_used_rx'] ?? $c['rx_bytes'] ?? 0);
|
||||
$initialTxBytes = (int)($c['traffic_used_tx'] ?? $c['tx_bytes'] ?? 0);
|
||||
$initialTrafficUsed = ($initialRxBytes || $initialTxBytes) ? round(($initialRxBytes + $initialTxBytes) / 1073741824, 2) : '-';
|
||||
@@ -1342,8 +1690,8 @@ function clicd_ClientAreaOutput($params, $key)
|
||||
'vars' => [
|
||||
'container' => $c,
|
||||
'status_text' => (($c['status'] ?? '') === 'running') ? '运行中' : '已关机',
|
||||
'server_ip' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
|
||||
'ssh_host' => $params['server_ip'] ?? parse_url(clicd_base_url($params), PHP_URL_HOST),
|
||||
'server_ip' => $publicHost,
|
||||
'ssh_host' => $publicHost,
|
||||
'ssh_port' => $c['ssh_port'] ?? '',
|
||||
'ssh_password' => $c['ssh_password'] ?? '',
|
||||
'ipv4' => $c['ip'] ?? '',
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
$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
@@ -2,7 +2,7 @@
|
||||
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
|
||||
</p>
|
||||
|
||||
<h1 align="center">CLICD</h1>
|
||||
<h1 align="center">CLICD <sub></sub></h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
@@ -73,7 +73,7 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
|
||||
| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 |
|
||||
| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 |
|
||||
| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 |
|
||||
| 资源限制 | 支持 CPU、内存、磁盘、Swap、带宽用量、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
|
||||
| 资源限制 | 支持 CPU、内存、磁盘、Swap、独立上行/下行带宽、读/写 I/O 限速、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
|
||||
| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 |
|
||||
| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 |
|
||||
| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 |
|
||||
@@ -119,10 +119,4 @@ This open-source software is intended solely for educational purposes, specifica
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
[](https://meteor-history.com)
|
||||
|
||||
+4
-6
@@ -1,18 +1,16 @@
|
||||
module clicd
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.5
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/term v0.37.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/sys v0.45.0
|
||||
modernc.org/sqlite v1.29.10
|
||||
)
|
||||
|
||||
|
||||
+6
-6
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
|
||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type ContainerMetricPoint struct {
|
||||
TS int64 `json:"ts"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Memory float64 `json:"memory"`
|
||||
Network float64 `json:"network"`
|
||||
NetworkRx float64 `json:"network_rx"`
|
||||
NetworkTx float64 `json:"network_tx"`
|
||||
DiskIO float64 `json:"disk_io"`
|
||||
DiskRead float64 `json:"disk_read"`
|
||||
DiskWrite float64 `json:"disk_write"`
|
||||
}
|
||||
|
||||
var containerMetricSamplerOnce sync.Once
|
||||
var containerMetricMu sync.RWMutex
|
||||
var containerMetricHistory = map[string][]ContainerMetricPoint{}
|
||||
var containerMetricInFlight sync.Map
|
||||
|
||||
const (
|
||||
containerMetricSampleInterval = 30 * time.Second
|
||||
containerMetricSampleTimeout = 20 * time.Second
|
||||
containerMetricConcurrency = 4
|
||||
)
|
||||
|
||||
func StartContainerMetricSampler() {
|
||||
containerMetricSamplerOnce.Do(func() {
|
||||
go func() {
|
||||
sampleAllContainerMetrics()
|
||||
ticker := time.NewTicker(containerMetricSampleInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
sampleAllContainerMetrics()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func sampleAllContainerMetrics() {
|
||||
containers, _ := listByRuntime()
|
||||
sem := make(chan struct{}, containerMetricConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, c := range containers {
|
||||
c := c
|
||||
if c.Status != "running" {
|
||||
continue
|
||||
}
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
sampleContainerMetricWithTimeout(c)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
pruneContainerMetricHistory()
|
||||
}
|
||||
|
||||
func sampleContainerMetricWithTimeout(c config.Container) {
|
||||
key := containerMetricKey(c)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, loaded := containerMetricInFlight.LoadOrStore(key, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
done := make(chan struct{}, 1)
|
||||
go func() {
|
||||
defer containerMetricInFlight.Delete(key)
|
||||
if usage, err := usageByRuntime(c.ID); err == nil {
|
||||
appendContainerMetricPoint(c, usage)
|
||||
}
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(containerMetricSampleTimeout):
|
||||
}
|
||||
}
|
||||
|
||||
func appendContainerMetricPoint(c config.Container, usage map[string]interface{}) {
|
||||
key := containerMetricKey(c)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
memoryTotal := numberFromUsage(usage, "memory_total_bytes")
|
||||
if memoryTotal <= 0 {
|
||||
memoryTotal = float64(c.RAMMB) * 1024 * 1024
|
||||
}
|
||||
memoryPct := 0.0
|
||||
if memoryTotal > 0 {
|
||||
memoryPct = clampPercent(numberFromUsage(usage, "memory_usage_bytes") / memoryTotal * 100)
|
||||
}
|
||||
vcpu := c.VCPU
|
||||
if vcpu <= 0 {
|
||||
vcpu = 1
|
||||
}
|
||||
cpuPct := clampPercent(numberFromUsage(usage, "cpu_usage_pct") / vcpu)
|
||||
networkRx := positiveNumberFromUsage(usage, "network_rx_bps")
|
||||
networkTx := positiveNumberFromUsage(usage, "network_tx_bps")
|
||||
diskRead := positiveNumberFromUsage(usage, "disk_read_bps")
|
||||
diskWrite := positiveNumberFromUsage(usage, "disk_write_bps")
|
||||
point := ContainerMetricPoint{
|
||||
TS: time.Now().UnixMilli(),
|
||||
CPU: cpuPct,
|
||||
Memory: memoryPct,
|
||||
NetworkRx: networkRx,
|
||||
NetworkTx: networkTx,
|
||||
Network: networkRx + networkTx,
|
||||
DiskRead: diskRead,
|
||||
DiskWrite: diskWrite,
|
||||
DiskIO: diskRead + diskWrite,
|
||||
}
|
||||
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
|
||||
|
||||
containerMetricMu.Lock()
|
||||
defer containerMetricMu.Unlock()
|
||||
|
||||
history := containerMetricHistory[key]
|
||||
keepFrom := 0
|
||||
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
|
||||
keepFrom++
|
||||
}
|
||||
if keepFrom > 0 {
|
||||
copy(history, history[keepFrom:])
|
||||
history = history[:len(history)-keepFrom]
|
||||
}
|
||||
containerMetricHistory[key] = append(history, point)
|
||||
}
|
||||
|
||||
func getContainerMetricHistory(c *config.Container) []ContainerMetricPoint {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
key := containerMetricKey(*c)
|
||||
containerMetricMu.RLock()
|
||||
defer containerMetricMu.RUnlock()
|
||||
|
||||
history := containerMetricHistory[key]
|
||||
result := make([]ContainerMetricPoint, len(history))
|
||||
copy(result, history)
|
||||
return result
|
||||
}
|
||||
|
||||
func pruneContainerMetricHistory() {
|
||||
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
|
||||
valid := map[string]bool{}
|
||||
if config.AppConfig != nil {
|
||||
for _, c := range config.AppConfig.Containers {
|
||||
valid[containerMetricKey(c)] = true
|
||||
}
|
||||
}
|
||||
|
||||
containerMetricMu.Lock()
|
||||
defer containerMetricMu.Unlock()
|
||||
|
||||
for key, history := range containerMetricHistory {
|
||||
if !valid[key] {
|
||||
delete(containerMetricHistory, key)
|
||||
continue
|
||||
}
|
||||
keepFrom := 0
|
||||
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
|
||||
keepFrom++
|
||||
}
|
||||
if keepFrom > 0 {
|
||||
copy(history, history[keepFrom:])
|
||||
containerMetricHistory[key] = history[:len(history)-keepFrom]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containerMetricKey(c config.Container) string {
|
||||
if c.UUID != "" {
|
||||
return "uuid:" + c.UUID
|
||||
}
|
||||
if c.ID > 0 {
|
||||
return fmt.Sprintf("id:%d", c.ID)
|
||||
}
|
||||
if c.Name != "" {
|
||||
return "name:" + c.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func numberFromUsage(usage map[string]interface{}, key string) float64 {
|
||||
value, ok := usage[key]
|
||||
if !ok || value == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
case float32:
|
||||
return float64(v)
|
||||
case int:
|
||||
return float64(v)
|
||||
case int64:
|
||||
return float64(v)
|
||||
case int32:
|
||||
return float64(v)
|
||||
case uint:
|
||||
return float64(v)
|
||||
case uint64:
|
||||
return float64(v)
|
||||
case uint32:
|
||||
return float64(v)
|
||||
case json.Number:
|
||||
n, _ := v.Float64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseFloat(v, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func positiveNumberFromUsage(usage map[string]interface{}, key string) float64 {
|
||||
value := numberFromUsage(usage, key)
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -29,8 +30,9 @@ func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"rules": c.FirewallRules,
|
||||
"enabled": c.FirewallEnabled,
|
||||
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -43,17 +45,32 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Rules *[]config.FirewallRule `json:"rules"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
DefaultAction *string `json:"default_action"`
|
||||
Rules *[]config.FirewallRule `json:"rules"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
oldEnabled := c.FirewallEnabled
|
||||
oldDefaultAction := c.FirewallDefaultAction
|
||||
oldRules := append([]config.FirewallRule(nil), c.FirewallRules...)
|
||||
|
||||
if req.Enabled != nil {
|
||||
c.FirewallEnabled = *req.Enabled
|
||||
}
|
||||
if req.DefaultAction != nil {
|
||||
action := normalizeFirewallDefaultAction(*req.DefaultAction)
|
||||
if action == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"})
|
||||
return
|
||||
}
|
||||
c.FirewallDefaultAction = action
|
||||
} else if strings.TrimSpace(c.FirewallDefaultAction) == "" {
|
||||
c.FirewallDefaultAction = "DROP"
|
||||
}
|
||||
if req.Rules != nil {
|
||||
// Validate and assign IDs to new rules
|
||||
rules := *req.Rules
|
||||
@@ -61,9 +78,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
|
||||
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
|
||||
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action))
|
||||
rules[i].Network = normalizeFirewallNetwork(rules[i].Network)
|
||||
rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP)
|
||||
rules[i].Port = strings.TrimSpace(rules[i].Port)
|
||||
|
||||
if rules[i].Network == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"})
|
||||
return
|
||||
}
|
||||
if rules[i].Direction != "in" && rules[i].Direction != "out" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
|
||||
return
|
||||
@@ -76,11 +98,21 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
|
||||
return
|
||||
}
|
||||
if rules[i].ID == "" {
|
||||
if rules[i].SourceIP != "" {
|
||||
if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") {
|
||||
rules[i].ID = generateFirewallRuleID()
|
||||
}
|
||||
// Validate port spec
|
||||
if rules[i].Port != "" {
|
||||
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"})
|
||||
return
|
||||
}
|
||||
if err := validatePortSpec(rules[i].Port); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
|
||||
return
|
||||
@@ -90,11 +122,14 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c.FirewallRules = rules
|
||||
}
|
||||
|
||||
config.SaveConfig()
|
||||
|
||||
// Apply firewall rules to iptables if container is running
|
||||
if c.Status == "running" {
|
||||
if err := lxc.ApplyFirewallRules(id); err != nil {
|
||||
c.FirewallEnabled = oldEnabled
|
||||
c.FirewallDefaultAction = oldDefaultAction
|
||||
c.FirewallRules = oldRules
|
||||
_ = lxc.ApplyFirewallRules(id)
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -102,28 +137,54 @@ func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
|
||||
// If disabled and not running, clean any lingering rules
|
||||
lxc.CleanFirewallRules(id)
|
||||
}
|
||||
config.SaveConfig()
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: "Firewall updated",
|
||||
Data: map[string]interface{}{
|
||||
"enabled": c.FirewallEnabled,
|
||||
"rules": c.FirewallRules,
|
||||
"enabled": c.FirewallEnabled,
|
||||
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
|
||||
"rules": c.FirewallRules,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" || action == "DROP" {
|
||||
return action
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeFirewallNetwork(network string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
switch network {
|
||||
case "", "ipv4", "nat4":
|
||||
return "ipv4"
|
||||
case "ipv6":
|
||||
return "ipv6"
|
||||
case "all", "both":
|
||||
return "all"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func validatePortSpec(port string) error {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
|
||||
partCount := 0
|
||||
for _, part := range strings.Split(port, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
return &portValidationError{port}
|
||||
}
|
||||
partCount++
|
||||
if strings.Contains(part, "-") {
|
||||
// Range
|
||||
bounds := strings.SplitN(part, "-", 2)
|
||||
@@ -135,6 +196,9 @@ func validatePortSpec(port string) error {
|
||||
if err != nil || hi < 1 || hi > 65535 {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
if hi < lo {
|
||||
return &portValidationError{part}
|
||||
}
|
||||
} else {
|
||||
p, err := strconv.Atoi(part)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
@@ -142,9 +206,48 @@ func validatePortSpec(port string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if partCount > 15 {
|
||||
return &portValidationError{"too many ports; maximum 15 items per rule"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFirewallIPSpec(value string, network string) error {
|
||||
var addr netip.Addr
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr = prefix.Addr()
|
||||
} else {
|
||||
parsed, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr = parsed
|
||||
}
|
||||
switch network {
|
||||
case "ipv4":
|
||||
if !addr.Is4() {
|
||||
return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value}
|
||||
}
|
||||
case "ipv6":
|
||||
if !addr.Is6() || addr.Is4In6() {
|
||||
return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ipValidationError struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (e *ipValidationError) Error() string {
|
||||
return e.value
|
||||
}
|
||||
|
||||
type portValidationError struct {
|
||||
port string
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -130,6 +132,11 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
getUsage(w, r, id)
|
||||
case action == "history" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getContainerMetricHistory(c)})
|
||||
case action == "traffic" && r.Method == http.MethodGet:
|
||||
if !requireScope(w, r, "container:read") {
|
||||
return
|
||||
@@ -165,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
assignIPv6(w, r, id)
|
||||
case action == "public-ipv4" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "container:network") {
|
||||
return
|
||||
}
|
||||
updatePublicIPv4(w, r, id)
|
||||
case action == "ipv6-addresses" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "ipv6:assign") {
|
||||
return
|
||||
}
|
||||
updateIPv6Addresses(w, r, id)
|
||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||
handleContainerSnapshots(w, r, id, action)
|
||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||
@@ -210,10 +227,21 @@ func listContainers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg lxc.ContainerConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(body, &fields)
|
||||
if err := normalizeCreateResourceLimits(&cfg, fields); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
|
||||
return
|
||||
@@ -227,6 +255,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if ids, err := normalizeAllowedImageIDs(cfg.AllowedImageIDs); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
} else {
|
||||
cfg.AllowedImageIDs = ids
|
||||
}
|
||||
if cfg.VCPU <= 0 {
|
||||
cfg.VCPU = 1
|
||||
}
|
||||
@@ -275,6 +309,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateStoragePool(&cfg); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -386,10 +424,14 @@ func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
|
||||
func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req struct {
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
IOMBps int `json:"io_speed_mbps"`
|
||||
BWMbps int `json:"network_bw_mbps"`
|
||||
VCPU *float64 `json:"vcpu"`
|
||||
RAMMB *int `json:"ram_mb"`
|
||||
IOMBps *int `json:"io_speed_mbps"`
|
||||
IOReadMBps *int `json:"io_read_mbps"`
|
||||
IOWriteMBps *int `json:"io_write_mbps"`
|
||||
BWMbps *int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps *int `json:"network_down_mbps"`
|
||||
NetworkUpMbps *int `json:"network_up_mbps"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
|
||||
@@ -404,29 +446,42 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
// Update config
|
||||
nextVCPU := c.VCPU
|
||||
nextRAMMB := c.RAMMB
|
||||
if req.VCPU > 0 {
|
||||
nextVCPU = req.VCPU
|
||||
if req.VCPU != nil {
|
||||
nextVCPU = *req.VCPU
|
||||
}
|
||||
if req.RAMMB > 0 {
|
||||
nextRAMMB = req.RAMMB
|
||||
if req.RAMMB != nil {
|
||||
nextRAMMB = *req.RAMMB
|
||||
}
|
||||
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
for name, value := range map[string]*int{
|
||||
"network_bw_mbps": req.BWMbps,
|
||||
"network_down_mbps": req.NetworkDownMbps,
|
||||
"network_up_mbps": req.NetworkUpMbps,
|
||||
"io_speed_mbps": req.IOMBps,
|
||||
"io_read_mbps": req.IOReadMBps,
|
||||
"io_write_mbps": req.IOWriteMBps,
|
||||
} {
|
||||
if err := rejectNegativeLimit(name, value); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.VCPU = nextVCPU
|
||||
c.RAMMB = nextRAMMB
|
||||
c.IOSpeedMBps = req.IOMBps
|
||||
c.NetworkBWMbps = req.BWMbps
|
||||
applyNetworkLimitPatch(c, req.BWMbps, req.NetworkDownMbps, req.NetworkUpMbps)
|
||||
applyIOLimitPatch(c, req.IOMBps, req.IOReadMBps, req.IOWriteMBps)
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
config.SaveConfig()
|
||||
|
||||
// Re-apply resource limits to running container
|
||||
if c.Status == "running" {
|
||||
if err := applyLimitsByRuntime(c); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-apply persisted/runtime limits. LXC also uses this path to migrate
|
||||
// old managed config lines such as lxc.prlimit.nproc.
|
||||
if err := applyLimitsByRuntime(c); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
msg := "Resource limits updated"
|
||||
@@ -436,6 +491,114 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg})
|
||||
}
|
||||
|
||||
func normalizeCreateResourceLimits(cfg *lxc.ContainerConfig, fields map[string]json.RawMessage) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if err := rejectNegativeCreateLimits(*cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
bwSet := hasJSONField(fields, "network_bw_mbps")
|
||||
downSet := hasJSONField(fields, "network_down_mbps")
|
||||
upSet := hasJSONField(fields, "network_up_mbps")
|
||||
if bwSet {
|
||||
if !downSet {
|
||||
cfg.NetworkDownMbps = cfg.NetworkBWMbps
|
||||
}
|
||||
if !upSet {
|
||||
cfg.NetworkUpMbps = cfg.NetworkBWMbps
|
||||
}
|
||||
}
|
||||
ioSet := hasJSONField(fields, "io_speed_mbps")
|
||||
readSet := hasJSONField(fields, "io_read_mbps")
|
||||
writeSet := hasJSONField(fields, "io_write_mbps")
|
||||
if ioSet {
|
||||
if !readSet {
|
||||
cfg.IOReadMBps = cfg.IOSpeedMBps
|
||||
}
|
||||
if !writeSet {
|
||||
cfg.IOWriteMBps = cfg.IOSpeedMBps
|
||||
}
|
||||
}
|
||||
cfg.NormalizeResourceAliases()
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectNegativeCreateLimits(cfg lxc.ContainerConfig) error {
|
||||
for name, value := range map[string]int{
|
||||
"network_bw_mbps": cfg.NetworkBWMbps,
|
||||
"network_down_mbps": cfg.NetworkDownMbps,
|
||||
"network_up_mbps": cfg.NetworkUpMbps,
|
||||
"io_speed_mbps": cfg.IOSpeedMBps,
|
||||
"io_read_mbps": cfg.IOReadMBps,
|
||||
"io_write_mbps": cfg.IOWriteMBps,
|
||||
} {
|
||||
if value < 0 {
|
||||
return fmt.Errorf("%s cannot be negative", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasJSONField(fields map[string]json.RawMessage, name string) bool {
|
||||
if fields == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := fields[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func rejectNegativeLimit(name string, value *int) error {
|
||||
if value != nil && *value < 0 {
|
||||
return fmt.Errorf("%s cannot be negative", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyNetworkLimitPatch(c *config.Container, legacy *int, down *int, up *int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
nextDown := c.NetworkDownMbps
|
||||
nextUp := c.NetworkUpMbps
|
||||
if legacy != nil {
|
||||
nextDown = *legacy
|
||||
nextUp = *legacy
|
||||
}
|
||||
if down != nil {
|
||||
nextDown = *down
|
||||
}
|
||||
if up != nil {
|
||||
nextUp = *up
|
||||
}
|
||||
c.NetworkDownMbps = nextDown
|
||||
c.NetworkUpMbps = nextUp
|
||||
c.NetworkBWMbps = config.LegacySymmetricLimit(nextDown, nextUp)
|
||||
}
|
||||
|
||||
func applyIOLimitPatch(c *config.Container, legacy *int, read *int, write *int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
config.NormalizeContainerResourceAliases(c)
|
||||
nextRead := c.IOReadMBps
|
||||
nextWrite := c.IOWriteMBps
|
||||
if legacy != nil {
|
||||
nextRead = *legacy
|
||||
nextWrite = *legacy
|
||||
}
|
||||
if read != nil {
|
||||
nextRead = *read
|
||||
}
|
||||
if write != nil {
|
||||
nextWrite = *write
|
||||
}
|
||||
c.IOReadMBps = nextRead
|
||||
c.IOWriteMBps = nextWrite
|
||||
c.IOSpeedMBps = config.LegacySymmetricLimit(nextRead, nextWrite)
|
||||
}
|
||||
|
||||
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
@@ -443,9 +606,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
|
||||
@@ -512,6 +680,18 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||
}
|
||||
|
||||
// HandleHostHistory returns host resource samples collected by the server.
|
||||
func HandleHostHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
return
|
||||
}
|
||||
if !requireScope(w, r, "host:read") {
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostMetricHistory()})
|
||||
}
|
||||
|
||||
func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && lxc.IsExpired(*c) {
|
||||
|
||||
+495
-32
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -21,12 +23,13 @@ import (
|
||||
)
|
||||
|
||||
type HostInfo struct {
|
||||
CPU CpuInfo `json:"cpu"`
|
||||
RAM MemoryInfo `json:"ram"`
|
||||
Disk DiskInfo `json:"disk"`
|
||||
Network NetworkInfo `json:"network"`
|
||||
DiskIO DiskIOInfo `json:"disk_io"`
|
||||
Load LoadInfo `json:"load"`
|
||||
CPU CpuInfo `json:"cpu"`
|
||||
RAM MemoryInfo `json:"ram"`
|
||||
Disk DiskInfo `json:"disk"`
|
||||
Network NetworkInfo `json:"network"`
|
||||
DiskIO DiskIOInfo `json:"disk_io"`
|
||||
Load LoadInfo `json:"load"`
|
||||
Runtime HostRuntimeProbe `json:"runtime"`
|
||||
}
|
||||
|
||||
type HostProbeReport struct {
|
||||
@@ -215,10 +218,34 @@ type DiskIOInfo struct {
|
||||
WriteBps float64 `json:"write_bps"`
|
||||
}
|
||||
|
||||
type HostMetricPoint struct {
|
||||
TS int64 `json:"ts"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Memory float64 `json:"memory"`
|
||||
Network float64 `json:"network"`
|
||||
NetworkRx float64 `json:"network_rx"`
|
||||
NetworkTx float64 `json:"network_tx"`
|
||||
DiskIO float64 `json:"disk_io"`
|
||||
DiskRead float64 `json:"disk_read"`
|
||||
DiskWrite float64 `json:"disk_write"`
|
||||
DiskUsagePct float64 `json:"disk_usage_pct"`
|
||||
}
|
||||
|
||||
var hostCPUMu sync.Mutex
|
||||
var lastHostCPU cpuTimes
|
||||
var hostIOMu sync.Mutex
|
||||
var lastHostIO hostIOSample
|
||||
var hostMetricSamplerOnce sync.Once
|
||||
var hostMetricMu sync.RWMutex
|
||||
var hostMetricHistory []HostMetricPoint
|
||||
var egressIPv4Mu sync.Mutex
|
||||
var cachedEgressIPv4 lxc.PublicIPInfo
|
||||
var cachedEgressIPv4At time.Time
|
||||
|
||||
const (
|
||||
hostMetricSampleInterval = 30 * time.Second
|
||||
hostMetricRetention = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type cpuTimes struct {
|
||||
Total uint64
|
||||
@@ -234,6 +261,10 @@ type hostIOSample struct {
|
||||
}
|
||||
|
||||
func getHostInfo() HostInfo {
|
||||
return getHostInfoWithNetworkDetails(true)
|
||||
}
|
||||
|
||||
func getHostInfoWithNetworkDetails(includeDetails bool) HostInfo {
|
||||
info := HostInfo{
|
||||
CPU: CpuInfo{Cores: runtime.NumCPU()},
|
||||
}
|
||||
@@ -241,11 +272,107 @@ func getHostInfo() HostInfo {
|
||||
info.RAM = getMemoryInfo()
|
||||
info.Disk = getDiskInfo()
|
||||
info.CPU.Usage = getCPUUsage()
|
||||
info.Network, info.DiskIO = getHostRates()
|
||||
info.Network, info.DiskIO = getHostRates(includeDetails)
|
||||
info.Load = getLoadInfo()
|
||||
info.Runtime = detectRuntimeProbeQuick()
|
||||
return info
|
||||
}
|
||||
|
||||
func detectRuntimeProbeQuick() HostRuntimeProbe {
|
||||
devKVM := fileExists("/dev/kvm")
|
||||
nested, detail := detectNestedVirtualization()
|
||||
lxcOK := commandExists("lxc-create")
|
||||
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
|
||||
kvmOK := kvmSupportedArch && devKVM && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
|
||||
probe := HostRuntimeProbe{
|
||||
LXCAvailable: lxcOK,
|
||||
KVMAvailable: kvmOK,
|
||||
DevKVM: devKVM,
|
||||
NestedVirtualization: nested,
|
||||
NestedDetail: detail,
|
||||
SupportMode: "unsupported",
|
||||
}
|
||||
if probe.KVMAvailable {
|
||||
probe.SupportMode = "kvm_lxc"
|
||||
} else if probe.LXCAvailable {
|
||||
probe.SupportMode = "lxc_only"
|
||||
}
|
||||
return probe
|
||||
}
|
||||
|
||||
func StartHostMetricSampler() {
|
||||
hostMetricSamplerOnce.Do(func() {
|
||||
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
|
||||
go func() {
|
||||
ticker := time.NewTicker(hostMetricSampleInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func appendHostMetricPoint(info HostInfo) {
|
||||
memoryPct := 0.0
|
||||
if info.RAM.TotalMB > 0 {
|
||||
memoryPct = clampPercent(float64(info.RAM.UsedMB) / float64(info.RAM.TotalMB) * 100)
|
||||
}
|
||||
diskUsagePct := 0.0
|
||||
if info.Disk.TotalGB > 0 {
|
||||
diskUsagePct = clampPercent(info.Disk.UsedGB / info.Disk.TotalGB * 100)
|
||||
}
|
||||
point := HostMetricPoint{
|
||||
TS: time.Now().UnixMilli(),
|
||||
CPU: clampPercent(info.CPU.Usage),
|
||||
Memory: memoryPct,
|
||||
NetworkRx: info.Network.RXBps,
|
||||
NetworkTx: info.Network.TXBps,
|
||||
Network: info.Network.RXBps + info.Network.TXBps,
|
||||
DiskRead: info.DiskIO.ReadBps,
|
||||
DiskWrite: info.DiskIO.WriteBps,
|
||||
DiskIO: info.DiskIO.ReadBps + info.DiskIO.WriteBps,
|
||||
DiskUsagePct: diskUsagePct,
|
||||
}
|
||||
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
|
||||
|
||||
hostMetricMu.Lock()
|
||||
defer hostMetricMu.Unlock()
|
||||
|
||||
keepFrom := 0
|
||||
for keepFrom < len(hostMetricHistory) && hostMetricHistory[keepFrom].TS < cutoff {
|
||||
keepFrom++
|
||||
}
|
||||
if keepFrom > 0 {
|
||||
copy(hostMetricHistory, hostMetricHistory[keepFrom:])
|
||||
hostMetricHistory = hostMetricHistory[:len(hostMetricHistory)-keepFrom]
|
||||
}
|
||||
hostMetricHistory = append(hostMetricHistory, point)
|
||||
}
|
||||
|
||||
func getHostMetricHistory() []HostMetricPoint {
|
||||
hostMetricMu.RLock()
|
||||
defer hostMetricMu.RUnlock()
|
||||
|
||||
result := make([]HostMetricPoint, len(hostMetricHistory))
|
||||
copy(result, hostMetricHistory)
|
||||
return result
|
||||
}
|
||||
|
||||
func clampPercent(value float64) float64 {
|
||||
if value < 0 || !isFiniteFloat(value) {
|
||||
return 0
|
||||
}
|
||||
if value > 100 {
|
||||
return 100
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isFiniteFloat(value float64) bool {
|
||||
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
}
|
||||
|
||||
func getMemoryInfo() MemoryInfo {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
@@ -391,20 +518,22 @@ func parseSizeGBf(s string) (float64, error) {
|
||||
return val, err
|
||||
}
|
||||
|
||||
func getHostRates() (NetworkInfo, DiskIOInfo) {
|
||||
func getHostRates(includeDetails bool) (NetworkInfo, DiskIOInfo) {
|
||||
rx, tx := readHostNetworkBytes()
|
||||
readBytes, writeBytes := readHostDiskBytes()
|
||||
now := unixNano()
|
||||
|
||||
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
||||
publicIPv4 := lxc.DetectPublicIPv4()
|
||||
network.PublicIPv4 = publicIPv4.Address
|
||||
network.PublicIPv4Interface = publicIPv4.Interface
|
||||
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
||||
if len(network.IPv6Prefixes) > 0 {
|
||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||
if includeDetails {
|
||||
publicIPv4 := detectDisplayPublicIPv4()
|
||||
network.PublicIPv4 = publicIPv4.Address
|
||||
network.PublicIPv4Interface = publicIPv4.Interface
|
||||
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
||||
if len(network.IPv6Prefixes) > 0 {
|
||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||
}
|
||||
}
|
||||
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
||||
|
||||
@@ -437,23 +566,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 +759,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 +769,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 +803,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 +1040,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 +1469,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 +1734,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 +1811,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 +1820,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 +1842,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 +1895,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 +1904,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 +1917,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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -40,6 +42,9 @@ type ImageInfo struct {
|
||||
|
||||
var imageDownloadsMu sync.Mutex
|
||||
var imageDownloads = map[string]*imageDownloadStatus{}
|
||||
var lxcImageCacheMu sync.Mutex
|
||||
var lxcImageDownloadMu sync.Mutex
|
||||
var lxcImageDownloadActive bool
|
||||
|
||||
type imageDownloadStatus struct {
|
||||
Downloading bool
|
||||
@@ -135,6 +140,22 @@ func isImageDownloadActive(id string) bool {
|
||||
return st != nil && st.Downloading
|
||||
}
|
||||
|
||||
func beginLXCImageDownload() bool {
|
||||
lxcImageDownloadMu.Lock()
|
||||
defer lxcImageDownloadMu.Unlock()
|
||||
if lxcImageDownloadActive {
|
||||
return false
|
||||
}
|
||||
lxcImageDownloadActive = true
|
||||
return true
|
||||
}
|
||||
|
||||
func endLXCImageDownload() {
|
||||
lxcImageDownloadMu.Lock()
|
||||
lxcImageDownloadActive = false
|
||||
lxcImageDownloadMu.Unlock()
|
||||
}
|
||||
|
||||
func lxcImageDownloadTempName(id string) string {
|
||||
return fmt.Sprintf("clicd-img-dl-%s", id)
|
||||
}
|
||||
@@ -227,9 +248,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 +278,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 := ""
|
||||
@@ -301,7 +327,6 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||
if tmpl == nil {
|
||||
image := kvm.FindImage(req.TemplateID)
|
||||
@@ -309,6 +334,14 @@ 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 _, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||
ensureImageEnabled(image.ID)
|
||||
clearImageDownload(image.ID)
|
||||
@@ -349,6 +382,29 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
return
|
||||
}
|
||||
if !beginLXCImageDownload() {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Another LXC image download is active"})
|
||||
return
|
||||
}
|
||||
lxcDownloadHandedOff := false
|
||||
defer func() {
|
||||
if !lxcDownloadHandedOff {
|
||||
endLXCImageDownload()
|
||||
}
|
||||
}()
|
||||
imagePool, err := config.SelectStoragePoolForContent(
|
||||
config.StorageContentImages,
|
||||
"",
|
||||
dirSizeBytes("/var/cache/lxc/download")+1024*1024*1024,
|
||||
)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ensureLXCImageCachePool(*imagePool); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Already downloaded? Just enable if needed.
|
||||
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
|
||||
@@ -365,6 +421,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
go func(tmpl lxc.Template) {
|
||||
defer endLXCImageDownload()
|
||||
// Download via lxc-create with a temp container, then destroy it.
|
||||
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||
@@ -376,7 +433,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
st.Stage = "lxc-create"
|
||||
})
|
||||
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
output, err := runLXCImageDownloadCommand(cmd, tmpl.ID)
|
||||
|
||||
// Clean up the temp container unconditionally.
|
||||
cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||
@@ -393,10 +450,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ensureImageEnabled(tmpl.ID)
|
||||
finishImageDownload(tmpl.ID, nil)
|
||||
}(*tmpl)
|
||||
lxcDownloadHandedOff = true
|
||||
|
||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||
}
|
||||
|
||||
type lxcImageDownloadCommandResult struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func runLXCImageDownloadCommand(cmd *exec.Cmd, templateID string) ([]byte, error) {
|
||||
startedAt := time.Now()
|
||||
done := make(chan lxcImageDownloadCommandResult, 1)
|
||||
go func() {
|
||||
output, err := cmd.CombinedOutput()
|
||||
done <- lxcImageDownloadCommandResult{output: output, err: err}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
var lastBytes int64
|
||||
for {
|
||||
select {
|
||||
case result := <-done:
|
||||
return result.output, result.err
|
||||
case <-ticker.C:
|
||||
downloadedBytes := newestLXCRootfsDownloadSize(startedAt)
|
||||
if downloadedBytes <= 0 || downloadedBytes == lastBytes {
|
||||
continue
|
||||
}
|
||||
lastBytes = downloadedBytes
|
||||
updateImageDownload(templateID, func(st *imageDownloadStatus) {
|
||||
st.Stage = "downloading"
|
||||
st.DownloadedBytes = downloadedBytes
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newestLXCRootfsDownloadSize(startedAt time.Time) int64 {
|
||||
matches, _ := filepath.Glob("/tmp/tmp.*/rootfs.tar.xz")
|
||||
var newestTime time.Time
|
||||
var newestSize int64
|
||||
for _, match := range matches {
|
||||
info, err := os.Stat(match)
|
||||
if err != nil || info.IsDir() || info.ModTime().Before(startedAt.Add(-5*time.Second)) {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().After(newestTime) {
|
||||
newestTime = info.ModTime()
|
||||
newestSize = info.Size()
|
||||
}
|
||||
}
|
||||
return newestSize
|
||||
}
|
||||
|
||||
func ensureLXCImageCachePool(pool config.StoragePool) error {
|
||||
lxcImageCacheMu.Lock()
|
||||
defer lxcImageCacheMu.Unlock()
|
||||
|
||||
cachePath := "/var/cache/lxc/download"
|
||||
targetPath := filepath.Join(pool.Path, "images", "lxc")
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(targetAbs, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create LXC image storage: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(cachePath)
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(targetAbs, cachePath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourcePath := cachePath
|
||||
linked := info.Mode()&os.ModeSymlink != 0
|
||||
if linked {
|
||||
sourcePath, err = filepath.EvalSymlinks(cachePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve LXC image cache: %v", err)
|
||||
}
|
||||
}
|
||||
sourceAbs, err := filepath.Abs(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sourceAbs == targetAbs {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(targetAbs, sourceAbs+string(os.PathSeparator)) || strings.HasPrefix(sourceAbs, targetAbs+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("LXC image cache source and target must not be nested")
|
||||
}
|
||||
if !info.IsDir() && !linked {
|
||||
return fmt.Errorf("LXC image cache is not a directory: %s", cachePath)
|
||||
}
|
||||
|
||||
if output, err := exec.Command("cp", "-a", sourceAbs+string(os.PathSeparator)+".", targetAbs+string(os.PathSeparator)).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to migrate LXC image cache: %v, output: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
tempLink := fmt.Sprintf("%s.clicd-new-%d", cachePath, time.Now().UnixNano())
|
||||
if err := os.Symlink(targetAbs, tempLink); err != nil {
|
||||
return err
|
||||
}
|
||||
if linked {
|
||||
if err := os.Rename(tempLink, cachePath); err != nil {
|
||||
_ = os.Remove(tempLink)
|
||||
return fmt.Errorf("failed to switch LXC image cache: %v", err)
|
||||
}
|
||||
if isManagedLXCImageCachePath(sourceAbs) {
|
||||
_ = os.RemoveAll(sourceAbs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
backupPath := fmt.Sprintf("%s.clicd-backup-%d", cachePath, time.Now().UnixNano())
|
||||
if err := os.Rename(cachePath, backupPath); err != nil {
|
||||
_ = os.Remove(tempLink)
|
||||
return fmt.Errorf("failed to prepare LXC image cache migration: %v", err)
|
||||
}
|
||||
if err := os.Rename(tempLink, cachePath); err != nil {
|
||||
_ = os.Rename(backupPath, cachePath)
|
||||
_ = os.Remove(tempLink)
|
||||
return fmt.Errorf("failed to activate LXC image storage: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(backupPath); err != nil {
|
||||
return fmt.Errorf("LXC image cache migrated but old cache cleanup failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isManagedLXCImageCachePath(path string) bool {
|
||||
path = filepath.Clean(path)
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
|
||||
if path == filepath.Clean(filepath.Join(pool.Path, "images", "lxc")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return path == filepath.Clean("/var/lib/clicd/images/lxc")
|
||||
}
|
||||
|
||||
// HandleImageCancel cancels an in-progress image download.
|
||||
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -531,11 +732,36 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||
enabledSet := getEnabledImageSet()
|
||||
var subUser *config.SubUser
|
||||
var targetContainer *config.Container
|
||||
currentImageIDs := map[string]bool{}
|
||||
if isSubUserRequest(r) {
|
||||
subUser = subUserFromRequest(r)
|
||||
if identifier := r.URL.Query().Get("container"); identifier != "" {
|
||||
targetContainer = containerByIdentifier(identifier)
|
||||
if targetContainer == nil || !isContainerAllowedForRequest(r, identifier) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||
return
|
||||
}
|
||||
currentImageIDs[targetContainer.Template] = true
|
||||
} else {
|
||||
for _, id := range subUserCurrentImageIDs(subUser) {
|
||||
currentImageIDs[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
|
||||
continue
|
||||
}
|
||||
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
|
||||
@@ -544,7 +770,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
for _, t := range lxc.GetTemplates() {
|
||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
||||
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
|
||||
continue
|
||||
}
|
||||
if downloaded := isImageDownloaded(t.Distro, t.Release, t.Arch); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
|
||||
result = append(result, map[string]string{
|
||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||
@@ -560,9 +789,48 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
|
||||
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
|
||||
}
|
||||
|
||||
func imageTemplateExists(templateID string) bool {
|
||||
return lxc.FindTemplate(templateID) != nil || kvm.FindImage(templateID) != nil
|
||||
}
|
||||
|
||||
func isImageDownloadedForRuntime(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
|
||||
}
|
||||
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
|
||||
return downloaded
|
||||
}
|
||||
tmpl := lxc.FindTemplate(templateID)
|
||||
if tmpl == nil {
|
||||
return false
|
||||
}
|
||||
return isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||
}
|
||||
|
||||
func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool {
|
||||
if isSubUserRequest(r) {
|
||||
if !isTemplateAllowedForRequest(r, c, templateID) {
|
||||
return false
|
||||
}
|
||||
if c != nil && c.Template == templateID {
|
||||
return isImageDownloadedForRuntime(templateID, runtime)
|
||||
}
|
||||
}
|
||||
return isImageEnabledAndDownloaded(templateID, runtime)
|
||||
}
|
||||
|
||||
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 +847,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.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -22,3 +25,54 @@ func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
|
||||
}
|
||||
|
||||
type ipAssignmentRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Auto *bool `json:"auto,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Addresses []string `json:"addresses,omitempty"`
|
||||
}
|
||||
|
||||
func (req ipAssignmentRequest) allocation() ([]string, int, bool) {
|
||||
auto := req.Mode == "random" || req.Mode == "auto"
|
||||
if req.Mode == "custom" {
|
||||
auto = false
|
||||
}
|
||||
if req.Mode == "clear" || req.Mode == "none" {
|
||||
return nil, 0, false
|
||||
}
|
||||
if req.Auto != nil {
|
||||
auto = *req.Auto
|
||||
}
|
||||
return req.Addresses, req.Count, auto
|
||||
}
|
||||
|
||||
func updatePublicIPv4(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req ipAssignmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
addresses, count, auto := req.allocation()
|
||||
c, err := updatePublicIPv4ByRuntime(id, addresses, count, auto)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Public IPv4 assignments updated", Data: c})
|
||||
}
|
||||
|
||||
func updateIPv6Addresses(w http.ResponseWriter, r *http.Request, id int) {
|
||||
var req ipAssignmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
addresses, count, auto := req.allocation()
|
||||
c, err := updateIPv6ByRuntime(id, addresses, count, auto)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assignments updated", Data: c})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/kvm"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
// CaptureRuntimeRestoreState records which managed workloads are actually
|
||||
// running before the CLICD service exits. On the next host boot, only those
|
||||
// workloads are started again.
|
||||
func CaptureRuntimeRestoreState() {
|
||||
if config.AppConfig == nil {
|
||||
return
|
||||
}
|
||||
lxcManager := lxc.NewManager()
|
||||
kvmManager := kvm.NewManager()
|
||||
changed := false
|
||||
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
status, err := runtimeStatus(*c, lxcManager, kvmManager)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to capture runtime state for %s: %v\n", c.Name, err)
|
||||
continue
|
||||
}
|
||||
restore := status == "running"
|
||||
if c.RestoreOnHostBoot != restore {
|
||||
c.RestoreOnHostBoot = restore
|
||||
changed = true
|
||||
}
|
||||
if status != "" && c.Status != status {
|
||||
c.Status = status
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
fmt.Printf("Warning: failed to save host boot restore state: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StartHostBootRestore() {
|
||||
go RestoreHostBootState()
|
||||
}
|
||||
|
||||
func RestoreHostBootState() {
|
||||
if config.AppConfig == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
lxcManager := lxc.NewManager()
|
||||
kvmManager := kvm.NewManager()
|
||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||
|
||||
for _, c := range containers {
|
||||
if !c.RestoreOnHostBoot {
|
||||
continue
|
||||
}
|
||||
if c.PolicyBlocked {
|
||||
fmt.Printf("Skipping host boot restore for %s: policy blocked\n", c.Name)
|
||||
continue
|
||||
}
|
||||
if lxc.IsExpired(c) {
|
||||
fmt.Printf("Skipping host boot restore for %s: expired at %s\n", c.Name, c.ExpiresAt)
|
||||
continue
|
||||
}
|
||||
|
||||
status, err := runtimeStatus(c, lxcManager, kvmManager)
|
||||
if err == nil && status == "running" {
|
||||
config.UpdateContainerStatusAndRestore(c.ID, "running", true)
|
||||
if !c.IsKVM() {
|
||||
_ = lxcManager.ApplyPortMappings(c.ID)
|
||||
} else {
|
||||
_ = lxc.NewManager().ApplyPortMappings(c.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Restoring workload after host boot: %s (ID=%d)\n", c.Name, c.ID)
|
||||
if c.IsKVM() {
|
||||
if err := kvmManager.StartContainer(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to restore KVM %s: %v\n", c.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := lxcManager.StartContainer(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to restore LXC %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
lxc.EnsureAllRunningPortMappings()
|
||||
}
|
||||
|
||||
func runtimeStatus(c config.Container, lxcManager *lxc.Manager, kvmManager *kvm.Manager) (string, error) {
|
||||
if c.IsKVM() {
|
||||
return kvmManager.GetContainerStatus(c.VirshName())
|
||||
}
|
||||
return lxcManager.GetContainerStatus(c.LxcName())
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -41,6 +46,19 @@ type ipv4Route struct {
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
}
|
||||
|
||||
type lanDHCPRoute struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
LXCName string `json:"lxc_name"`
|
||||
Status string `json:"status"`
|
||||
Address string `json:"address"`
|
||||
Interface string `json:"interface"`
|
||||
PrefixLen int `json:"prefix_len,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type ipv6Route struct {
|
||||
ContainerID int `json:"container_id"`
|
||||
ContainerName string `json:"container_name"`
|
||||
@@ -53,20 +71,24 @@ type ipv6Route struct {
|
||||
|
||||
type routingResponse struct {
|
||||
NAT4 routeCapacity `json:"nat4"`
|
||||
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||
IPv4 routeCapacity `json:"ipv4"`
|
||||
LANDHCP routeCapacity `json:"lan_dhcp"`
|
||||
IPv6 routeCapacity `json:"ipv6"`
|
||||
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
|
||||
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
||||
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
|
||||
LANDHCPAssignments []lanDHCPRoute `json:"lan_dhcp_assignments"`
|
||||
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
type routingPoolsRequest struct {
|
||||
Addresses *[]string `json:"addresses"`
|
||||
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||
Addresses *[]string `json:"addresses"`
|
||||
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||
NAT4PortRange *nat4PortRange `json:"nat4_port_range"`
|
||||
}
|
||||
|
||||
type publicIPv4ScanRequest struct {
|
||||
@@ -118,15 +140,15 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
nat4Mappings := make([]nat4Route, 0)
|
||||
usedPorts := map[int]bool{}
|
||||
ipv4Assignments := make([]ipv4Route, 0)
|
||||
lanDHCPAssignments := make([]lanDHCPRoute, 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{
|
||||
@@ -158,6 +180,20 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
c.NormalizeNetworkAssignments()
|
||||
if c.UsesLANIPv4() {
|
||||
lanDHCPAssignments = append(lanDHCPAssignments, lanDHCPRoute{
|
||||
ContainerID: c.ID,
|
||||
ContainerName: c.Name,
|
||||
LXCName: c.LxcName(),
|
||||
Status: c.Status,
|
||||
Address: c.IP,
|
||||
Interface: c.LANInterface,
|
||||
PrefixLen: c.LANIPv4PrefixLen,
|
||||
Gateway: c.LANIPv4Gateway,
|
||||
MACAddress: c.MACAddress,
|
||||
Mode: c.LANIPv4Mode,
|
||||
})
|
||||
}
|
||||
for _, ip := range c.IPv6Addresses {
|
||||
if ip.Address == "" {
|
||||
continue
|
||||
@@ -185,11 +221,17 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
|
||||
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
|
||||
})
|
||||
sort.SliceStable(lanDHCPAssignments, func(i, j int) bool {
|
||||
if lanDHCPAssignments[i].Interface == lanDHCPAssignments[j].Interface {
|
||||
return lanDHCPAssignments[i].ContainerName < lanDHCPAssignments[j].ContainerName
|
||||
}
|
||||
return lanDHCPAssignments[i].Interface < lanDHCPAssignments[j].Interface
|
||||
})
|
||||
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||
})
|
||||
|
||||
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
|
||||
totalNAT4Ports := config.NATPortCapacity()
|
||||
nat4Used := len(usedPorts)
|
||||
nat4Remaining := totalNAT4Ports - nat4Used
|
||||
if nat4Remaining < 0 {
|
||||
@@ -216,11 +258,20 @@ 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),
|
||||
Total: strconv.Itoa(ipv4Total),
|
||||
},
|
||||
LANDHCP: routeCapacity{
|
||||
Used: len(lanDHCPAssignments),
|
||||
Remaining: "DHCP",
|
||||
Total: "DHCP",
|
||||
},
|
||||
IPv6: routeCapacity{
|
||||
Used: len(ipv6Assignments),
|
||||
Remaining: ipv6Remaining,
|
||||
@@ -229,6 +280,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
||||
HostPublicIPv4: hostPublicIPv4,
|
||||
PublicIPv4Addresses: publicIPv4s,
|
||||
IPv4Assignments: ipv4Assignments,
|
||||
LANDHCPAssignments: lanDHCPAssignments,
|
||||
NAT4Mappings: nat4Mappings,
|
||||
IPv6Assignments: ipv6Assignments,
|
||||
IPv6Prefixes: prefixes,
|
||||
@@ -246,6 +298,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 {
|
||||
|
||||
@@ -20,7 +20,7 @@ func runtimeFromRequest(value string) string {
|
||||
}
|
||||
|
||||
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
|
||||
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
|
||||
return cfg.WantsNAT() || cfg.WantsLANIPv4() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
|
||||
}
|
||||
|
||||
func runtimeFromTemplateID(templateID string) string {
|
||||
@@ -32,6 +32,7 @@ func runtimeFromTemplateID(templateID string) string {
|
||||
|
||||
func createByRuntime(cfg lxc.ContainerConfig) error {
|
||||
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||
cfg.NormalizeResourceAliases()
|
||||
if cfg.Virtualization == config.VirtualizationKVM {
|
||||
return kvmManager.CreateContainer(cfg)
|
||||
}
|
||||
@@ -114,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
||||
return lxcManager.AssignIPv6(id)
|
||||
}
|
||||
|
||||
func updatePublicIPv4ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
|
||||
}
|
||||
return lxcManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
|
||||
}
|
||||
|
||||
func updateIPv6ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.UpdateIPv6Assignments(id, requested, count, auto)
|
||||
}
|
||||
return lxcManager.UpdateIPv6Assignments(id, requested, count, auto)
|
||||
}
|
||||
|
||||
func usageByRuntime(id int) (map[string]interface{}, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
@@ -130,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} {
|
||||
return lxcManager.GetTrafficInfo(id)
|
||||
}
|
||||
|
||||
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
|
||||
}
|
||||
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
|
||||
}
|
||||
|
||||
func deleteSnapshotByRuntime(snapshotID string) error {
|
||||
|
||||
@@ -49,15 +49,19 @@ type connEntry struct {
|
||||
}
|
||||
|
||||
type trafficStats struct {
|
||||
total int
|
||||
totalSynSent int
|
||||
destCounts map[string]int
|
||||
destPorts map[string]map[int]int
|
||||
portDestCounts map[int]map[string]int
|
||||
portTotalCounts map[int]int
|
||||
udpDestCounts map[int]map[string]int
|
||||
udpTotalCounts map[int]int
|
||||
synSentByDst map[string]int
|
||||
total int
|
||||
totalSynSent int
|
||||
destCounts map[string]int
|
||||
destPorts map[string]map[int]int
|
||||
portDestCounts map[int]map[string]int
|
||||
portTotalCounts map[int]int
|
||||
udpDestCounts map[int]map[string]int
|
||||
udpTotalCounts map[int]int
|
||||
udpDestTotalCounts map[string]int
|
||||
synSentByDst map[string]int
|
||||
tcpSynDestPorts map[string]map[int]int
|
||||
tcpSynPortDestCounts map[int]map[string]int
|
||||
tcpSynPortTotalCounts map[int]int
|
||||
}
|
||||
|
||||
var scanner *SecurityScanner
|
||||
@@ -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"})
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -48,6 +49,38 @@ func HandleLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTaskQueueSettings returns or updates the global task concurrency limit.
|
||||
func HandleTaskQueueSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: globalQueue.Settings()})
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var req struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if req.Concurrency < 1 || req.Concurrency > config.MaxTaskConcurrency {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "任务并发数必须在 1 到 16 之间"})
|
||||
return
|
||||
}
|
||||
previous := config.AppConfig.TaskConcurrency
|
||||
config.AppConfig.TaskConcurrency = req.Concurrency
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
config.AppConfig.TaskConcurrency = previous
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存任务队列设置失败"})
|
||||
return
|
||||
}
|
||||
globalQueue.SetConcurrency(req.Concurrency)
|
||||
auditRequest(r, "settings.task_queue", "task_concurrency", fmt.Sprintf("concurrency=%d", req.Concurrency), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "任务队列设置已保存", Data: globalQueue.Settings()})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||
config.AddLoginLog(username, ip, userAgent, success)
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID
|
||||
|
||||
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||
user := requestUser(r)
|
||||
var req struct {
|
||||
StoragePoolID string `json:"storage_pool_id"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
}
|
||||
req.StoragePoolID = strings.TrimSpace(req.StoragePoolID)
|
||||
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, req.StoragePoolID, 0); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if isSubUserRequest(r) {
|
||||
c := config.FindContainer(containerID)
|
||||
limit := config.ContainerSnapshotLimit(c)
|
||||
@@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
@@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
|
||||
if req.Time == "" {
|
||||
req.Time = "03:00"
|
||||
}
|
||||
if req.Enabled {
|
||||
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, "", 0); err != nil {
|
||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
user := requestUser(r)
|
||||
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type storageInfoResponse struct {
|
||||
Pools []storagePoolInfo `json:"pools"`
|
||||
Disks []storageDiskInfo `json:"disks"`
|
||||
ContentTypes []string `json:"content_types"`
|
||||
}
|
||||
|
||||
type storagePoolInfo struct {
|
||||
config.StoragePool
|
||||
Available bool `json:"available"`
|
||||
Exists bool `json:"exists"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
|
||||
ContentUsage []storageContentUsage `json:"content_usage"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type storageContentUsage struct {
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type storageDiskInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
FSType string `json:"fstype"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
Model string `json:"model"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
StoragePath string `json:"storage_path,omitempty"`
|
||||
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
|
||||
ContentUsage []storageContentUsage `json:"content_usage"`
|
||||
}
|
||||
|
||||
func HandleStorage(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
|
||||
case http.MethodPut:
|
||||
var req struct {
|
||||
Pools []config.StoragePool `json:"pools"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
pools, err := normalizeStoragePoolsRequest(req.Pools)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
for _, pool := range pools {
|
||||
if err := os.MkdirAll(pool.Path, 0755); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: fmt.Sprintf("Failed to create %s: %v", pool.Path, err)})
|
||||
return
|
||||
}
|
||||
}
|
||||
config.AppConfig.StoragePools = pools
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save storage pools"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
func buildStorageInfo() storageInfoResponse {
|
||||
disks := detectStorageDisks()
|
||||
pools := make([]storagePoolInfo, 0, len(config.AppConfig.StoragePools))
|
||||
for _, pool := range config.AppConfig.StoragePools {
|
||||
info := storagePoolInfo{StoragePool: pool}
|
||||
if filepath.Clean(pool.MountPoint) == string(os.PathSeparator) {
|
||||
_ = os.MkdirAll(pool.Path, 0755)
|
||||
}
|
||||
if st, err := os.Stat(pool.Path); err == nil && st.IsDir() {
|
||||
info.Exists = true
|
||||
} else if err != nil {
|
||||
info.Error = err.Error()
|
||||
}
|
||||
detectedMountPoint := bestMountPointForPath(pool.Path, disks)
|
||||
if info.MountPoint == "" {
|
||||
info.MountPoint = detectedMountPoint
|
||||
}
|
||||
if detectedMountPoint != "" && filepath.Clean(info.MountPoint) == filepath.Clean(detectedMountPoint) {
|
||||
info.Available = info.Exists
|
||||
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(pool.Path)
|
||||
info.ContentUsage, info.ClicdUsedBytes = contentUsageForPool(pool.Path)
|
||||
} else if info.Error == "" {
|
||||
info.Error = "storage disk is not mounted"
|
||||
}
|
||||
pools = append(pools, info)
|
||||
}
|
||||
for i := range disks {
|
||||
for _, pool := range pools {
|
||||
if pool.MountPoint != disks[i].MountPoint {
|
||||
continue
|
||||
}
|
||||
disks[i].ClicdUsedBytes += pool.ClicdUsedBytes
|
||||
disks[i].ContentUsage = mergeContentUsage(disks[i].ContentUsage, pool.ContentUsage)
|
||||
if disks[i].StoragePoolID == "" {
|
||||
disks[i].StoragePoolID = pool.ID
|
||||
disks[i].StoragePath = pool.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
return storageInfoResponse{
|
||||
Pools: pools,
|
||||
Disks: disks,
|
||||
ContentTypes: []string{
|
||||
config.StorageContentLXC,
|
||||
config.StorageContentKVM,
|
||||
config.StorageContentImages,
|
||||
config.StorageContentSnapshots,
|
||||
config.StorageContentBackups,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStoragePoolsRequest(items []config.StoragePool) ([]config.StoragePool, error) {
|
||||
return normalizeStoragePoolsRequestWithDisks(items, detectStorageDisks())
|
||||
}
|
||||
|
||||
func normalizeStoragePoolsRequestWithDisks(items []config.StoragePool, disks []storageDiskInfo) ([]config.StoragePool, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("at least one mounted storage disk configuration must be retained")
|
||||
}
|
||||
result := make([]config.StoragePool, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
defaultSeen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
disk, managedPath, err := storageDiskForPoolRequest(item, disks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, name := storagePoolIdentity(disk)
|
||||
if seen[id] {
|
||||
return nil, fmt.Errorf("duplicate storage disk: %s", disk.MountPoint)
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
contentTypes := normalizeStorageContentTypes(item.ContentTypes)
|
||||
defaultContents := normalizeStorageContentTypes(item.DefaultContents)
|
||||
allowed := map[string]bool{}
|
||||
for _, content := range contentTypes {
|
||||
allowed[content] = true
|
||||
}
|
||||
defaults := make([]string, 0, len(defaultContents))
|
||||
for _, content := range defaultContents {
|
||||
if !allowed[content] {
|
||||
continue
|
||||
}
|
||||
if defaultSeen[content] {
|
||||
return nil, fmt.Errorf("only one default storage disk is allowed for %s", content)
|
||||
}
|
||||
defaultSeen[content] = true
|
||||
defaults = append(defaults, content)
|
||||
}
|
||||
result = append(result, config.StoragePool{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Path: managedPath,
|
||||
MountPoint: disk.MountPoint,
|
||||
ContentTypes: contentTypes,
|
||||
DefaultContents: defaults,
|
||||
Enabled: item.Enabled,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func storageDiskForPoolRequest(item config.StoragePool, disks []storageDiskInfo) (storageDiskInfo, string, error) {
|
||||
requestedMount := filepath.Clean(strings.TrimSpace(item.MountPoint))
|
||||
if requestedMount == "." {
|
||||
requestedMount = ""
|
||||
}
|
||||
requestedPath := filepath.Clean(strings.TrimSpace(item.Path))
|
||||
if requestedPath == "." {
|
||||
requestedPath = ""
|
||||
}
|
||||
for _, disk := range disks {
|
||||
mountPoint := filepath.Clean(disk.MountPoint)
|
||||
managedPath := managedStoragePath(mountPoint)
|
||||
mountMatches := requestedMount != "" && requestedMount == mountPoint
|
||||
pathMatches := requestedPath != "" && requestedPath == managedPath
|
||||
if !mountMatches && !pathMatches {
|
||||
continue
|
||||
}
|
||||
if requestedMount != "" && !mountMatches {
|
||||
return storageDiskInfo{}, "", fmt.Errorf("storage disk mount point has changed; refresh and try again")
|
||||
}
|
||||
if requestedPath != "" && !pathMatches {
|
||||
return storageDiskInfo{}, "", fmt.Errorf("custom storage paths are not allowed; refresh and try again")
|
||||
}
|
||||
return disk, managedPath, nil
|
||||
}
|
||||
return storageDiskInfo{}, "", fmt.Errorf("storage disk is not mounted or is no longer available")
|
||||
}
|
||||
|
||||
func storagePoolIdentity(disk storageDiskInfo) (string, string) {
|
||||
mountPoint := filepath.Clean(disk.MountPoint)
|
||||
if mountPoint == string(os.PathSeparator) {
|
||||
return "disk-root", "system (/)"
|
||||
}
|
||||
baseName := filepath.Base(mountPoint)
|
||||
if baseName == "" || baseName == "." || baseName == string(os.PathSeparator) {
|
||||
baseName = strings.TrimSpace(disk.Name)
|
||||
}
|
||||
if baseName == "" {
|
||||
baseName = "storage"
|
||||
}
|
||||
devicePath := strings.TrimSpace(disk.Path)
|
||||
if devicePath == "" {
|
||||
devicePath = strings.TrimSpace(disk.Name)
|
||||
}
|
||||
return "disk-" + storageID(baseName), fmt.Sprintf("%s (%s)", baseName, devicePath)
|
||||
}
|
||||
|
||||
func managedStoragePath(mountPoint string) string {
|
||||
if filepath.Clean(mountPoint) == string(os.PathSeparator) {
|
||||
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
|
||||
}
|
||||
return filepath.Join(filepath.Clean(mountPoint), "clicd")
|
||||
}
|
||||
|
||||
func normalizeStorageContentTypes(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, value := range values {
|
||||
var next string
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case config.StorageContentLXC:
|
||||
next = config.StorageContentLXC
|
||||
case config.StorageContentKVM:
|
||||
next = config.StorageContentKVM
|
||||
case config.StorageContentImages:
|
||||
next = config.StorageContentImages
|
||||
case config.StorageContentSnapshots:
|
||||
next = config.StorageContentSnapshots
|
||||
case config.StorageContentBackups:
|
||||
next = config.StorageContentBackups
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if seen[next] {
|
||||
continue
|
||||
}
|
||||
seen[next] = true
|
||||
result = append(result, next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func storageID(name string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(name))
|
||||
id = strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-").Replace(id)
|
||||
id = strings.Trim(id, "-")
|
||||
if id == "" {
|
||||
return "storage"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func detectStorageDisks() []storageDiskInfo {
|
||||
type lsblkDevice struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
FSType string `json:"fstype"`
|
||||
MountPoint string `json:"mountpoint"`
|
||||
Model string `json:"model"`
|
||||
Size int64 `json:"size"`
|
||||
ReadOnly bool `json:"ro"`
|
||||
Children []lsblkDevice `json:"children"`
|
||||
}
|
||||
var payload struct {
|
||||
BlockDevices []lsblkDevice `json:"blockdevices"`
|
||||
}
|
||||
out, err := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RO").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(out, &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := []storageDiskInfo{}
|
||||
var walk func(lsblkDevice)
|
||||
walk = func(dev lsblkDevice) {
|
||||
info := storageDiskInfo{
|
||||
Name: dev.Name,
|
||||
Path: dev.Path,
|
||||
Type: dev.Type,
|
||||
FSType: dev.FSType,
|
||||
MountPoint: dev.MountPoint,
|
||||
Model: strings.TrimSpace(dev.Model),
|
||||
SizeBytes: dev.Size,
|
||||
}
|
||||
if isUsableStorageMount(dev.Type, dev.FSType, dev.Path, dev.MountPoint, dev.ReadOnly) && !mountIsReadOnly(dev.MountPoint) {
|
||||
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(dev.MountPoint)
|
||||
result = append(result, info)
|
||||
}
|
||||
for _, child := range dev.Children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
for _, dev := range payload.BlockDevices {
|
||||
walk(dev)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isUsableStorageMount(deviceType, fsType, devicePath, mountPoint string, readOnly bool) bool {
|
||||
if readOnly || strings.TrimSpace(mountPoint) == "" || !strings.HasPrefix(mountPoint, "/") {
|
||||
return false
|
||||
}
|
||||
|
||||
deviceType = strings.ToLower(strings.TrimSpace(deviceType))
|
||||
devicePath = strings.ToLower(strings.TrimSpace(devicePath))
|
||||
if deviceType == "loop" || deviceType == "rom" || deviceType == "zram" || strings.HasPrefix(devicePath, "/dev/loop") {
|
||||
return false
|
||||
}
|
||||
|
||||
fsType = strings.ToLower(strings.TrimSpace(fsType))
|
||||
unsupportedFileSystems := map[string]bool{
|
||||
"": true,
|
||||
"squashfs": true,
|
||||
"iso9660": true,
|
||||
"udf": true,
|
||||
"swap": true,
|
||||
"tmpfs": true,
|
||||
"devtmpfs": true,
|
||||
"overlay": true,
|
||||
"proc": true,
|
||||
"sysfs": true,
|
||||
"cgroup": true,
|
||||
"cgroup2": true,
|
||||
"efivarfs": true,
|
||||
"securityfs": true,
|
||||
}
|
||||
if unsupportedFileSystems[fsType] {
|
||||
return false
|
||||
}
|
||||
|
||||
mountPoint = pathpkg.Clean(mountPoint)
|
||||
for _, reserved := range []string{"/snap", "/boot"} {
|
||||
if mountPoint == reserved || strings.HasPrefix(mountPoint, reserved+"/") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mountIsReadOnly(mountPoint string) bool {
|
||||
out, err := exec.Command("findmnt", "-n", "-o", "OPTIONS", "--target", mountPoint).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, option := range strings.Split(strings.TrimSpace(string(out)), ",") {
|
||||
if strings.TrimSpace(option) == "ro" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contentUsageForPool(poolPath string) ([]storageContentUsage, int64) {
|
||||
mapping := map[string]string{
|
||||
config.StorageContentLXC: "lxc",
|
||||
config.StorageContentKVM: "kvm",
|
||||
config.StorageContentImages: "images",
|
||||
config.StorageContentSnapshots: "snapshots",
|
||||
config.StorageContentBackups: "backups",
|
||||
}
|
||||
result := make([]storageContentUsage, 0, len(mapping))
|
||||
var total int64
|
||||
for _, content := range []string{
|
||||
config.StorageContentLXC,
|
||||
config.StorageContentKVM,
|
||||
config.StorageContentImages,
|
||||
config.StorageContentSnapshots,
|
||||
config.StorageContentBackups,
|
||||
} {
|
||||
size := dirSizeBytes(filepath.Join(poolPath, mapping[content]))
|
||||
result = append(result, storageContentUsage{ContentType: content, SizeBytes: size})
|
||||
total += size
|
||||
}
|
||||
return result, total
|
||||
}
|
||||
|
||||
func mergeContentUsage(current []storageContentUsage, next []storageContentUsage) []storageContentUsage {
|
||||
sizes := map[string]int64{}
|
||||
order := []string{}
|
||||
for _, item := range append(current, next...) {
|
||||
if _, ok := sizes[item.ContentType]; !ok {
|
||||
order = append(order, item.ContentType)
|
||||
}
|
||||
sizes[item.ContentType] += item.SizeBytes
|
||||
}
|
||||
result := make([]storageContentUsage, 0, len(order))
|
||||
for _, content := range order {
|
||||
result = append(result, storageContentUsage{ContentType: content, SizeBytes: sizes[content]})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dirSizeBytes(path string) int64 {
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = resolved
|
||||
}
|
||||
// Count allocated blocks on this filesystem only. LXC rootfs directories can
|
||||
// contain active mounts such as proc/sys; traversing them is slow and reports
|
||||
// enormous virtual sizes that are not actually occupied by CLICD data.
|
||||
out, err := exec.Command("du", "-skx", path).Output()
|
||||
if err == nil {
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) > 0 {
|
||||
var sizeKB int64
|
||||
if _, scanErr := fmt.Sscanf(fields[0], "%d", &sizeKB); scanErr == nil && sizeKB <= (1<<63-1)/1024 {
|
||||
return sizeKB * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
var size int64
|
||||
_ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, statErr := d.Info(); statErr == nil {
|
||||
size += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return size
|
||||
}
|
||||
|
||||
func dfPath(path string) (size int64, used int64, free int64) {
|
||||
out, err := exec.Command("df", "-B1", "-P", path).Output()
|
||||
if err != nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) < 2 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
fields := strings.Fields(lines[len(lines)-1])
|
||||
if len(fields) < 6 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
fmt.Sscanf(fields[1], "%d", &size)
|
||||
fmt.Sscanf(fields[2], "%d", &used)
|
||||
fmt.Sscanf(fields[3], "%d", &free)
|
||||
return size, used, free
|
||||
}
|
||||
|
||||
func bestMountPointForPath(path string, disks []storageDiskInfo) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
path = pathpkg.Clean(path)
|
||||
best := ""
|
||||
for _, disk := range disks {
|
||||
mp := pathpkg.Clean(strings.ReplaceAll(disk.MountPoint, "\\", "/"))
|
||||
if disk.MountPoint == "" || mp == "." {
|
||||
continue
|
||||
}
|
||||
matches := path == mp
|
||||
if mp == "/" {
|
||||
matches = pathpkg.IsAbs(path)
|
||||
} else if strings.HasPrefix(path, mp+"/") {
|
||||
matches = true
|
||||
}
|
||||
if matches {
|
||||
if len(mp) > len(best) {
|
||||
best = mp
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestIsUsableStorageMount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deviceType string
|
||||
fsType string
|
||||
devicePath string
|
||||
mountPoint string
|
||||
readOnly bool
|
||||
wantUsable bool
|
||||
}{
|
||||
{name: "root partition", deviceType: "part", fsType: "ext4", devicePath: "/dev/sda2", mountPoint: "/", wantUsable: true},
|
||||
{name: "mounted data disk", deviceType: "disk", fsType: "xfs", devicePath: "/dev/sdb", mountPoint: "/data", wantUsable: true},
|
||||
{name: "snap loop", deviceType: "loop", fsType: "squashfs", devicePath: "/dev/loop0", mountPoint: "/snap/core20/2105", readOnly: true},
|
||||
{name: "loop without ro flag", deviceType: "loop", fsType: "ext4", devicePath: "/dev/loop7", mountPoint: "/mnt/loop"},
|
||||
{name: "read only disk", deviceType: "part", fsType: "ext4", devicePath: "/dev/sdc1", mountPoint: "/archive", readOnly: true},
|
||||
{name: "optical image", deviceType: "rom", fsType: "iso9660", devicePath: "/dev/sr0", mountPoint: "/media/cdrom"},
|
||||
{name: "efi partition", deviceType: "part", fsType: "vfat", devicePath: "/dev/sda1", mountPoint: "/boot/efi"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isUsableStorageMount(tt.deviceType, tt.fsType, tt.devicePath, tt.mountPoint, tt.readOnly)
|
||||
if got != tt.wantUsable {
|
||||
t.Fatalf("isUsableStorageMount() = %v, want %v", got, tt.wantUsable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestMountPointForPath(t *testing.T) {
|
||||
disks := []storageDiskInfo{
|
||||
{Path: "/dev/sda2", MountPoint: "/"},
|
||||
{Path: "/dev/sdb1", MountPoint: "/mnt/clicd-data"},
|
||||
}
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{path: "/var/lib/clicd", want: "/"},
|
||||
{path: "/mnt/clicd-data/clicd", want: "/mnt/clicd-data"},
|
||||
{path: "/mnt/clicd-data", want: "/mnt/clicd-data"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := bestMountPointForPath(tt.path, disks); got != tt.want {
|
||||
t.Fatalf("bestMountPointForPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoragePoolsUsesServerManagedPath(t *testing.T) {
|
||||
disks := []storageDiskInfo{
|
||||
{Path: "/dev/sda2", MountPoint: "/"},
|
||||
{Path: "/dev/sdb1", MountPoint: "/mnt/data"},
|
||||
}
|
||||
items := []config.StoragePool{{
|
||||
ID: "disk-data",
|
||||
Name: "data",
|
||||
Path: "/mnt/data/clicd",
|
||||
MountPoint: "/mnt/data",
|
||||
ContentTypes: []string{config.StorageContentLXC},
|
||||
DefaultContents: []string{config.StorageContentLXC},
|
||||
Enabled: true,
|
||||
}}
|
||||
pools, err := normalizeStoragePoolsRequestWithDisks(items, disks)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPath := filepath.Join(filepath.Clean("/mnt/data"), "clicd")
|
||||
if len(pools) != 1 || pools[0].ID != "disk-data" || pools[0].Name != "data (/dev/sdb1)" || pools[0].Path != wantPath || pools[0].MountPoint != "/mnt/data" {
|
||||
t.Fatalf("unexpected normalized pools: %#v", pools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStoragePoolsRejectsUncontrolledPath(t *testing.T) {
|
||||
disks := []storageDiskInfo{{Path: "/dev/sdb1", MountPoint: "/mnt/data"}}
|
||||
for _, path := range []string{"/etc", "/mnt/data/clicd/../../etc", "/mnt/data/other"} {
|
||||
_, err := normalizeStoragePoolsRequestWithDisks([]config.StoragePool{{
|
||||
ID: "disk-data",
|
||||
Name: "data",
|
||||
Path: path,
|
||||
MountPoint: "/mnt/data",
|
||||
Enabled: true,
|
||||
}}, disks)
|
||||
if err == nil {
|
||||
t.Fatalf("path %q was accepted", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirSizeBytesUsesAllocatedBlocks(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("allocated-block behavior is provided by the Linux du command")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file, err := os.Create(filepath.Join(dir, "sparse.img"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Truncate(1 << 30); err != nil {
|
||||
file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := dirSizeBytes(dir); got >= 128<<20 {
|
||||
t.Fatalf("dirSizeBytes() = %d, expected allocated size instead of 1 GiB apparent size", got)
|
||||
}
|
||||
}
|
||||
+232
-43
@@ -22,24 +22,30 @@ func generateRandomStr(length int) string {
|
||||
}
|
||||
|
||||
type subUserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
CurrentImageIDs []string `json:"current_image_ids,omitempty"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
||||
return subUserResponse{
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
Password: password,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AccessCode: su.AccessCode,
|
||||
CreatedAt: su.CreatedAt,
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
Password: password,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||
AccessCode: su.AccessCode,
|
||||
CreatedAt: su.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||
if !su.ImageLimitConfigured && len(su.AllowedImageIDs) == 0 {
|
||||
su.AllowedImageIDs = effectiveContainerAllowedImageIDs(c)
|
||||
su.ImageLimitConfigured = true
|
||||
}
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
@@ -114,14 +124,16 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
accessCode := generateRandomStr(8)
|
||||
|
||||
subUser := config.SubUser{
|
||||
ID: "sub-" + generateRandomStr(8),
|
||||
Username: username,
|
||||
Password: password,
|
||||
PassHash: string(hash),
|
||||
ContainerNames: []string{containerName},
|
||||
ContainerUUIDs: []string{c.UUID},
|
||||
AccessCode: accessCode,
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
ID: "sub-" + generateRandomStr(8),
|
||||
Username: username,
|
||||
Password: password,
|
||||
PassHash: string(hash),
|
||||
ContainerNames: []string{containerName},
|
||||
ContainerUUIDs: []string{c.UUID},
|
||||
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
|
||||
ImageLimitConfigured: true,
|
||||
AccessCode: accessCode,
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
||||
@@ -306,6 +318,155 @@ func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
||||
return subUserAllowedContainers(r)
|
||||
}
|
||||
|
||||
func subUserFromRequest(r *http.Request) *config.SubUser {
|
||||
username := ""
|
||||
if ctx, ok := authContextFromRequest(r); ok && ctx.Type == authTypeSubUser {
|
||||
username = ctx.Username
|
||||
}
|
||||
if username == "" {
|
||||
if claims, ok := claimsFromRequest(r); ok {
|
||||
username, _ = claims["sub_user"].(string)
|
||||
}
|
||||
}
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range config.AppConfig.SubUsers {
|
||||
if config.AppConfig.SubUsers[i].Username == username {
|
||||
return &config.AppConfig.SubUsers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAllowedImageIDs(ids []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
if !imageTemplateExists(id) {
|
||||
return nil, fmt.Errorf("unknown image template: %s", id)
|
||||
}
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isTemplateAllowedForRequest(r *http.Request, c *config.Container, templateID string) bool {
|
||||
if !isSubUserRequest(r) {
|
||||
return true
|
||||
}
|
||||
return isImageAllowedForSubUser(subUserFromRequest(r), c, templateID)
|
||||
}
|
||||
|
||||
func isImageAllowedForSubUser(su *config.SubUser, c *config.Container, templateID string) bool {
|
||||
if su == nil || strings.TrimSpace(templateID) == "" {
|
||||
return false
|
||||
}
|
||||
for _, id := range effectiveSubUserAllowedImageIDs(su) {
|
||||
if id == templateID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func effectiveContainerAllowedImageIDs(c *config.Container) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c.ImageLimitConfigured || len(c.AllowedImageIDs) > 0 {
|
||||
return cleanImageIDList(c.AllowedImageIDs)
|
||||
}
|
||||
if c.Template != "" {
|
||||
return []string{c.Template}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveSubUserAllowedImageIDs(su *config.SubUser) []string {
|
||||
if su == nil {
|
||||
return nil
|
||||
}
|
||||
if su.ImageLimitConfigured || len(su.AllowedImageIDs) > 0 {
|
||||
return cleanImageIDList(su.AllowedImageIDs)
|
||||
}
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range subUserAssignedContainers(su) {
|
||||
for _, id := range effectiveContainerAllowedImageIDs(c) {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cleanImageIDList(ids []string) []string {
|
||||
result := make([]string, 0, len(ids))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func subUserCurrentImageIDs(su *config.SubUser) []string {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, c := range subUserAssignedContainers(su) {
|
||||
if c.Template != "" && !seen[c.Template] {
|
||||
seen[c.Template] = true
|
||||
result = append(result, c.Template)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func subUserAssignedContainers(su *config.SubUser) []*config.Container {
|
||||
if su == nil {
|
||||
return nil
|
||||
}
|
||||
result := []*config.Container{}
|
||||
seen := map[string]bool{}
|
||||
for _, uuid := range su.ContainerUUIDs {
|
||||
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||
key := c.UUID
|
||||
if key == "" {
|
||||
key = c.Name
|
||||
}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range su.ContainerNames {
|
||||
if c := config.FindContainerByName(name); c != nil {
|
||||
key := c.UUID
|
||||
if key == "" {
|
||||
key = c.Name
|
||||
}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isAccessRestrictedRequest(r *http.Request) bool {
|
||||
_, restricted := requestAllowedContainers(r)
|
||||
return restricted
|
||||
@@ -485,7 +646,7 @@ func isSubUserBlockedAction(action string, method string) bool {
|
||||
return method != http.MethodGet
|
||||
}
|
||||
switch action {
|
||||
case "usage", "traffic":
|
||||
case "usage", "traffic", "history":
|
||||
return method != http.MethodGet
|
||||
default:
|
||||
return true
|
||||
@@ -504,7 +665,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
||||
return method == http.MethodGet
|
||||
}
|
||||
switch {
|
||||
case action == "usage" || action == "traffic" || action == "random-port":
|
||||
case action == "usage" || action == "traffic" || action == "history" || action == "random-port":
|
||||
return method == http.MethodGet
|
||||
case action == "snapshots":
|
||||
return method == http.MethodGet || method == http.MethodPost
|
||||
@@ -580,18 +741,21 @@ func splitBy(s, sep string) []string {
|
||||
|
||||
// SubUserListItem is the enriched sub-user info returned by the list API
|
||||
type SubUserListItem struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids"`
|
||||
ContainerName string `json:"container_name"`
|
||||
ContainerUUID string `json:"container_uuid"`
|
||||
AccessCode string `json:"access_code"`
|
||||
Password string `json:"password,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastLogin string `json:"last_login"`
|
||||
LastLoginIP string `json:"last_login_ip"`
|
||||
LastLoginUA string `json:"last_login_ua"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured"`
|
||||
CurrentImageIDs []string `json:"current_image_ids"`
|
||||
ContainerName string `json:"container_name"`
|
||||
ContainerUUID string `json:"container_uuid"`
|
||||
AccessCode string `json:"access_code"`
|
||||
Password string `json:"password,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastLogin string `json:"last_login"`
|
||||
LastLoginIP string `json:"last_login_ip"`
|
||||
LastLoginUA string `json:"last_login_ua"`
|
||||
}
|
||||
|
||||
// HandleSubUserList returns the list of all sub-users with container info
|
||||
@@ -607,13 +771,16 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||
for _, su := range config.AppConfig.SubUsers {
|
||||
item := SubUserListItem{
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AccessCode: su.AccessCode,
|
||||
Password: su.Password,
|
||||
CreatedAt: su.CreatedAt,
|
||||
ID: su.ID,
|
||||
Username: su.Username,
|
||||
ContainerNames: su.ContainerNames,
|
||||
ContainerUUIDs: su.ContainerUUIDs,
|
||||
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||
AccessCode: su.AccessCode,
|
||||
Password: su.Password,
|
||||
CreatedAt: su.CreatedAt,
|
||||
}
|
||||
|
||||
// Resolve container name from first active UUID
|
||||
@@ -711,6 +878,28 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
logs := filterSubUserLoginLogs(target.Username)
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||
|
||||
case action == "images" && r.Method == http.MethodPut:
|
||||
if !requireScope(w, r, "subuser:update") {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
AllowedImageIDs []string `json:"allowed_image_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
ids, err := normalizeAllowedImageIDs(req.AllowedImageIDs)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
target.AllowedImageIDs = ids
|
||||
target.ImageLimitConfigured = true
|
||||
target.TokenVersion++
|
||||
config.SaveConfig()
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: newSubUserResponse(*target, target.Password)})
|
||||
|
||||
default:
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||
}
|
||||
|
||||
+389
-164
@@ -30,6 +30,8 @@ type Task struct {
|
||||
ContainerName string `json:"container_name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
StageDetail string `json:"stage_detail,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TemplateID string `json:"template_id,omitempty"`
|
||||
Config lxc.ContainerConfig `json:"config,omitempty"`
|
||||
@@ -37,30 +39,74 @@ type Task struct {
|
||||
User string `json:"user,omitempty"` // who created this task
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
activeKey string
|
||||
}
|
||||
|
||||
type TaskQueue struct {
|
||||
mu sync.Mutex
|
||||
createQueue []*Task
|
||||
opQueue []*Task
|
||||
tasks map[string]*Task
|
||||
nextID int
|
||||
createCond *sync.Cond
|
||||
opCond *sync.Cond
|
||||
stop chan struct{}
|
||||
mu sync.Mutex
|
||||
createQueue []*Task
|
||||
opQueue []*Task
|
||||
tasks map[string]*Task
|
||||
nextID int
|
||||
createCond *sync.Cond
|
||||
opCond *sync.Cond
|
||||
maxConcurrency int
|
||||
activeTasks int
|
||||
activeTargets map[string]bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
type TaskQueueSettings struct {
|
||||
Concurrency int `json:"concurrency"`
|
||||
Active int `json:"active"`
|
||||
Pending int `json:"pending"`
|
||||
}
|
||||
|
||||
var globalQueue *TaskQueue
|
||||
|
||||
func init() {
|
||||
globalQueue = &TaskQueue{
|
||||
tasks: make(map[string]*Task),
|
||||
stop: make(chan struct{}),
|
||||
globalQueue = newTaskQueue(config.DefaultTaskConcurrency)
|
||||
go globalQueue.createDispatcher()
|
||||
go globalQueue.opDispatcher()
|
||||
}
|
||||
|
||||
func newTaskQueue(concurrency int) *TaskQueue {
|
||||
q := &TaskQueue{
|
||||
tasks: make(map[string]*Task),
|
||||
maxConcurrency: config.NormalizeTaskConcurrency(concurrency),
|
||||
activeTargets: make(map[string]bool),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
|
||||
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
|
||||
go globalQueue.createWorker()
|
||||
go globalQueue.opWorker()
|
||||
q.createCond = sync.NewCond(&q.mu)
|
||||
q.opCond = sync.NewCond(&q.mu)
|
||||
return q
|
||||
}
|
||||
|
||||
func ConfigureTaskQueue(concurrency int) {
|
||||
globalQueue.SetConcurrency(concurrency)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) SetConcurrency(concurrency int) {
|
||||
q.mu.Lock()
|
||||
q.maxConcurrency = config.NormalizeTaskConcurrency(concurrency)
|
||||
q.createCond.Broadcast()
|
||||
q.opCond.Broadcast()
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *TaskQueue) Settings() TaskQueueSettings {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return TaskQueueSettings{
|
||||
Concurrency: q.maxConcurrency,
|
||||
Active: q.activeTasks,
|
||||
Pending: len(q.createQueue) + len(q.opQueue),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *TaskQueue) signalDispatchers() {
|
||||
q.createCond.Broadcast()
|
||||
q.opCond.Broadcast()
|
||||
}
|
||||
|
||||
func (q *TaskQueue) enqueueTask(task *Task) {
|
||||
@@ -90,6 +136,8 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
|
||||
ContainerID: containerID,
|
||||
ContainerName: containerName,
|
||||
Status: "pending",
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
@@ -98,6 +146,7 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
|
||||
}
|
||||
if cfg != nil {
|
||||
task.Config = *cfg
|
||||
task.Config.NormalizeResourceAliases()
|
||||
}
|
||||
q.enqueueTask(task)
|
||||
q.persistTasks()
|
||||
@@ -162,6 +211,7 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
|
||||
var result []string
|
||||
for _, cfg := range configs {
|
||||
cfgCopy := cfg
|
||||
cfgCopy.NormalizeResourceAliases()
|
||||
id := q.nextID
|
||||
q.nextID++
|
||||
task := &Task{
|
||||
@@ -170,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
|
||||
ContainerID: 0,
|
||||
ContainerName: cfgCopy.Name,
|
||||
Status: "pending",
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Config: cfgCopy,
|
||||
User: user,
|
||||
@@ -200,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
||||
ContainerID: containerID,
|
||||
ContainerName: containerName,
|
||||
Status: "pending",
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
TemplateID: templateID,
|
||||
User: user,
|
||||
@@ -211,6 +265,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()
|
||||
|
||||
@@ -228,166 +286,283 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
|
||||
return taskID, true
|
||||
}
|
||||
|
||||
// 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}.
|
||||
func (q *TaskQueue) createWorker() {
|
||||
for {
|
||||
q.mu.Lock()
|
||||
for len(q.createQueue) == 0 {
|
||||
q.createCond.Wait()
|
||||
}
|
||||
task := q.createQueue[0]
|
||||
q.createQueue = q.createQueue[1:]
|
||||
task.Status = "running"
|
||||
q.mu.Unlock()
|
||||
func (q *TaskQueue) CancelPendingSecurityStops() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
createdByTask := false
|
||||
if task.Config.Name == "" {
|
||||
task.Config.Name = task.ContainerName
|
||||
}
|
||||
if task.Config.Name == "" {
|
||||
task.Status = "failed"
|
||||
task.Error = "container name is required"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
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
|
||||
}
|
||||
c := config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
// 1) Download image + apply limits (lxc-create)
|
||||
err := createByRuntime(task.Config)
|
||||
if err != nil {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
createdByTask = true
|
||||
newOpQueue = append(newOpQueue, task)
|
||||
}
|
||||
q.opQueue = newOpQueue
|
||||
|
||||
// 2) Find created container by name
|
||||
c = config.FindContainerByName(task.Config.Name)
|
||||
if c == nil {
|
||||
task.Status = "failed"
|
||||
task.Error = "created but not found in config"
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
|
||||
q.mu.Lock()
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
for id, task := range q.tasks {
|
||||
if isSecurityStopTask(task) && task.Status == "pending" {
|
||||
delete(q.tasks, id)
|
||||
cancelled++
|
||||
}
|
||||
|
||||
task.ContainerID = c.ID
|
||||
task.ContainerName = c.Name
|
||||
|
||||
// 3) Start + initialize SSH/network in the same worker.
|
||||
// If init fails, destroy the container so no dead entry remains.
|
||||
startErr := startByRuntime(c.ID)
|
||||
if startErr != nil {
|
||||
if createdByTask {
|
||||
_ = destroyByRuntime(c.ID)
|
||||
}
|
||||
task.Status = "failed"
|
||||
task.Error = startErr.Error()
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
}
|
||||
if cancelled > 0 {
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
}
|
||||
return cancelled
|
||||
}
|
||||
|
||||
// The two dispatchers keep long-running creates from blocking power operations,
|
||||
// while sharing one global concurrency budget.
|
||||
func (q *TaskQueue) createDispatcher() {
|
||||
for {
|
||||
task := q.takeNextTask(true)
|
||||
go q.runCreateTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
|
||||
// including the follow-up initialization after a create succeeds.
|
||||
func (q *TaskQueue) opWorker() {
|
||||
func (q *TaskQueue) opDispatcher() {
|
||||
for {
|
||||
q.mu.Lock()
|
||||
for len(q.opQueue) == 0 {
|
||||
q.opCond.Wait()
|
||||
}
|
||||
task := q.opQueue[0]
|
||||
q.opQueue = q.opQueue[1:]
|
||||
task.Status = "running"
|
||||
q.mu.Unlock()
|
||||
|
||||
var err error
|
||||
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) {
|
||||
c := config.FindContainer(task.ContainerID)
|
||||
if c != nil {
|
||||
if lxc.IsExpired(*c) {
|
||||
err = fmt.Errorf("容器已到期,不允许此操作")
|
||||
} else if lxc.IsTrafficExceeded(*c) {
|
||||
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
if lxc.HasSSHAuthOptions(task.Config) {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||
} else {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
auditUser := task.User
|
||||
if auditUser == "" {
|
||||
auditUser = "admin"
|
||||
}
|
||||
if err != nil {
|
||||
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 {
|
||||
task.Status = "done"
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskStop:
|
||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||
case TaskRestart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskReinstall:
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
}
|
||||
}
|
||||
q.persistTasks()
|
||||
q.mu.Unlock()
|
||||
task := q.takeNextTask(false)
|
||||
go q.runOperationTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *TaskQueue) takeNextTask(create bool) *Task {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
cond := q.opCond
|
||||
if create {
|
||||
cond = q.createCond
|
||||
}
|
||||
for {
|
||||
queue := q.opQueue
|
||||
if create {
|
||||
queue = q.createQueue
|
||||
}
|
||||
if q.activeTasks < q.maxConcurrency {
|
||||
if index := runnableTaskIndex(queue, q.activeTargets); index >= 0 {
|
||||
task := queue[index]
|
||||
queue = append(queue[:index], queue[index+1:]...)
|
||||
if create {
|
||||
q.createQueue = queue
|
||||
} else {
|
||||
q.opQueue = queue
|
||||
}
|
||||
task.Status = "running"
|
||||
task.Error = ""
|
||||
task.Stage = "preparing"
|
||||
task.StageDetail = "准备初始化环境"
|
||||
task.activeKey = taskConcurrencyKey(task)
|
||||
q.activeTargets[task.activeKey] = true
|
||||
q.activeTasks++
|
||||
q.persistTasks()
|
||||
return task
|
||||
}
|
||||
}
|
||||
cond.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func runnableTaskIndex(queue []*Task, activeTargets map[string]bool) int {
|
||||
for index, task := range queue {
|
||||
if !activeTargets[taskConcurrencyKey(task)] {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func taskConcurrencyKey(task *Task) string {
|
||||
if task == nil {
|
||||
return "task:nil"
|
||||
}
|
||||
name := strings.TrimSpace(task.ContainerName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(task.Config.Name)
|
||||
}
|
||||
if name != "" {
|
||||
return "name:" + strings.ToLower(name)
|
||||
}
|
||||
if task.ContainerID > 0 {
|
||||
return fmt.Sprintf("id:%d", task.ContainerID)
|
||||
}
|
||||
return "task:" + task.ID
|
||||
}
|
||||
|
||||
func (q *TaskQueue) finishTask(task *Task, status string, taskErr error) {
|
||||
q.mu.Lock()
|
||||
task.Status = status
|
||||
if taskErr != nil {
|
||||
task.Error = taskErr.Error()
|
||||
if task.Type == TaskCreate {
|
||||
task.Stage = "failed"
|
||||
task.StageDetail = "初始化失败"
|
||||
}
|
||||
} else {
|
||||
task.Error = ""
|
||||
if task.Type == TaskCreate {
|
||||
task.Stage = "completed"
|
||||
task.StageDetail = "初始化完成"
|
||||
}
|
||||
}
|
||||
if task.activeKey != "" {
|
||||
delete(q.activeTargets, task.activeKey)
|
||||
task.activeKey = ""
|
||||
}
|
||||
if q.activeTasks > 0 {
|
||||
q.activeTasks--
|
||||
}
|
||||
q.persistTasks()
|
||||
q.signalDispatchers()
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *TaskQueue) updateTaskStage(task *Task, stage, detail string) {
|
||||
q.mu.Lock()
|
||||
task.Stage = stage
|
||||
task.StageDetail = detail
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
// runCreateTask handles lxc-create, resource setup, start, and SSH init. A
|
||||
// restored task resumes initialization when the same-name container exists.
|
||||
func (q *TaskQueue) runCreateTask(task *Task) {
|
||||
q.mu.Lock()
|
||||
createdByTask := false
|
||||
if task.Config.Name == "" {
|
||||
task.Config.Name = task.ContainerName
|
||||
}
|
||||
task.Config.NormalizeResourceAliases()
|
||||
cfg := task.Config
|
||||
q.mu.Unlock()
|
||||
cfg.Progress = func(stage, detail string) {
|
||||
q.updateTaskStage(task, stage, detail)
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
err := fmt.Errorf("container name is required")
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
c := config.FindContainerByName(cfg.Name)
|
||||
if c == nil {
|
||||
if err := createByRuntime(cfg); err != nil {
|
||||
config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
createdByTask = true
|
||||
c = config.FindContainerByName(cfg.Name)
|
||||
if c == nil {
|
||||
err := fmt.Errorf("created but not found in config")
|
||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
task.ContainerID = c.ID
|
||||
task.ContainerName = c.Name
|
||||
q.mu.Unlock()
|
||||
startDetail := "启动容器并等待网络就绪"
|
||||
if strings.EqualFold(cfg.Virtualization, config.VirtualizationKVM) {
|
||||
startDetail = "启动虚拟机并等待网络就绪"
|
||||
}
|
||||
q.updateTaskStage(task, "starting", startDetail)
|
||||
if err := startByRuntime(c.ID); err != nil {
|
||||
if createdByTask {
|
||||
_ = destroyByRuntime(c.ID)
|
||||
}
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+err.Error(), "admin")
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
|
||||
q.finishTask(task, "done", nil)
|
||||
}
|
||||
|
||||
func (q *TaskQueue) runOperationTask(task *Task) {
|
||||
q.mu.Lock()
|
||||
err := resolveTaskContainer(task)
|
||||
q.mu.Unlock()
|
||||
skipped := false
|
||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||
c := config.FindContainer(task.ContainerID)
|
||||
if c != nil {
|
||||
if lxc.IsExpired(*c) {
|
||||
err = fmt.Errorf("容器已到期,不允许此操作")
|
||||
} else if lxc.IsTrafficExceeded(*c) {
|
||||
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||
skipped = true
|
||||
}
|
||||
if err == nil && !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(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auditUser := task.User
|
||||
if auditUser == "" {
|
||||
auditUser = "admin"
|
||||
}
|
||||
if err != nil {
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||
q.finishTask(task, "failed", err)
|
||||
return
|
||||
}
|
||||
if skipped {
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
||||
q.finishTask(task, "done", nil)
|
||||
return
|
||||
}
|
||||
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskStop:
|
||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||
case TaskRestart:
|
||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
case TaskReinstall:
|
||||
clearPolicyBlockAfterAdminRecovery(task)
|
||||
}
|
||||
q.finishTask(task, "done", nil)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -455,7 +630,8 @@ func (q *TaskQueue) GetTasks() []*Task {
|
||||
result := make([]*Task, 0, len(q.tasks))
|
||||
// Collect all task IDs, sort by creation time (extracted from ID number)
|
||||
for _, t := range q.tasks {
|
||||
result = append(result, t)
|
||||
copyTask := *t
|
||||
result = append(result, ©Task)
|
||||
}
|
||||
// Stable sort by ID number (task-N where N is sequential)
|
||||
for i := 0; i < len(result); i++ {
|
||||
@@ -512,7 +688,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
||||
if c := config.FindContainer(id); c != nil {
|
||||
runtime = c.Runtime()
|
||||
}
|
||||
if !isImageEnabledAndDownloaded(templateID, runtime) {
|
||||
if !isTemplateAllowedForRequest(r, c, templateID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not allowed for this user"})
|
||||
return
|
||||
}
|
||||
if !isTemplateAvailableForRequest(r, c, templateID, runtime) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
@@ -592,17 +772,36 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Containers[i].VCPU <= 0 {
|
||||
req.Containers[i].VCPU = 1
|
||||
}
|
||||
if err := rejectNegativeCreateLimits(req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.Containers[i].NormalizeResourceAliases()
|
||||
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
|
||||
if req.Containers[i].WantsLANIPv4() && req.Containers[i].Virtualization != config.VirtualizationLXC {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": LAN IPv4 is only supported for LXC containers"})
|
||||
return
|
||||
}
|
||||
if req.Containers[i].RAMMB < 128 {
|
||||
req.Containers[i].RAMMB = 512
|
||||
}
|
||||
if req.Containers[i].DiskGB < 1 {
|
||||
req.Containers[i].DiskGB = 5
|
||||
}
|
||||
if err := validateCreateStoragePool(&req.Containers[i]); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
}
|
||||
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||
return
|
||||
}
|
||||
if ids, err := normalizeAllowedImageIDs(req.Containers[i].AllowedImageIDs); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||
return
|
||||
} else {
|
||||
req.Containers[i].AllowedImageIDs = ids
|
||||
}
|
||||
if req.Containers[i].PortMappingCount < 0 {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||
return
|
||||
@@ -724,6 +923,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||
return
|
||||
}
|
||||
if taskType == TaskReinstall && !isTemplateAllowedForRequest(r, c, req.TemplateID) {
|
||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: c.Name + ": template is not allowed for this user"})
|
||||
return
|
||||
}
|
||||
if taskConfig != nil {
|
||||
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
||||
@@ -808,7 +1011,12 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// RestoreTasks restores task queue from config
|
||||
func RestoreTasks() {
|
||||
globalQueue.mu.Lock()
|
||||
defer globalQueue.mu.Unlock()
|
||||
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)
|
||||
@@ -820,6 +1028,7 @@ func RestoreTasks() {
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = containerName
|
||||
}
|
||||
cfg.NormalizeResourceAliases()
|
||||
containerID := st.ContainerID
|
||||
if containerID <= 0 && containerName != "" {
|
||||
if c := config.FindContainerByName(containerName); c != nil {
|
||||
@@ -833,6 +1042,8 @@ func RestoreTasks() {
|
||||
ContainerName: containerName,
|
||||
Status: st.Status,
|
||||
Error: st.Error,
|
||||
Stage: "queued",
|
||||
StageDetail: "排队等待",
|
||||
CreatedAt: st.CreatedAt,
|
||||
TemplateID: st.TemplateID,
|
||||
Config: cfg,
|
||||
@@ -862,3 +1073,17 @@ func parseIDNum(id string) int {
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func validateCreateStoragePool(cfg *lxc.ContainerConfig) error {
|
||||
required := config.StorageContentLXC
|
||||
if cfg.Virtualization == config.VirtualizationKVM {
|
||||
required = config.StorageContentKVM
|
||||
}
|
||||
requiredBytes := int64(cfg.DiskGB) * 1024 * 1024 * 1024
|
||||
pool, err := config.SelectStoragePoolForContent(required, cfg.StoragePoolID, requiredBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.StoragePoolID = pool.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
"clicd/internal/lxc"
|
||||
)
|
||||
|
||||
func TestRunnableTaskIndexSkipsActiveContainer(t *testing.T) {
|
||||
queue := []*Task{
|
||||
{ID: "task-1", Type: TaskStop, ContainerID: 1, ContainerName: "alpha"},
|
||||
{ID: "task-2", Type: TaskStart, ContainerID: 1, ContainerName: "alpha"},
|
||||
{ID: "task-3", Type: TaskStart, ContainerID: 2, ContainerName: "beta"},
|
||||
}
|
||||
active := map[string]bool{taskConcurrencyKey(queue[0]): true}
|
||||
|
||||
if got := runnableTaskIndex(queue[1:], active); got != 1 {
|
||||
t.Fatalf("runnableTaskIndex() = %d, want 1 for the other container", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskConcurrencyKeyUsesContainerName(t *testing.T) {
|
||||
create := &Task{ID: "task-1", Type: TaskCreate, Config: lxcConfigWithName("Example")}
|
||||
operation := &Task{ID: "task-2", Type: TaskDelete, ContainerID: 9, ContainerName: "example"}
|
||||
if taskConcurrencyKey(create) != taskConcurrencyKey(operation) {
|
||||
t.Fatalf("same container received different concurrency keys: %q and %q", taskConcurrencyKey(create), taskConcurrencyKey(operation))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskQueueSetConcurrencyNormalizesAndReports(t *testing.T) {
|
||||
q := newTaskQueue(config.DefaultTaskConcurrency)
|
||||
q.SetConcurrency(config.MaxTaskConcurrency + 10)
|
||||
if got := q.Settings().Concurrency; got != config.MaxTaskConcurrency {
|
||||
t.Fatalf("concurrency = %d, want %d", got, config.MaxTaskConcurrency)
|
||||
}
|
||||
q.SetConcurrency(0)
|
||||
if got := q.Settings().Concurrency; got != config.DefaultTaskConcurrency {
|
||||
t.Fatalf("concurrency = %d, want default %d", got, config.DefaultTaskConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskQueueUpdateTaskStage(t *testing.T) {
|
||||
q := newTaskQueue(config.DefaultTaskConcurrency)
|
||||
task := &Task{ID: "task-1", Type: TaskCreate, Status: "running"}
|
||||
|
||||
q.updateTaskStage(task, "rootfs", "下载模板并创建基础文件系统")
|
||||
|
||||
if task.Stage != "rootfs" || task.StageDetail != "下载模板并创建基础文件系统" {
|
||||
t.Fatalf("unexpected task stage: %q %q", task.Stage, task.StageDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func lxcConfigWithName(name string) lxc.ContainerConfig {
|
||||
return lxc.ContainerConfig{Name: name}
|
||||
}
|
||||
+54
-37
@@ -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.",
|
||||
@@ -388,6 +391,7 @@ func cliCreateContainer(reader *bufio.Reader) {
|
||||
IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500),
|
||||
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
|
||||
}
|
||||
cfg.NormalizeResourceAliases()
|
||||
|
||||
cliPrintf("\n正在创建容器 %s ...\n", name)
|
||||
if err := manager.CreateContainer(cfg); err != nil {
|
||||
@@ -556,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
|
||||
@@ -575,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
|
||||
}
|
||||
|
||||
@@ -596,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
|
||||
}
|
||||
@@ -604,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 {
|
||||
@@ -616,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
|
||||
@@ -626,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 {
|
||||
@@ -644,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
|
||||
@@ -666,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,
|
||||
@@ -707,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 != "" {
|
||||
@@ -716,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -24,11 +27,12 @@ type PortMapping struct {
|
||||
|
||||
type FirewallRule struct {
|
||||
ID string `json:"id"`
|
||||
Direction string `json:"direction"` // "in" or "out"
|
||||
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
|
||||
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
|
||||
SourceIP string `json:"source_ip"` // "" = any
|
||||
Action string `json:"action"` // "ACCEPT" or "DROP"
|
||||
Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4"
|
||||
Direction string `json:"direction"` // "in" or "out"
|
||||
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
|
||||
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
|
||||
SourceIP string `json:"source_ip"` // "" = any
|
||||
Action string `json:"action"` // "ACCEPT" or "DROP"
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
@@ -108,12 +112,16 @@ type Container struct {
|
||||
LXCName string `json:"lxc_name,omitempty"`
|
||||
KVMName string `json:"kvm_name,omitempty"`
|
||||
DiskImage string `json:"disk_image,omitempty"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
StoragePath string `json:"storage_path,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
Template string `json:"template"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
|
||||
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
|
||||
@@ -122,8 +130,16 @@ type Container struct {
|
||||
TrafficUsedTX int64 `json:"traffic_used_tx"`
|
||||
TrafficResetDate string `json:"traffic_reset_date"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
Status string `json:"status"`
|
||||
RestoreOnHostBoot bool `json:"restore_on_host_boot,omitempty"`
|
||||
IP string `json:"ip"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||
@@ -136,7 +152,10 @@ type Container struct {
|
||||
PortMappings []PortMapping `json:"port_mappings"`
|
||||
PortMappingLimit int `json:"port_mapping_limit"`
|
||||
FirewallEnabled bool `json:"firewall_enabled"`
|
||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||
FirewallDefaultAction string `json:"firewall_default_action"`
|
||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
@@ -154,6 +173,9 @@ type Container struct {
|
||||
const (
|
||||
VirtualizationLXC = "lxc"
|
||||
VirtualizationKVM = "kvm"
|
||||
|
||||
LANIPv4ModeDHCP = "dhcp"
|
||||
LANIPv4ModeStatic = "static"
|
||||
)
|
||||
|
||||
func NormalizeVirtualization(value string) string {
|
||||
@@ -173,8 +195,373 @@ func (c *Container) IsKVM() bool {
|
||||
return c.Runtime() == VirtualizationKVM
|
||||
}
|
||||
|
||||
func (c *Container) UsesLANDHCP() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeDHCP)
|
||||
}
|
||||
|
||||
func (c *Container) UsesLANStaticIPv4() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeStatic)
|
||||
}
|
||||
|
||||
func (c *Container) UsesLANIPv4() bool {
|
||||
return c.UsesLANDHCP() || c.UsesLANStaticIPv4()
|
||||
}
|
||||
|
||||
func normalizeStoragePools() bool {
|
||||
if AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
result := make([]StoragePool, 0, len(AppConfig.StoragePools))
|
||||
seen := map[string]bool{}
|
||||
defaultSeen := map[string]bool{}
|
||||
for _, pool := range AppConfig.StoragePools {
|
||||
pool.ID = strings.TrimSpace(pool.ID)
|
||||
pool.Name = strings.TrimSpace(pool.Name)
|
||||
pool.Path = filepath.Clean(strings.TrimSpace(pool.Path))
|
||||
pool.MountPoint = filepath.Clean(strings.TrimSpace(pool.MountPoint))
|
||||
if pool.MountPoint == "." {
|
||||
pool.MountPoint = ""
|
||||
}
|
||||
if pool.MountPoint != "" {
|
||||
managedPath := managedStoragePoolPath(pool.MountPoint)
|
||||
if pool.Path != managedPath {
|
||||
pool.Path = managedPath
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if pool.ID == "" {
|
||||
pool.ID = storagePoolIDFromName(pool.Name, pool.Path)
|
||||
changed = true
|
||||
}
|
||||
if pool.Name == "" {
|
||||
pool.Name = pool.ID
|
||||
changed = true
|
||||
}
|
||||
if pool.Path == "." || !filepath.IsAbs(pool.Path) || seen[pool.ID] {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
seen[pool.ID] = true
|
||||
pool.ContentTypes = normalizeStorageContentTypes(pool.ContentTypes)
|
||||
pool.DefaultContents = normalizeStorageContentTypes(pool.DefaultContents)
|
||||
allowed := map[string]bool{}
|
||||
for _, content := range pool.ContentTypes {
|
||||
allowed[content] = true
|
||||
}
|
||||
defaults := make([]string, 0, len(pool.DefaultContents))
|
||||
for _, content := range pool.DefaultContents {
|
||||
if !allowed[content] || defaultSeen[content] {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
defaultSeen[content] = true
|
||||
defaults = append(defaults, content)
|
||||
}
|
||||
pool.DefaultContents = defaults
|
||||
if pool.ContentTypes == nil {
|
||||
pool.ContentTypes = []string{}
|
||||
}
|
||||
result = append(result, pool)
|
||||
}
|
||||
if len(result) != len(AppConfig.StoragePools) {
|
||||
changed = true
|
||||
}
|
||||
AppConfig.StoragePools = result
|
||||
return changed
|
||||
}
|
||||
|
||||
func managedStoragePoolPath(mountPoint string) string {
|
||||
mountPoint = filepath.Clean(strings.TrimSpace(mountPoint))
|
||||
if mountPoint == string(os.PathSeparator) {
|
||||
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
|
||||
}
|
||||
return filepath.Join(mountPoint, "clicd")
|
||||
}
|
||||
|
||||
func storagePoolIDFromName(name, path string) string {
|
||||
base := strings.ToLower(strings.TrimSpace(name))
|
||||
if base == "" {
|
||||
base = filepath.Base(filepath.Clean(path))
|
||||
}
|
||||
replacer := strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-")
|
||||
base = replacer.Replace(base)
|
||||
base = strings.Trim(base, "-")
|
||||
if base == "" {
|
||||
base = "storage"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func normalizeStorageContentTypes(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
valid := map[string]bool{
|
||||
StorageContentLXC: true,
|
||||
StorageContentKVM: true,
|
||||
StorageContentImages: true,
|
||||
StorageContentSnapshots: true,
|
||||
StorageContentBackups: true,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, value := range values {
|
||||
next := strings.ToLower(strings.TrimSpace(value))
|
||||
if !valid[next] || seen[next] {
|
||||
continue
|
||||
}
|
||||
seen[next] = true
|
||||
result = append(result, next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func StoragePoolsForContent(content string) []StoragePool {
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
result := []StoragePool{}
|
||||
for _, pool := range AppConfig.StoragePools {
|
||||
if !pool.Enabled || !storagePoolAllows(pool, content) {
|
||||
continue
|
||||
}
|
||||
result = append(result, pool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func StoragePoolByID(id string) *StoragePool {
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
for i := range AppConfig.StoragePools {
|
||||
if AppConfig.StoragePools[i].ID == id {
|
||||
return &AppConfig.StoragePools[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func StoragePoolAllowsContent(pool StoragePool, content string) bool {
|
||||
return storagePoolAllows(pool, strings.ToLower(strings.TrimSpace(content)))
|
||||
}
|
||||
|
||||
func StoragePathForContent(content, fallback string) string {
|
||||
if pool := DefaultStoragePoolForContent(content); pool != nil {
|
||||
return pool.Path
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// PreferredStoragePoolForContent returns the configured default without doing
|
||||
// filesystem probes. Use SelectStoragePoolForContent for new writes.
|
||||
func PreferredStoragePoolForContent(content string) *StoragePool {
|
||||
if AppConfig == nil {
|
||||
return nil
|
||||
}
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
for i := range AppConfig.StoragePools {
|
||||
pool := &AppConfig.StoragePools[i]
|
||||
if !pool.Enabled || !storagePoolAllows(*pool, content) {
|
||||
continue
|
||||
}
|
||||
for _, item := range pool.DefaultContents {
|
||||
if item == content {
|
||||
return pool
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range AppConfig.StoragePools {
|
||||
pool := &AppConfig.StoragePools[i]
|
||||
if pool.Enabled && storagePoolAllows(*pool, content) {
|
||||
return pool
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultStoragePoolForContent(content string) *StoragePool {
|
||||
pool, _ := SelectStoragePoolForContent(content, "", 0)
|
||||
return pool
|
||||
}
|
||||
|
||||
const storagePoolFreeReserveBytes int64 = 256 * 1024 * 1024
|
||||
|
||||
type storagePoolCandidate struct {
|
||||
pool *StoragePool
|
||||
freeBytes int64
|
||||
isDefault bool
|
||||
}
|
||||
|
||||
// SelectStoragePoolForContent picks a writable mounted pool. The requested or
|
||||
// configured default pool is preferred while it has enough space; remaining
|
||||
// pools are tried by available space from largest to smallest.
|
||||
func SelectStoragePoolForContent(content, requestedPoolID string, requiredBytes int64) (*StoragePool, error) {
|
||||
if AppConfig == nil {
|
||||
return nil, fmt.Errorf("storage configuration is not loaded")
|
||||
}
|
||||
content = strings.ToLower(strings.TrimSpace(content))
|
||||
requestedPoolID = strings.TrimSpace(requestedPoolID)
|
||||
if requiredBytes < 0 {
|
||||
requiredBytes = 0
|
||||
}
|
||||
requiredFree := requiredBytes + storagePoolFreeReserveBytes
|
||||
candidates := make([]storagePoolCandidate, 0, len(AppConfig.StoragePools))
|
||||
configured := 0
|
||||
for i := range AppConfig.StoragePools {
|
||||
pool := &AppConfig.StoragePools[i]
|
||||
if !pool.Enabled || !storagePoolAllows(*pool, content) {
|
||||
continue
|
||||
}
|
||||
configured++
|
||||
freeBytes, available := probeStoragePoolFreeBytes(*pool)
|
||||
if !available {
|
||||
continue
|
||||
}
|
||||
candidate := storagePoolCandidate{pool: pool, freeBytes: freeBytes}
|
||||
for _, item := range pool.DefaultContents {
|
||||
if item == content {
|
||||
candidate.isDefault = true
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if configured == 0 {
|
||||
return nil, fmt.Errorf("no storage disk is enabled for %s", storageContentLabel(content))
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("all storage disks enabled for %s are unavailable or unmounted", storageContentLabel(content))
|
||||
}
|
||||
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
return candidates[i].freeBytes > candidates[j].freeBytes
|
||||
})
|
||||
preferred := func(match func(storagePoolCandidate) bool) *StoragePool {
|
||||
for _, candidate := range candidates {
|
||||
if match(candidate) && candidate.freeBytes >= requiredFree {
|
||||
return candidate.pool
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if requestedPoolID != "" {
|
||||
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.pool.ID == requestedPoolID }); pool != nil {
|
||||
return pool, nil
|
||||
}
|
||||
}
|
||||
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.isDefault }); pool != nil {
|
||||
return pool, nil
|
||||
}
|
||||
if pool := preferred(func(storagePoolCandidate) bool { return true }); pool != nil {
|
||||
return pool, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage disks enabled for %s do not have enough free space", storageContentLabel(content))
|
||||
}
|
||||
|
||||
var probeStoragePoolFreeBytes = storagePoolFreeBytes
|
||||
|
||||
func storagePoolFreeBytes(pool StoragePool) (int64, bool) {
|
||||
if strings.TrimSpace(pool.Path) == "" {
|
||||
return 0, false
|
||||
}
|
||||
if _, err := os.Stat(pool.Path); err != nil {
|
||||
if !os.IsNotExist(err) || filepath.Clean(pool.MountPoint) != string(os.PathSeparator) {
|
||||
return 0, false
|
||||
}
|
||||
if err := os.MkdirAll(pool.Path, 0755); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if mountPoint := strings.TrimSpace(pool.MountPoint); mountPoint != "" {
|
||||
out, err := exec.Command("findmnt", "-n", "-o", "TARGET", "--target", pool.Path).Output()
|
||||
if err != nil || filepath.Clean(strings.TrimSpace(string(out))) != filepath.Clean(mountPoint) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
out, err := exec.Command("df", "-B1", "-P", pool.Path).Output()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
fields := strings.Fields(lines[len(lines)-1])
|
||||
if len(fields) < 4 {
|
||||
return 0, false
|
||||
}
|
||||
freeBytes, err := strconv.ParseInt(fields[3], 10, 64)
|
||||
return freeBytes, err == nil
|
||||
}
|
||||
|
||||
func storageContentLabel(content string) string {
|
||||
switch content {
|
||||
case StorageContentLXC:
|
||||
return "LXC containers"
|
||||
case StorageContentKVM:
|
||||
return "KVM disks"
|
||||
case StorageContentImages:
|
||||
return "image cache"
|
||||
case StorageContentSnapshots:
|
||||
return "snapshots"
|
||||
case StorageContentBackups:
|
||||
return "backups"
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
func storagePoolAllows(pool StoragePool, content string) bool {
|
||||
for _, item := range pool.ContentTypes {
|
||||
if item == content {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Container) NormalizeNetworkAssignments() bool {
|
||||
changed := false
|
||||
lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode))
|
||||
if lanMode != "" && lanMode != LANIPv4ModeDHCP && lanMode != LANIPv4ModeStatic {
|
||||
lanMode = ""
|
||||
}
|
||||
if c.LANIPv4Mode != lanMode {
|
||||
c.LANIPv4Mode = lanMode
|
||||
changed = true
|
||||
}
|
||||
lanInterface := strings.TrimSpace(c.LANInterface)
|
||||
if c.LANInterface != lanInterface {
|
||||
c.LANInterface = lanInterface
|
||||
changed = true
|
||||
}
|
||||
lanAddress := strings.TrimSpace(c.LANIPv4Address)
|
||||
if c.LANIPv4Address != lanAddress {
|
||||
c.LANIPv4Address = lanAddress
|
||||
changed = true
|
||||
}
|
||||
lanGateway := strings.TrimSpace(c.LANIPv4Gateway)
|
||||
if c.LANIPv4Gateway != lanGateway {
|
||||
c.LANIPv4Gateway = lanGateway
|
||||
changed = true
|
||||
}
|
||||
if c.LANIPv4Mode == LANIPv4ModeDHCP {
|
||||
if c.LANIPv4Address != "" {
|
||||
c.LANIPv4Address = ""
|
||||
changed = true
|
||||
}
|
||||
} else if c.LANIPv4Mode != LANIPv4ModeStatic {
|
||||
if c.LANIPv4Address != "" || c.LANIPv4PrefixLen != 0 || c.LANIPv4Gateway != "" {
|
||||
c.LANIPv4Address = ""
|
||||
c.LANIPv4PrefixLen = 0
|
||||
c.LANIPv4Gateway = ""
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
seenIPv4 := map[string]bool{}
|
||||
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
|
||||
for _, item := range c.PublicIPv4s {
|
||||
@@ -313,16 +700,18 @@ func DeleteApiKey(id string) {
|
||||
}
|
||||
|
||||
type SubUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
PassHash string `json:"pass_hash"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
Token string `json:"-"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TokenVersion int `json:"token_version"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
PassHash string `json:"pass_hash"`
|
||||
ContainerNames []string `json:"container_names"`
|
||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
Token string `json:"-"`
|
||||
AccessCode string `json:"access_code"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TokenVersion int `json:"token_version"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
@@ -355,6 +744,43 @@ type SSLConfig struct {
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
StorageContentLXC = "lxc"
|
||||
StorageContentKVM = "kvm"
|
||||
StorageContentImages = "images"
|
||||
StorageContentSnapshots = "snapshots"
|
||||
StorageContentBackups = "backups"
|
||||
)
|
||||
|
||||
type StoragePool struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
MountPoint string `json:"mount_point,omitempty"`
|
||||
ContentTypes []string `json:"content_types"`
|
||||
DefaultContents []string `json:"default_contents,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func defaultPrimaryStoragePool() StoragePool {
|
||||
contents := []string{
|
||||
StorageContentLXC,
|
||||
StorageContentKVM,
|
||||
StorageContentImages,
|
||||
StorageContentSnapshots,
|
||||
StorageContentBackups,
|
||||
}
|
||||
return StoragePool{
|
||||
ID: "disk-root",
|
||||
Name: "system (/)",
|
||||
Path: "/var/lib/clicd",
|
||||
MountPoint: "/",
|
||||
ContentTypes: append([]string(nil), contents...),
|
||||
DefaultContents: append([]string(nil), contents...),
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ClicdConfig is the main configuration structure
|
||||
type ClicdConfig struct {
|
||||
AdminUser string `json:"admin_user"`
|
||||
@@ -366,6 +792,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"`
|
||||
@@ -378,16 +806,29 @@ type ClicdConfig struct {
|
||||
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
TaskConcurrency int `json:"task_concurrency"`
|
||||
Language string `json:"language"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||
StoragePools []StoragePool `json:"storage_pools"`
|
||||
}
|
||||
|
||||
var configPath string
|
||||
var AppConfig *ClicdConfig
|
||||
var allocationMu sync.Mutex
|
||||
|
||||
const DefaultSnapshotLimit = 3
|
||||
|
||||
const (
|
||||
DefaultTaskConcurrency = 2
|
||||
MaxTaskConcurrency = 16
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultNATPortStart = 20000
|
||||
DefaultNATPortEnd = 65535
|
||||
)
|
||||
|
||||
func getConfigPath() string {
|
||||
if configPath != "" {
|
||||
return configPath
|
||||
@@ -503,6 +944,8 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
NATPortStart: DefaultNATPortStart,
|
||||
NATPortEnd: DefaultNATPortEnd,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
@@ -512,6 +955,8 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||
WebSSHAllowedOrigins: []string{},
|
||||
TaskConcurrency: DefaultTaskConcurrency,
|
||||
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
@@ -546,10 +991,17 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.NextSSHPort = 22000
|
||||
changed = true
|
||||
}
|
||||
if normalizeNATPortRangeDefaults() {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextContainerID == 0 {
|
||||
AppConfig.NextContainerID = 1
|
||||
changed = true
|
||||
}
|
||||
if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized {
|
||||
AppConfig.TaskConcurrency = normalized
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.DataDir == "" {
|
||||
AppConfig.DataDir = dataDir
|
||||
changed = true
|
||||
@@ -577,6 +1029,13 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.WebSSHAllowedOrigins = normalized
|
||||
changed = true
|
||||
}
|
||||
if len(AppConfig.StoragePools) == 0 {
|
||||
AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()}
|
||||
changed = true
|
||||
}
|
||||
if normalizeStoragePools() {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.SubUsers == nil {
|
||||
AppConfig.SubUsers = make([]SubUser, 0)
|
||||
changed = true
|
||||
@@ -622,6 +1081,16 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeTaskConcurrency(value int) int {
|
||||
if value <= 0 {
|
||||
return DefaultTaskConcurrency
|
||||
}
|
||||
if value > MaxTaskConcurrency {
|
||||
return MaxTaskConcurrency
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func NormalizeLanguage(language string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||
case "en", "en-us", "en_us", "english":
|
||||
@@ -702,6 +1171,9 @@ func migrateLoadedConfig() bool {
|
||||
if ensureContainerNetworkAssignments() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerResourceAliases() {
|
||||
changed = true
|
||||
}
|
||||
if ensureContainerSnapshotScheduleDefaults() {
|
||||
changed = true
|
||||
}
|
||||
@@ -800,6 +1272,91 @@ func ensureContainerNetworkAssignments() bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
func ensureContainerResourceAliases() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.Containers {
|
||||
if NormalizeContainerResourceAliases(&AppConfig.Containers[i]) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeContainerResourceAliases(c *Container) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if c.NetworkBWMbps < 0 {
|
||||
c.NetworkBWMbps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.NetworkDownMbps < 0 {
|
||||
c.NetworkDownMbps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.NetworkUpMbps < 0 {
|
||||
c.NetworkUpMbps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.NetworkDownMbps == 0 && c.NetworkUpMbps == 0 && c.NetworkBWMbps > 0 {
|
||||
c.NetworkDownMbps = c.NetworkBWMbps
|
||||
c.NetworkUpMbps = c.NetworkBWMbps
|
||||
changed = true
|
||||
}
|
||||
nextNetworkBW := LegacySymmetricLimit(c.NetworkDownMbps, c.NetworkUpMbps)
|
||||
if c.NetworkBWMbps != nextNetworkBW {
|
||||
c.NetworkBWMbps = nextNetworkBW
|
||||
changed = true
|
||||
}
|
||||
|
||||
if c.IOSpeedMBps < 0 {
|
||||
c.IOSpeedMBps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.IOReadMBps < 0 {
|
||||
c.IOReadMBps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.IOWriteMBps < 0 {
|
||||
c.IOWriteMBps = 0
|
||||
changed = true
|
||||
}
|
||||
if c.IOReadMBps == 0 && c.IOWriteMBps == 0 && c.IOSpeedMBps > 0 {
|
||||
c.IOReadMBps = c.IOSpeedMBps
|
||||
c.IOWriteMBps = c.IOSpeedMBps
|
||||
changed = true
|
||||
}
|
||||
nextIO := LegacySymmetricLimit(c.IOReadMBps, c.IOWriteMBps)
|
||||
if c.IOSpeedMBps != nextIO {
|
||||
c.IOSpeedMBps = nextIO
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func LegacySymmetricLimit(a, b int) int {
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if b < 0 {
|
||||
b = 0
|
||||
}
|
||||
if a == b {
|
||||
return a
|
||||
}
|
||||
if a == 0 {
|
||||
return b
|
||||
}
|
||||
if b == 0 {
|
||||
return a
|
||||
}
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func migrateSubUsers() bool {
|
||||
changed := false
|
||||
for i := range AppConfig.SubUsers {
|
||||
@@ -880,16 +1437,21 @@ func SaveConfig() error {
|
||||
|
||||
// AddContainer adds a container to the config
|
||||
func AddContainer(c Container) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
if c.UUID == "" {
|
||||
c.UUID = NewContainerUUID()
|
||||
}
|
||||
c.Virtualization = NormalizeVirtualization(c.Virtualization)
|
||||
NormalizeContainerResourceAliases(&c)
|
||||
AppConfig.Containers = append(AppConfig.Containers, c)
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// AllocateContainerID allocates a new container ID
|
||||
func AllocateContainerID() int {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
id := AppConfig.NextContainerID
|
||||
AppConfig.NextContainerID++
|
||||
SaveConfig()
|
||||
@@ -1051,6 +1613,23 @@ func UpdateContainerStatus(id int, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateContainerStatusAndRestore(id int, status string, restoreOnHostBoot bool) {
|
||||
c := FindContainer(id)
|
||||
if c != nil {
|
||||
c.Status = status
|
||||
c.RestoreOnHostBoot = restoreOnHostBoot
|
||||
SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func SetContainerRestoreOnHostBoot(id int, restore bool) {
|
||||
c := FindContainer(id)
|
||||
if c != nil {
|
||||
c.RestoreOnHostBoot = restore
|
||||
SaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
|
||||
c := FindContainer(id)
|
||||
if c == nil {
|
||||
@@ -1073,16 +1652,104 @@ 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) {
|
||||
allocationMu.Lock()
|
||||
defer allocationMu.Unlock()
|
||||
used := collectAllHostPorts()
|
||||
start, end := NATPortRange()
|
||||
port := AppConfig.NextSSHPort
|
||||
if port < start || port > end {
|
||||
port = start
|
||||
}
|
||||
capacity := end - start + 1
|
||||
for i := 0; i < capacity; i++ {
|
||||
candidate := start + ((port - start + i) % capacity)
|
||||
if used[candidate] {
|
||||
continue
|
||||
}
|
||||
AppConfig.NextSSHPort = candidate + 1
|
||||
if AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
SaveConfig()
|
||||
return candidate, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end)
|
||||
}
|
||||
|
||||
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllocateSSHPortUsesConfiguredNATRange(t *testing.T) {
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 30000,
|
||||
NATPortEnd: 30002,
|
||||
NextSSHPort: 22000,
|
||||
Containers: []Container{{
|
||||
PortMappings: []PortMapping{
|
||||
{HostPort: 30000},
|
||||
{HostPort: 30001},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
port, err := AllocateSSHPort()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if port != 30002 {
|
||||
t.Fatalf("expected port 30002, got %d", port)
|
||||
}
|
||||
if AppConfig.NextSSHPort != 30000 {
|
||||
t.Fatalf("expected next port to wrap to 30000, got %d", AppConfig.NextSSHPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) {
|
||||
AppConfig = &ClicdConfig{
|
||||
NATPortStart: 31000,
|
||||
NATPortEnd: 31001,
|
||||
NextSSHPort: 31000,
|
||||
Containers: []Container{{
|
||||
PortMappings: []PortMapping{
|
||||
{HostPort: 31000},
|
||||
{HostPort: 31001},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
if port, err := AllocateSSHPort(); err == nil {
|
||||
t.Fatalf("expected exhausted NAT range error, got port %d", port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeStoragePoolsReplacesPersistedCustomPath(t *testing.T) {
|
||||
previousConfig := AppConfig
|
||||
t.Cleanup(func() { AppConfig = previousConfig })
|
||||
mountPoint := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
|
||||
ID: "data",
|
||||
Name: "data",
|
||||
Path: filepath.Join(t.TempDir(), "uncontrolled"),
|
||||
MountPoint: mountPoint,
|
||||
Enabled: true,
|
||||
}}}
|
||||
if !normalizeStoragePools() {
|
||||
t.Fatal("expected custom path normalization to report a change")
|
||||
}
|
||||
want := managedStoragePoolPath(mountPoint)
|
||||
if got := AppConfig.StoragePools[0].Path; got != want {
|
||||
t.Fatalf("normalized path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectStoragePoolForContent(t *testing.T) {
|
||||
previousConfig := AppConfig
|
||||
previousProbe := probeStoragePoolFreeBytes
|
||||
t.Cleanup(func() {
|
||||
AppConfig = previousConfig
|
||||
probeStoragePoolFreeBytes = previousProbe
|
||||
})
|
||||
|
||||
AppConfig = &ClicdConfig{StoragePools: []StoragePool{
|
||||
{
|
||||
ID: "primary",
|
||||
Path: "/primary",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
DefaultContents: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "large",
|
||||
Path: "/large",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
ID: "small",
|
||||
Path: "/small",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
},
|
||||
}}
|
||||
|
||||
free := map[string]int64{
|
||||
"primary": 20 * 1024 * 1024 * 1024,
|
||||
"large": 50 * 1024 * 1024 * 1024,
|
||||
"small": 10 * 1024 * 1024 * 1024,
|
||||
}
|
||||
probeStoragePoolFreeBytes = func(pool StoragePool) (int64, bool) {
|
||||
value, ok := free[pool.ID]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
pool, err := SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pool.ID != "primary" {
|
||||
t.Fatalf("selected %q, want configured default primary", pool.ID)
|
||||
}
|
||||
|
||||
free["primary"] = 128 * 1024 * 1024
|
||||
pool, err = SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pool.ID != "large" {
|
||||
t.Fatalf("selected %q, want largest fallback pool", pool.ID)
|
||||
}
|
||||
|
||||
pool, err = SelectStoragePoolForContent(StorageContentLXC, "small", 5*1024*1024*1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pool.ID != "small" {
|
||||
t.Fatalf("selected %q, want requested pool", pool.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectStoragePoolRequiresEnabledContent(t *testing.T) {
|
||||
previousConfig := AppConfig
|
||||
previousProbe := probeStoragePoolFreeBytes
|
||||
t.Cleanup(func() {
|
||||
AppConfig = previousConfig
|
||||
probeStoragePoolFreeBytes = previousProbe
|
||||
})
|
||||
|
||||
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
|
||||
ID: "primary",
|
||||
Path: "/primary",
|
||||
ContentTypes: []string{StorageContentLXC},
|
||||
Enabled: true,
|
||||
}}}
|
||||
probeStoragePoolFreeBytes = func(StoragePool) (int64, bool) { return 100 * 1024 * 1024 * 1024, true }
|
||||
|
||||
if _, err := SelectStoragePoolForContent(StorageContentSnapshots, "", 0); err == nil {
|
||||
t.Fatal("expected snapshots selection to fail when no pool enables snapshots")
|
||||
}
|
||||
}
|
||||
@@ -20,33 +20,45 @@ var (
|
||||
)
|
||||
|
||||
type savedTaskConfig struct {
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Name string `json:"name"`
|
||||
Virtualization string `json:"virtualization,omitempty"`
|
||||
TemplateID string `json:"template_id"`
|
||||
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||
VCPU float64 `json:"vcpu"`
|
||||
CPUPercent int `json:"cpu_percent"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||
NetworkDownMbps int `json:"network_down_mbps"`
|
||||
NetworkUpMbps int `json:"network_up_mbps"`
|
||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||
TrafficMode string `json:"traffic_mode"`
|
||||
TrafficInGB int `json:"traffic_in_gb"`
|
||||
TrafficOutGB int `json:"traffic_out_gb"`
|
||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||
IOReadMBps int `json:"io_read_mbps"`
|
||||
IOWriteMBps int `json:"io_write_mbps"`
|
||||
ExtraPorts []int `json:"extra_ports"`
|
||||
PortMappingCount int `json:"port_mapping_count"`
|
||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||
LANInterface string `json:"lan_interface,omitempty"`
|
||||
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||
SnapshotLimit int `json:"snapshot_limit"`
|
||||
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||
AssignIPv4 bool `json:"assign_ipv4"`
|
||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||
AssignIPv6 bool `json:"assign_ipv6"`
|
||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||
SSHPassword string `json:"ssh_password,omitempty"`
|
||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
@@ -55,10 +67,12 @@ func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||
}
|
||||
var cfg savedTaskConfig
|
||||
_ = json.Unmarshal([]byte(raw), &cfg)
|
||||
normalizeSavedTaskConfigLimits(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
normalizeSavedTaskConfigLimits(&cfg)
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -66,6 +80,41 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func normalizeSavedTaskConfigLimits(cfg *savedTaskConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.NetworkBWMbps < 0 {
|
||||
cfg.NetworkBWMbps = 0
|
||||
}
|
||||
if cfg.NetworkDownMbps < 0 {
|
||||
cfg.NetworkDownMbps = 0
|
||||
}
|
||||
if cfg.NetworkUpMbps < 0 {
|
||||
cfg.NetworkUpMbps = 0
|
||||
}
|
||||
if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 {
|
||||
cfg.NetworkDownMbps = cfg.NetworkBWMbps
|
||||
cfg.NetworkUpMbps = cfg.NetworkBWMbps
|
||||
}
|
||||
cfg.NetworkBWMbps = LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||
|
||||
if cfg.IOSpeedMBps < 0 {
|
||||
cfg.IOSpeedMBps = 0
|
||||
}
|
||||
if cfg.IOReadMBps < 0 {
|
||||
cfg.IOReadMBps = 0
|
||||
}
|
||||
if cfg.IOWriteMBps < 0 {
|
||||
cfg.IOWriteMBps = 0
|
||||
}
|
||||
if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 {
|
||||
cfg.IOReadMBps = cfg.IOSpeedMBps
|
||||
cfg.IOWriteMBps = cfg.IOSpeedMBps
|
||||
}
|
||||
cfg.IOSpeedMBps = LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps)
|
||||
}
|
||||
|
||||
func encodeStringSlice(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
@@ -142,12 +191,16 @@ func ensureSchema() error {
|
||||
lxc_name TEXT,
|
||||
kvm_name TEXT,
|
||||
disk_image TEXT,
|
||||
storage_pool_id TEXT,
|
||||
storage_path TEXT,
|
||||
mac_address TEXT,
|
||||
template TEXT,
|
||||
vcpu REAL,
|
||||
ram_mb INTEGER,
|
||||
disk_gb INTEGER,
|
||||
network_bw_mbps INTEGER,
|
||||
network_down_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
network_up_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
monthly_traffic_gb INTEGER,
|
||||
traffic_mode TEXT,
|
||||
traffic_in_gb INTEGER,
|
||||
@@ -156,8 +209,16 @@ func ensureSchema() error {
|
||||
traffic_used_tx INTEGER,
|
||||
traffic_reset_date TEXT,
|
||||
io_speed_mbps INTEGER,
|
||||
io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT,
|
||||
restore_on_host_boot INTEGER NOT NULL DEFAULT 0,
|
||||
ip TEXT,
|
||||
lan_ipv4_mode TEXT,
|
||||
lan_interface TEXT,
|
||||
lan_ipv4_address TEXT,
|
||||
lan_ipv4_prefix_len INTEGER,
|
||||
lan_ipv4_gateway TEXT,
|
||||
ipv6 TEXT,
|
||||
ipv6_prefix_len INTEGER,
|
||||
ipv6_interface TEXT,
|
||||
@@ -177,7 +238,9 @@ func ensureSchema() error {
|
||||
snapshot_schedule_created_by TEXT,
|
||||
policy_blocked INTEGER,
|
||||
policy_blocked_reason TEXT,
|
||||
policy_blocked_at TEXT
|
||||
policy_blocked_at TEXT,
|
||||
allowed_image_ids TEXT,
|
||||
image_limit_configured INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS port_mappings (
|
||||
container_id INTEGER NOT NULL,
|
||||
@@ -213,7 +276,9 @@ func ensureSchema() error {
|
||||
pass_hash TEXT,
|
||||
access_code TEXT,
|
||||
created_at TEXT,
|
||||
token_version INTEGER
|
||||
token_version INTEGER,
|
||||
allowed_image_ids TEXT,
|
||||
image_limit_configured INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
|
||||
sub_user_id TEXT NOT NULL,
|
||||
@@ -282,13 +347,22 @@ func ensureSchema() error {
|
||||
cfg_ram_mb INTEGER,
|
||||
cfg_disk_gb INTEGER,
|
||||
cfg_network_bw_mbps INTEGER,
|
||||
cfg_network_down_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_network_up_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_monthly_traffic_gb INTEGER,
|
||||
cfg_traffic_mode TEXT,
|
||||
cfg_traffic_in_gb INTEGER,
|
||||
cfg_traffic_out_gb INTEGER,
|
||||
cfg_io_speed_mbps INTEGER,
|
||||
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_port_mapping_count INTEGER,
|
||||
cfg_assign_nat INTEGER,
|
||||
cfg_lan_ipv4_mode TEXT,
|
||||
cfg_lan_interface TEXT,
|
||||
cfg_lan_ipv4_address TEXT,
|
||||
cfg_lan_ipv4_prefix_len INTEGER,
|
||||
cfg_lan_ipv4_gateway TEXT,
|
||||
cfg_snapshot_limit INTEGER,
|
||||
cfg_assign_ipv4 INTEGER,
|
||||
cfg_ipv4_count INTEGER,
|
||||
@@ -299,6 +373,8 @@ func ensureSchema() error {
|
||||
cfg_ssh_auth_mode TEXT,
|
||||
cfg_ssh_password TEXT,
|
||||
cfg_ssh_public_key TEXT,
|
||||
cfg_allowed_image_ids TEXT,
|
||||
cfg_image_limit_configured INTEGER NOT NULL DEFAULT 0,
|
||||
cfg_expires_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||
@@ -340,6 +416,7 @@ func ensureSchema() error {
|
||||
}
|
||||
|
||||
func ensureSchemaMigrations() error {
|
||||
added := map[string]bool{}
|
||||
for _, column := range []struct {
|
||||
table string
|
||||
name string
|
||||
@@ -352,32 +429,117 @@ func ensureSchemaMigrations() error {
|
||||
{"api_keys", "last_used_ip", "TEXT"},
|
||||
{"tasks", "ip", "TEXT"},
|
||||
{"tasks", "user_agent", "TEXT"},
|
||||
{"tasks", "cfg_network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
||||
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
||||
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
||||
{"tasks", "cfg_assign_nat", "INTEGER"},
|
||||
{"tasks", "cfg_lan_ipv4_mode", "TEXT"},
|
||||
{"tasks", "cfg_lan_interface", "TEXT"},
|
||||
{"tasks", "cfg_lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"tasks", "cfg_lan_ipv4_prefix_len", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"tasks", "cfg_lan_ipv4_gateway", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
||||
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
||||
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
||||
{"tasks", "cfg_ssh_password", "TEXT"},
|
||||
{"tasks", "cfg_ssh_public_key", "TEXT"},
|
||||
{"tasks", "cfg_allowed_image_ids", "TEXT"},
|
||||
{"tasks", "cfg_image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"port_mappings", "host_ip", "TEXT"},
|
||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||
{"sub_users", "allowed_image_ids", "TEXT"},
|
||||
{"sub_users", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
||||
{"containers", "firewall_rules", "TEXT"},
|
||||
{"containers", "allowed_image_ids", "TEXT"},
|
||||
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "restore_on_host_boot", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "storage_pool_id", "TEXT"},
|
||||
{"containers", "storage_path", "TEXT"},
|
||||
{"containers", "lan_ipv4_mode", "TEXT"},
|
||||
{"containers", "lan_interface", "TEXT"},
|
||||
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"containers", "lan_ipv4_prefix_len", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"containers", "lan_ipv4_gateway", "TEXT NOT NULL DEFAULT ''"},
|
||||
} {
|
||||
if err := ensureColumn(column.table, column.name, column.def); err != nil {
|
||||
wasAdded, err := ensureColumn(column.table, column.name, column.def)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wasAdded {
|
||||
added[column.table+"."+column.name] = true
|
||||
}
|
||||
}
|
||||
if added["containers.network_down_mbps"] || added["containers.network_up_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET network_down_mbps = COALESCE(NULLIF(network_down_mbps, 0), COALESCE(network_bw_mbps, 0)),
|
||||
network_up_mbps = COALESCE(NULLIF(network_up_mbps, 0), COALESCE(network_bw_mbps, 0))
|
||||
WHERE COALESCE(network_bw_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if added["containers.io_read_mbps"] || added["containers.io_write_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET io_read_mbps = COALESCE(NULLIF(io_read_mbps, 0), COALESCE(io_speed_mbps, 0)),
|
||||
io_write_mbps = COALESCE(NULLIF(io_write_mbps, 0), COALESCE(io_speed_mbps, 0))
|
||||
WHERE COALESCE(io_speed_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if added["tasks.cfg_network_down_mbps"] || added["tasks.cfg_network_up_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE tasks
|
||||
SET cfg_network_down_mbps = COALESCE(NULLIF(cfg_network_down_mbps, 0), COALESCE(cfg_network_bw_mbps, 0)),
|
||||
cfg_network_up_mbps = COALESCE(NULLIF(cfg_network_up_mbps, 0), COALESCE(cfg_network_bw_mbps, 0))
|
||||
WHERE COALESCE(cfg_network_bw_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if added["tasks.cfg_io_read_mbps"] || added["tasks.cfg_io_write_mbps"] {
|
||||
if _, err := db.Exec(`UPDATE tasks
|
||||
SET cfg_io_read_mbps = COALESCE(NULLIF(cfg_io_read_mbps, 0), COALESCE(cfg_io_speed_mbps, 0)),
|
||||
cfg_io_write_mbps = COALESCE(NULLIF(cfg_io_write_mbps, 0), COALESCE(cfg_io_speed_mbps, 0))
|
||||
WHERE COALESCE(cfg_io_speed_mbps, 0) > 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET lan_ipv4_mode = COALESCE(lan_ipv4_mode, ''),
|
||||
lan_interface = COALESCE(lan_interface, ''),
|
||||
lan_ipv4_address = COALESCE(lan_ipv4_address, ''),
|
||||
lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0),
|
||||
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE containers
|
||||
SET storage_pool_id = COALESCE(storage_pool_id, ''),
|
||||
storage_path = COALESCE(storage_path, '')`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE tasks
|
||||
SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
|
||||
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
|
||||
cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''),
|
||||
cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0),
|
||||
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(table, name, def string) error {
|
||||
func ensureColumn(table, name, def string) (bool, error) {
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
@@ -386,17 +548,17 @@ func ensureColumn(table, name, def string) error {
|
||||
var notNull, pk int
|
||||
var defaultValue interface{}
|
||||
if err := rows.Scan(&cid, &columnName, &columnType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
if columnName == name {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
|
||||
return err
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
@@ -429,8 +591,11 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
NextContainerID: atoi(meta["next_container_id"]),
|
||||
NextVNCPort: atoi(meta["next_vnc_port"]),
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
NATPortStart: atoi(meta["nat_port_start"]),
|
||||
NATPortEnd: atoi(meta["nat_port_end"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
TaskConcurrency: atoi(meta["task_concurrency"]),
|
||||
Language: meta["language"],
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||
@@ -448,6 +613,9 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.StoragePools)
|
||||
}
|
||||
|
||||
if cfg.Containers, err = loadContainers(); err != nil {
|
||||
return nil, false, err
|
||||
@@ -547,6 +715,7 @@ func saveMeta(tx *sql.Tx) error {
|
||||
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||
storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools)
|
||||
values := map[string]string{
|
||||
"admin_user": AppConfig.AdminUser,
|
||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||
@@ -556,14 +725,18 @@ 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),
|
||||
"task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency),
|
||||
"language": NormalizeLanguage(AppConfig.Language),
|
||||
"ssl": string(sslJSON),
|
||||
"ssl_certificates": string(sslCertificatesJSON),
|
||||
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||
"storage_pools": string(storagePoolsJSON),
|
||||
"schema_version": "1",
|
||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -577,26 +750,34 @@ func saveMeta(tx *sql.Tx) error {
|
||||
|
||||
func saveContainers(tx *sql.Tx) error {
|
||||
for _, c := range AppConfig.Containers {
|
||||
NormalizeContainerResourceAliases(&c)
|
||||
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
|
||||
if _, err := tx.Exec(`INSERT INTO containers (
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||
status, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_rules
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
|
||||
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.StoragePoolID, c.StoragePath, c.MACAddress, c.Template,
|
||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
||||
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
|
||||
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
|
||||
c.Status, boolInt(c.RestoreOnHostBoot), c.IP, c.LANIPv4Mode, c.LANInterface, c.LANIPv4Address, c.LANIPv4PrefixLen, c.LANIPv4Gateway,
|
||||
c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
||||
boolInt(c.FirewallEnabled), marshalFirewallRules(c.FirewallRules),
|
||||
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules), allowedImageIDs, boolInt(c.ImageLimitConfigured),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -624,8 +805,9 @@ func saveContainers(tx *sql.Tx) error {
|
||||
|
||||
func saveSubUsers(tx *sql.Tx) error {
|
||||
for _, su := range AppConfig.SubUsers {
|
||||
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil {
|
||||
allowedImageIDs := encodeStringSlice(su.AllowedImageIDs)
|
||||
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion, allowedImageIDs, boolInt(su.ImageLimitConfigured)); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, name := range su.ContainerNames {
|
||||
@@ -731,18 +913,24 @@ func saveTasksDB(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`INSERT INTO tasks(
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
||||
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
||||
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
|
||||
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.LANIPv4Mode, cfg.LANInterface,
|
||||
cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway, cfg.SnapshotLimit,
|
||||
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
||||
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
|
||||
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, encodeStringSlice(cfg.AllowedImageIDs), boolInt(cfg.ImageLimitConfigured), cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -786,15 +974,18 @@ func saveSnapshots(tx *sql.Tx) error {
|
||||
|
||||
func loadContainers() ([]Container, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
|
||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
|
||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||
status, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||
firewall_enabled, firewall_rules
|
||||
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
|
||||
FROM containers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -804,27 +995,49 @@ func loadContainers() ([]Container, error) {
|
||||
result := []Container{}
|
||||
for rows.Next() {
|
||||
var c Container
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled int
|
||||
var firewallRulesJSON sql.NullString
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured, restoreOnHostBoot int
|
||||
var firewallDefaultAction string
|
||||
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||
var storagePoolID, storagePath sql.NullString
|
||||
var lanIPv4Mode, lanInterface sql.NullString
|
||||
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
||||
var lanIPv4PrefixLen sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps,
|
||||
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &storagePoolID, &storagePath, &c.MACAddress, &c.Template,
|
||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
|
||||
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
||||
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
|
||||
&c.Status, &restoreOnHostBoot, &c.IP, &lanIPv4Mode, &lanInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway,
|
||||
&c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||
&firewallEnabled, &firewallRulesJSON,
|
||||
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, &allowedImageIDs, &imageLimitConfigured,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.StoragePoolID = storagePoolID.String
|
||||
c.StoragePath = storagePath.String
|
||||
c.LANIPv4Mode = lanIPv4Mode.String
|
||||
c.LANInterface = lanInterface.String
|
||||
c.LANIPv4Address = lanIPv4Address.String
|
||||
if lanIPv4PrefixLen.Valid {
|
||||
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||
}
|
||||
c.LANIPv4Gateway = lanIPv4Gateway.String
|
||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||
c.RestoreOnHostBoot = restoreOnHostBoot != 0
|
||||
c.PolicyBlocked = policyBlocked != 0
|
||||
c.FirewallEnabled = firewallEnabled != 0
|
||||
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
||||
c.ImageLimitConfigured = imageLimitConfigured != 0
|
||||
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
||||
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
||||
}
|
||||
c.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||
NormalizeContainerResourceAliases(&c)
|
||||
result = append(result, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -919,7 +1132,7 @@ func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
|
||||
}
|
||||
|
||||
func loadSubUsers() ([]SubUser, error) {
|
||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured FROM sub_users ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -927,9 +1140,13 @@ func loadSubUsers() ([]SubUser, error) {
|
||||
result := []SubUser{}
|
||||
for rows.Next() {
|
||||
var su SubUser
|
||||
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil {
|
||||
var allowedImageIDs sql.NullString
|
||||
var imageLimitConfigured int
|
||||
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion, &allowedImageIDs, &imageLimitConfigured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
su.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||
su.ImageLimitConfigured = imageLimitConfigured != 0
|
||||
result = append(result, su)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1018,10 +1235,13 @@ func loadTasks() ([]SavedTask, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
|
||||
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
|
||||
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
|
||||
FROM tasks ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1032,17 +1252,20 @@ func loadTasks() ([]SavedTask, error) {
|
||||
for rows.Next() {
|
||||
var t SavedTask
|
||||
var cfg savedTaskConfig
|
||||
var assignIPv4, assignIPv6 int
|
||||
var assignIPv4, assignIPv6, imageLimitConfigured int
|
||||
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
||||
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
|
||||
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
||||
var lanIPv4Mode, lanInterface, lanIPv4Address, lanIPv4Gateway, sshAuthMode, sshPassword, sshPublicKey, allowedImageIDs sql.NullString
|
||||
var assignNAT, lanIPv4PrefixLen, ipv4Count, ipv6Count sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
||||
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
|
||||
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
||||
&cfg.PortMappingCount, &assignNAT, &lanIPv4Mode, &lanInterface,
|
||||
&lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &cfg.SnapshotLimit,
|
||||
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
|
||||
&sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1052,6 +1275,13 @@ func loadTasks() ([]SavedTask, error) {
|
||||
value := assignNAT.Int64 != 0
|
||||
cfg.AssignNAT = &value
|
||||
}
|
||||
cfg.LANIPv4Mode = lanIPv4Mode.String
|
||||
cfg.LANInterface = lanInterface.String
|
||||
cfg.LANIPv4Address = lanIPv4Address.String
|
||||
if lanIPv4PrefixLen.Valid {
|
||||
cfg.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||
}
|
||||
cfg.LANIPv4Gateway = lanIPv4Gateway.String
|
||||
cfg.AssignIPv4 = assignIPv4 != 0
|
||||
if ipv4Count.Valid {
|
||||
cfg.IPv4Count = int(ipv4Count.Int64)
|
||||
@@ -1065,6 +1295,9 @@ func loadTasks() ([]SavedTask, error) {
|
||||
cfg.SSHAuthMode = sshAuthMode.String
|
||||
cfg.SSHPassword = sshPassword.String
|
||||
cfg.SSHPublicKey = sshPublicKey.String
|
||||
cfg.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||
cfg.ImageLimitConfigured = imageLimitConfigured != 0
|
||||
normalizeSavedTaskConfigLimits(&cfg)
|
||||
result = append(result, t)
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
@@ -1189,6 +1422,14 @@ func marshalFirewallRules(rules []FirewallRule) interface{} {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" {
|
||||
return "ACCEPT"
|
||||
}
|
||||
return "DROP"
|
||||
}
|
||||
|
||||
func boolPtrInt(value *bool) interface{} {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@@ -93,11 +93,15 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
|
||||
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
|
||||
}
|
||||
if cfg.TaskConcurrency != DefaultTaskConcurrency {
|
||||
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
|
||||
t.Fatalf("sqlite database was not created: %v", err)
|
||||
}
|
||||
|
||||
cfg.Containers[0].Status = "stopped"
|
||||
cfg.TaskConcurrency = 6
|
||||
if err := SaveConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -111,6 +115,9 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
||||
if got := cfg.Containers[0].Status; got != "stopped" {
|
||||
t.Fatalf("expected sqlite value to win after migration, got %q", got)
|
||||
}
|
||||
if got := cfg.TaskConcurrency; got != 6 {
|
||||
t.Fatalf("persisted task concurrency = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func resetConfigStoreForTest(t *testing.T) {
|
||||
|
||||
+464
-91
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package kvm
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -11,6 +12,39 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestImagePathUsesAllowlistedImageID(t *testing.T) {
|
||||
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute", "unknown-image"} {
|
||||
if got := filepath.Base(ImagePath(id)); got != "__invalid_image_id__.qcow2" {
|
||||
t.Fatalf("ImagePath(%q) basename = %q", id, got)
|
||||
}
|
||||
}
|
||||
validID := GetImages()[0].ID
|
||||
if got := filepath.Base(ImagePath(validID)); got != validID+".qcow2" {
|
||||
t.Fatalf("ImagePath(%q) basename = %q", validID, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info string
|
||||
want bool
|
||||
}{
|
||||
{name: "active", info: "Name: default\nActive: yes\n", want: true},
|
||||
{name: "spacing and case", info: " Active : YES \r\n", want: true},
|
||||
{name: "inactive", info: "Name: default\nActive: no\n", want: false},
|
||||
{name: "missing field", info: "Name: default\nAutostart: yes\n", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := libvirtNetworkActive(tc.info); got != tc.want {
|
||||
t.Fatalf("libvirtNetworkActive(%q) = %v, want %v", tc.info, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
|
||||
password := `pa'";$(touch /tmp/pwned); echo #\\word`
|
||||
got, err := chpasswdStdin("root", password)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
@@ -16,6 +20,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",
|
||||
@@ -36,12 +49,25 @@ func GetImages() []Image {
|
||||
Description: "Ubuntu 22.04 LTS cloud image for KVM",
|
||||
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-trixie", Name: "Debian 13 KVM",
|
||||
Distro: "debian", Release: "trixie", Arch: "amd64",
|
||||
Description: "Debian 13 generic cloud image for KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||
Description: "Debian 12 generic cloud image for KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-trixie-xfce", Name: "Debian 13 XFCE KVM",
|
||||
Distro: "debian", Release: "trixie", Arch: "amd64",
|
||||
Description: "Debian 13 generic cloud image with XFCE desktop provisioned via cloud-init",
|
||||
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
|
||||
Desktop: "xfce",
|
||||
},
|
||||
{
|
||||
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
|
||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||
@@ -94,6 +120,59 @@ 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-trixie", Name: "Debian 13 KVM",
|
||||
Distro: "debian", Release: "trixie", Arch: "arm64",
|
||||
Description: "Debian 13 generic cloud image for ARM64 KVM",
|
||||
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2",
|
||||
},
|
||||
{
|
||||
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 {
|
||||
@@ -104,16 +183,34 @@ func FindImage(id string) *Image {
|
||||
}
|
||||
|
||||
func CacheDir() string {
|
||||
if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil {
|
||||
return filepath.Join(pool.Path, "images", "kvm")
|
||||
}
|
||||
return filepath.Join(BaseDir(), "images")
|
||||
}
|
||||
|
||||
func ImagePath(id string) string {
|
||||
img := FindImage(id)
|
||||
ext := ".qcow2"
|
||||
safeID := "__invalid_image_id__"
|
||||
if img != nil {
|
||||
safeID = img.ID
|
||||
}
|
||||
if img != nil && img.Distro == "windows" {
|
||||
ext = ".iso"
|
||||
}
|
||||
return filepath.Join(CacheDir(), id+ext)
|
||||
fileName := safeID + ext
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
|
||||
candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
legacy := filepath.Join("/var/lib/clicd/kvm/images", fileName)
|
||||
if info, err := os.Stat(legacy); err == nil && !info.IsDir() {
|
||||
return legacy
|
||||
}
|
||||
return filepath.Join(CacheDir(), fileName)
|
||||
}
|
||||
|
||||
// IsWindowsImage returns true if the image distro is "windows".
|
||||
|
||||
@@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status {
|
||||
}
|
||||
|
||||
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
|
||||
if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 {
|
||||
return configured
|
||||
}
|
||||
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
|
||||
}
|
||||
|
||||
@@ -1512,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
|
||||
oldAssignments := append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
|
||||
oldPrimary := c.IPv6
|
||||
oldPrimaryPrefixLen := c.IPv6PrefixLen
|
||||
oldPrimaryInterface := c.IPv6Interface
|
||||
|
||||
assignments := []config.IPv6Assignment{}
|
||||
if auto || len(requested) > 0 {
|
||||
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignments = allocated
|
||||
}
|
||||
|
||||
for _, assignment := range oldAssignments {
|
||||
uplink := assignment.Interface
|
||||
if uplink == "" {
|
||||
uplink = oldPrimaryInterface
|
||||
}
|
||||
removeHostIPv6Routing(assignment.Address, uplink)
|
||||
}
|
||||
if len(oldAssignments) == 0 && oldPrimary != "" {
|
||||
removeHostIPv6Routing(oldPrimary, oldPrimaryInterface)
|
||||
oldAssignments = append(oldAssignments, config.IPv6Assignment{Address: oldPrimary, PrefixLen: oldPrimaryPrefixLen, Interface: oldPrimaryInterface})
|
||||
}
|
||||
|
||||
c.IPv6 = ""
|
||||
c.IPv6PrefixLen = 0
|
||||
c.IPv6Interface = ""
|
||||
c.IPv6Addresses = assignments
|
||||
c.NormalizeNetworkAssignments()
|
||||
config.SaveConfig()
|
||||
|
||||
if err := m.applyIPv6Config(c.LxcName(), c.IPv6AddressStrings()...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
|
||||
if _, err := os.Stat(rootfsPath); err == nil {
|
||||
if len(c.IPv6Addresses) == 0 {
|
||||
if err := removeContainerIPv6Init(rootfsPath); err != nil {
|
||||
fmt.Printf("Warning: failed to remove IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
} else if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||
}
|
||||
}
|
||||
status, _ := m.GetContainerStatus(c.LxcName())
|
||||
if status == "running" {
|
||||
m.removeGuestIPv6Addresses(c.LxcName(), oldAssignments)
|
||||
}
|
||||
if len(c.IPv6Addresses) > 0 {
|
||||
if err := m.ApplyIPv6(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if status == "running" {
|
||||
m.removeGuestIPv6DefaultRoute(c.LxcName())
|
||||
if err := ApplyFirewallRules(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 removal for %s: %v\n", c.Name, err)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
|
||||
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
||||
data, err := os.ReadFile(configFile)
|
||||
@@ -1610,6 +1682,9 @@ func (m *Manager) ApplyIPv6(id int) error {
|
||||
ensureIPv6NAT66(assignment.Address, uplink)
|
||||
}
|
||||
}
|
||||
if err := ApplyFirewallRules(c.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 setup for %s: %v\n", c.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1699,6 +1774,25 @@ exit 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeContainerIPv6Init(rootfsPath string) error {
|
||||
paths := []string{
|
||||
filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init"),
|
||||
filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service"),
|
||||
filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants", "clicd-ipv6.service"),
|
||||
filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6"),
|
||||
filepath.Join(rootfsPath, "etc", "runlevels", "default", "clicd-ipv6"),
|
||||
}
|
||||
for _, level := range []string{"2", "3", "4", "5"} {
|
||||
paths = append(paths, filepath.Join(rootfsPath, "etc", "rc"+level+".d", "S99clicd-ipv6"))
|
||||
}
|
||||
for _, path := range paths {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installContainerIPv6Systemd(rootfsPath string) error {
|
||||
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
||||
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
||||
@@ -1867,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) removeGuestIPv6Addresses(lxcName string, assignments []config.IPv6Assignment) {
|
||||
addrs := ipv6AssignmentAddresses(assignments)
|
||||
if len(addrs) == 0 {
|
||||
return
|
||||
}
|
||||
quoted := shellQuotedIPv6List(addrs)
|
||||
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
||||
fmt.Sprintf("for ip in %s; do ip -6 addr del \"$ip/128\" dev eth0 2>/dev/null || true; done", quoted)).Run()
|
||||
}
|
||||
|
||||
func (m *Manager) removeGuestIPv6DefaultRoute(lxcName string) {
|
||||
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
||||
fmt.Sprintf("ip -6 route del default via %s dev eth0 2>/dev/null || true", shellQuote(ipv6GatewayLinkLocal))).Run()
|
||||
}
|
||||
|
||||
func ensureIPv6NAT66(ipv6, uplink string) {
|
||||
if ipv6 == "" || uplink == "" {
|
||||
return
|
||||
|
||||
+839
-117
File diff suppressed because it is too large
Load Diff
@@ -88,3 +88,96 @@ 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 TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
|
||||
for _, line := range managedPrlimitLines() {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "lxc.prlimit.nproc") {
|
||||
t.Fatalf("managed prlimit lines must not set nproc: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootfsHasSSHD(t *testing.T) {
|
||||
rootfs := t.TempDir()
|
||||
if rootfsHasSSHD(rootfs) {
|
||||
t.Fatal("empty rootfs unexpectedly reports sshd")
|
||||
}
|
||||
sshd := filepath.Join(rootfs, "usr", "sbin", "sshd")
|
||||
if err := os.MkdirAll(filepath.Dir(sshd), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sshd, []byte("#!/bin/sh\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rootfsHasSSHD(rootfs) {
|
||||
t.Fatal("executable sshd was not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
storageContainer := filepath.Join(base, "storage", "ct-1")
|
||||
rootfs := filepath.Join(storageContainer, "rootfs")
|
||||
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lxcPath := filepath.Join(base, "lxc")
|
||||
if err := os.MkdirAll(lxcPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
containerLink := filepath.Join(lxcPath, "ct-1")
|
||||
if err := os.Symlink(storageContainer, containerLink); err != nil {
|
||||
t.Skipf("directory symlinks are unavailable: %v", err)
|
||||
}
|
||||
|
||||
linkedRootfs := filepath.Join(containerLink, "rootfs")
|
||||
if !sameFilesystemPath(rootfs, linkedRootfs) {
|
||||
t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+342
-54
@@ -67,6 +67,10 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
||||
|
||||
applyIPv4EgressPolicy(c, bridge, subnet, tag)
|
||||
|
||||
if err := ApplyFirewallRules(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -248,6 +252,7 @@ func EnsureForwardRules(bridge string) {
|
||||
if bridge == "" {
|
||||
bridge = "lxcbr0"
|
||||
}
|
||||
ensureLibvirtForwardRules(bridge)
|
||||
rules := [][]string{
|
||||
{"-i", bridge, "-j", "ACCEPT"},
|
||||
{"-o", bridge, "-j", "ACCEPT"},
|
||||
@@ -260,7 +265,34 @@ func EnsureForwardRules(bridge string) {
|
||||
break
|
||||
}
|
||||
}
|
||||
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
|
||||
appendArgs := append([]string{"-A", "FORWARD"}, args...)
|
||||
exec.Command("iptables", appendArgs...).Run()
|
||||
}
|
||||
}
|
||||
|
||||
func ensureLibvirtForwardRules(bridge string) {
|
||||
if bridge != "virbr0" || exec.Command("iptables", "-L", "LIBVIRT_FWI", "-n").Run() != nil {
|
||||
return
|
||||
}
|
||||
rules := []struct {
|
||||
chain string
|
||||
args []string
|
||||
}{
|
||||
{chain: "LIBVIRT_FWI", args: []string{"-o", bridge, "-j", "ACCEPT"}},
|
||||
{chain: "LIBVIRT_FWO", args: []string{"-i", bridge, "-j", "ACCEPT"}},
|
||||
{chain: "LIBVIRT_FWX", args: []string{"-i", bridge, "-o", bridge, "-j", "ACCEPT"}},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if exec.Command("iptables", "-L", rule.chain, "-n").Run() != nil {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
deleteArgs := append([]string{"-D", rule.chain}, rule.args...)
|
||||
if exec.Command("iptables", deleteArgs...).Run() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
insertArgs := append([]string{"-I", rule.chain, "1"}, rule.args...)
|
||||
exec.Command("iptables", insertArgs...).Run()
|
||||
}
|
||||
}
|
||||
@@ -367,6 +399,64 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||
c := config.FindContainer(id)
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("container not found: %d", id)
|
||||
}
|
||||
if c.UsesLANIPv4() {
|
||||
return nil, fmt.Errorf("public IPv4 cannot be assigned while LAN IPv4 mode is enabled")
|
||||
}
|
||||
|
||||
assignments := []config.PublicIPv4Assignment{}
|
||||
if auto || len(requested) > 0 {
|
||||
allocated, err := AllocatePublicIPv4Assignments(id, requested, count, auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assignments = allocated
|
||||
}
|
||||
|
||||
c.PublicIPv4s = assignments
|
||||
reconcilePortMappingHostIPs(c)
|
||||
c.NormalizeNetworkAssignments()
|
||||
config.SaveConfig()
|
||||
|
||||
_ = m.CleanPortMappings(id)
|
||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||
if c.Status == "running" && c.IP != "" {
|
||||
if err := m.ApplyPortMappings(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func reconcilePortMappingHostIPs(c *config.Container) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
assigned := map[string]bool{}
|
||||
for _, item := range c.PublicIPv4s {
|
||||
if addr := strings.TrimSpace(item.Address); addr != "" {
|
||||
assigned[addr] = true
|
||||
}
|
||||
}
|
||||
replacement := ""
|
||||
if len(assigned) == 1 {
|
||||
for addr := range assigned {
|
||||
replacement = addr
|
||||
}
|
||||
}
|
||||
for i := range c.PortMappings {
|
||||
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
|
||||
if hostIP == "" || assigned[hostIP] {
|
||||
continue
|
||||
}
|
||||
c.PortMappings[i].HostIP = replacement
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
|
||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||
return pm, fmt.Errorf("container port must be 1-65535")
|
||||
@@ -391,6 +481,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 {
|
||||
@@ -440,16 +534,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
|
||||
}
|
||||
@@ -576,6 +666,9 @@ func CleanFirewallRules(id int) {
|
||||
cmd := exec.Command("bash", "-c",
|
||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
||||
cmd.CombinedOutput()
|
||||
cmd = exec.Command("bash", "-c",
|
||||
fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do ip6tables $rule; done", tag))
|
||||
cmd.CombinedOutput()
|
||||
|
||||
// Also remove legacy default policy rules (without specific rule ID)
|
||||
for _, suffix := range []string{"default-in", "default-out"} {
|
||||
@@ -584,6 +677,9 @@ func CleanFirewallRules(id int) {
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
|
||||
).CombinedOutput()
|
||||
}
|
||||
exec.Command("ip6tables", "-D", "FORWARD",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s", tag, suffix),
|
||||
).CombinedOutput()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,42 +702,140 @@ func ApplyFirewallRules(id int) error {
|
||||
if c.IsKVM() {
|
||||
bridge = "virbr0"
|
||||
}
|
||||
containerIP := c.IP
|
||||
if containerIP == "" {
|
||||
containerIP := strings.TrimSpace(c.IP)
|
||||
containerIPv6s := firewallIPv6Addresses(c)
|
||||
if containerIP == "" && len(containerIPv6s) == 0 {
|
||||
return nil
|
||||
}
|
||||
tag := clicdTag(id)
|
||||
|
||||
// Apply default DROP policy first (inserted at position 1).
|
||||
// Then insert ACCEPT rules (also at position 1), which pushes the DROPs down.
|
||||
// Final order: ACCEPT rules on top, DROP defaults below, bridge ACCEPT rules at the bottom.
|
||||
applyDefaultFirewallPolicy(tag, bridge, containerIP)
|
||||
defaultAction := normalizeFirewallDefaultAction(c.FirewallDefaultAction)
|
||||
if defaultAction == "DROP" {
|
||||
if containerIP != "" {
|
||||
if err := applyDefaultFirewallPolicy(tag, bridge, containerIP); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := applyDefaultFirewallIPv6Policy(tag, bridge, containerIPv6s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range c.FirewallRules {
|
||||
for i := len(c.FirewallRules) - 1; i >= 0; i-- {
|
||||
rule := c.FirewallRules[i]
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
|
||||
fmt.Printf("Warning: failed to apply firewall rule %s for container %d: %v\n", rule.ID, id, err)
|
||||
if containerIP != "" && firewallRuleAppliesToFamily(rule, true) {
|
||||
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
|
||||
return fmt.Errorf("failed to apply firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||
}
|
||||
}
|
||||
if len(containerIPv6s) > 0 && firewallRuleAppliesToFamily(rule, false) {
|
||||
if err := applyOneFirewallIPv6Rule(tag, bridge, containerIPv6s, rule); err != nil {
|
||||
return fmt.Errorf("failed to apply IPv6 firewall rule %s for container %d: %w", rule.ID, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeFirewallDefaultAction(action string) string {
|
||||
action = strings.ToUpper(strings.TrimSpace(action))
|
||||
if action == "ACCEPT" {
|
||||
return "ACCEPT"
|
||||
}
|
||||
return "DROP"
|
||||
}
|
||||
|
||||
func normalizeFirewallNetwork(network string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
switch network {
|
||||
case "", "ipv4", "nat4":
|
||||
return "ipv4"
|
||||
case "ipv6":
|
||||
return "ipv6"
|
||||
case "all", "both":
|
||||
return "all"
|
||||
default:
|
||||
return "ipv4"
|
||||
}
|
||||
}
|
||||
|
||||
func firewallRuleAppliesToFamily(rule config.FirewallRule, ipv4 bool) bool {
|
||||
network := normalizeFirewallNetwork(rule.Network)
|
||||
if network == "ipv4" {
|
||||
return ipv4
|
||||
}
|
||||
if network == "ipv6" {
|
||||
return !ipv4
|
||||
}
|
||||
if rule.SourceIP == "" {
|
||||
return true
|
||||
}
|
||||
addr := firewallIPSpecAddr(rule.SourceIP)
|
||||
if !addr.IsValid() {
|
||||
return true
|
||||
}
|
||||
if ipv4 {
|
||||
return addr.Is4()
|
||||
}
|
||||
return addr.Is6() && !addr.Is4In6()
|
||||
}
|
||||
|
||||
func firewallIPSpecAddr(value string) netip.Addr {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return netip.Addr{}
|
||||
}
|
||||
if strings.Contains(value, "/") {
|
||||
prefix, err := netip.ParsePrefix(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return prefix.Addr()
|
||||
}
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func firewallIPv6Addresses(c *config.Container) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.NormalizeNetworkAssignments()
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, assignment := range c.IPv6Addresses {
|
||||
ip := strings.TrimSpace(assignment.Address)
|
||||
if ip == "" || seen[ip] {
|
||||
continue
|
||||
}
|
||||
if addr, err := netip.ParseAddr(ip); err == nil && addr.Is6() && !addr.Is4In6() {
|
||||
seen[ip] = true
|
||||
result = append(result, ip)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error {
|
||||
commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID)
|
||||
|
||||
// Build base iptables args
|
||||
args := []string{"-I", "FORWARD", "1"}
|
||||
|
||||
// Direction: in = traffic arriving at container (-i bridge -d containerIP)
|
||||
// out = traffic leaving container (-o bridge -s containerIP)
|
||||
// Direction: in = traffic arriving at container (-o bridge -d containerIP)
|
||||
// out = traffic leaving container (-i bridge -s containerIP)
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-i", bridge, "-d", containerIP+"/32")
|
||||
args = append(args, "-o", bridge, "-d", containerIP+"/32")
|
||||
case "out":
|
||||
args = append(args, "-o", bridge, "-s", containerIP+"/32")
|
||||
args = append(args, "-i", bridge, "-s", containerIP+"/32")
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||
}
|
||||
@@ -662,7 +856,7 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR
|
||||
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||
// For "in" direction, traffic going TO the container uses --dport
|
||||
// For "out" direction, traffic going FROM the container uses --dport (destination port on remote)
|
||||
args = append(args, "--dport", normalizePortSpec(rule.Port))
|
||||
args = append(args, firewallPortArgs(rule.Port)...)
|
||||
}
|
||||
|
||||
// Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender)
|
||||
@@ -693,51 +887,145 @@ func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallR
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyOneFirewallIPv6Rule(tag, bridge string, containerIPs []string, rule config.FirewallRule) error {
|
||||
for _, containerIP := range containerIPs {
|
||||
commentTag := fmt.Sprintf("clicd-%s-fw-%s-v6-%s", tag, rule.ID, firewallCommentIPTag(containerIP))
|
||||
args := []string{"-I", "FORWARD", "1"}
|
||||
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-o", bridge, "-d", containerIP+"/128")
|
||||
case "out":
|
||||
args = append(args, "-i", bridge, "-s", containerIP+"/128")
|
||||
default:
|
||||
return fmt.Errorf("invalid direction: %s", rule.Direction)
|
||||
}
|
||||
|
||||
switch rule.Protocol {
|
||||
case "tcp", "udp":
|
||||
args = append(args, "-p", rule.Protocol)
|
||||
case "icmp":
|
||||
args = append(args, "-p", "ipv6-icmp")
|
||||
case "all":
|
||||
default:
|
||||
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
|
||||
}
|
||||
|
||||
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
|
||||
args = append(args, firewallPortArgs(rule.Port)...)
|
||||
}
|
||||
|
||||
if rule.SourceIP != "" {
|
||||
switch rule.Direction {
|
||||
case "in":
|
||||
args = append(args, "-s", rule.SourceIP)
|
||||
case "out":
|
||||
args = append(args, "-d", rule.SourceIP)
|
||||
}
|
||||
}
|
||||
|
||||
action := "DROP"
|
||||
if rule.Action == "ACCEPT" {
|
||||
action = "ACCEPT"
|
||||
}
|
||||
args = append(args, "-j", action)
|
||||
args = append(args, "-m", "comment", "--comment", commentTag)
|
||||
|
||||
cmd := exec.Command("ip6tables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip6tables error: %s", string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firewallPortArgs(port string) []string {
|
||||
spec := normalizePortSpec(port)
|
||||
if strings.Contains(spec, ",") {
|
||||
return []string{"-m", "multiport", "--dports", spec}
|
||||
}
|
||||
return []string{"--dport", spec}
|
||||
}
|
||||
|
||||
// normalizePortSpec converts user port input to iptables-compatible port spec.
|
||||
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "22" -> "22"
|
||||
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "80,443,8000-9000" -> "80,443,8000:9000"
|
||||
func normalizePortSpec(port string) string {
|
||||
port = strings.TrimSpace(port)
|
||||
if port == "" {
|
||||
return ""
|
||||
}
|
||||
// Convert comma-separated to iptables format (already valid)
|
||||
// Convert dash range to colon range: "8000-9000" -> "8000:9000"
|
||||
if strings.Contains(port, "-") && !strings.Contains(port, ":") {
|
||||
parts := strings.SplitN(port, "-", 2)
|
||||
if len(parts) == 2 {
|
||||
return strings.TrimSpace(parts[0]) + ":" + strings.TrimSpace(parts[1])
|
||||
parts := strings.Split(port, ",")
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.Contains(part, "-") && !strings.Contains(part, ":") {
|
||||
bounds := strings.SplitN(part, "-", 2)
|
||||
if len(bounds) == 2 {
|
||||
part = strings.TrimSpace(bounds[0]) + ":" + strings.TrimSpace(bounds[1])
|
||||
}
|
||||
}
|
||||
parts[i] = part
|
||||
}
|
||||
return port
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) {
|
||||
// Default DROP: inserted at position 1 so they sit above bridge ACCEPT rules.
|
||||
// The user-defined ACCEPT rules (also at position 1) were inserted first,
|
||||
// so they end up above these DROP defaults after the position-1 insertions.
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
args := []string{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-d", containerIP + "/32",
|
||||
"-p", proto,
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-%s", tag, proto),
|
||||
}
|
||||
cmd := exec.Command("iptables", args...)
|
||||
cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
args := []string{
|
||||
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) error {
|
||||
defaults := [][]string{
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-o", bridge,
|
||||
"-s", containerIP + "/32",
|
||||
"-p", proto,
|
||||
"-d", containerIP + "/32",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-%s", tag, proto),
|
||||
}
|
||||
cmd := exec.Command("iptables", args...)
|
||||
cmd.CombinedOutput()
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in", tag),
|
||||
},
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-s", containerIP + "/32",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out", tag),
|
||||
},
|
||||
}
|
||||
for _, args := range defaults {
|
||||
cmd := exec.Command("iptables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("iptables default firewall error: %s", string(output))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDefaultFirewallIPv6Policy(tag, bridge string, containerIPs []string) error {
|
||||
for _, containerIP := range containerIPs {
|
||||
defaults := [][]string{
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-o", bridge,
|
||||
"-d", containerIP + "/128",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||
},
|
||||
{
|
||||
"-I", "FORWARD", "1",
|
||||
"-i", bridge,
|
||||
"-s", containerIP + "/128",
|
||||
"-j", "DROP",
|
||||
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-v6-%s", tag, firewallCommentIPTag(containerIP)),
|
||||
},
|
||||
}
|
||||
for _, args := range defaults {
|
||||
cmd := exec.Command("ip6tables", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ip6tables default firewall error: %s", string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firewallCommentIPTag(ip string) string {
|
||||
replacer := strings.NewReplacer(":", "_", ".", "_", "/", "_")
|
||||
return replacer.Replace(ip)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
var snapshotMu sync.Mutex
|
||||
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
|
||||
snapshotMu.Lock()
|
||||
defer snapshotMu.Unlock()
|
||||
|
||||
@@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
|
||||
if _, err := os.Stat(containerDir); err != nil {
|
||||
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
||||
}
|
||||
pool, err := config.SelectStoragePoolForContent(
|
||||
config.StorageContentSnapshots,
|
||||
firstString(storagePoolID),
|
||||
dirSizeBytes(containerDir),
|
||||
)
|
||||
if err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
||||
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
|
||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
||||
baseDir := filepath.Join(pool.Path, "snapshots")
|
||||
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
|
||||
if err := safePathUnder(snapshotDir, baseDir); err != nil {
|
||||
return config.Snapshot{}, err
|
||||
}
|
||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
||||
@@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
|
||||
|
||||
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||
if snapshot.Path != "" {
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
||||
if snapshot.Path == "" {
|
||||
return fmt.Errorf("snapshot path is empty")
|
||||
}
|
||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
||||
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
|
||||
}
|
||||
|
||||
func snapshotBaseDir() string {
|
||||
return filepath.Join(config.AppConfig.DataDir, "snapshots")
|
||||
return snapshotBaseDirForPool("")
|
||||
}
|
||||
|
||||
func snapshotBaseDirForPool(poolID string) string {
|
||||
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
|
||||
return filepath.Join(pool.Path, "snapshots")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func safeSnapshotPath(path string) error {
|
||||
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
|
||||
return nil
|
||||
}
|
||||
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
|
||||
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unsafe snapshot path: %s", path)
|
||||
}
|
||||
|
||||
func firstString(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(values[0])
|
||||
}
|
||||
|
||||
func copyTree(src string, dst string) error {
|
||||
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
|
||||
}
|
||||
|
||||
func dirSizeBytes(path string) int64 {
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = resolved
|
||||
}
|
||||
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package lxc
|
||||
|
||||
import "runtime"
|
||||
|
||||
// Template represents an LXC image template
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
@@ -13,55 +15,70 @@ 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-trixie", Name: "Debian 13",
|
||||
Distro: "debian", Release: "trixie", Arch: arch,
|
||||
Description: "Debian 13 (Trixie)",
|
||||
},
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -61,13 +61,16 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/host-history", corsMiddleware(api.AdminMiddleware(api.HandleHostHistory)))
|
||||
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
|
||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
||||
mux.HandleFunc("/api/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
|
||||
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
||||
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
|
||||
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
||||
@@ -104,13 +107,16 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
|
||||
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||
mux.HandleFunc("/api/v1/host-history", corsMiddleware(api.AuthMiddleware(api.HandleHostHistory)))
|
||||
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||
mux.HandleFunc("/api/v1/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
|
||||
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
||||
mux.HandleFunc("/api/v1/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
|
||||
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||
@@ -174,6 +180,8 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
func Run() error {
|
||||
// Use embedded frontend files
|
||||
webFS = GetEmbeddedFS()
|
||||
api.StartHostMetricSampler()
|
||||
api.StartContainerMetricSampler()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
setupRoutes(mux)
|
||||
|
||||
@@ -1 +1 @@
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.17"
|
||||
Version = "1.1.26"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"clicd/internal/api"
|
||||
"clicd/internal/cli"
|
||||
@@ -16,6 +19,8 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var shutdownCaptureOnce sync.Once
|
||||
|
||||
func main() {
|
||||
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
|
||||
|
||||
@@ -44,7 +49,10 @@ func main() {
|
||||
_ = cfg
|
||||
|
||||
if isServerMode || (!isTerminal && !isCliMode) {
|
||||
installShutdownStateCapture()
|
||||
|
||||
// Restore persisted state
|
||||
api.ConfigureTaskQueue(cfg.TaskConcurrency)
|
||||
api.RestoreTasks()
|
||||
api.RestoreLoginLogs()
|
||||
|
||||
@@ -75,6 +83,7 @@ func main() {
|
||||
|
||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||
config.CleanStaleContainers()
|
||||
api.StartHostBootRestore()
|
||||
lxc.EnsureAllRunningPortMappings()
|
||||
|
||||
// Pre-warm SSH for containers already running after host boot or service restart.
|
||||
@@ -97,6 +106,17 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func installShutdownStateCapture() {
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-signals
|
||||
fmt.Fprintf(os.Stderr, "Received %s, capturing workload restore state...\n", sig)
|
||||
shutdownCaptureOnce.Do(api.CaptureRuntimeRestoreState)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
func isWebPanelSystemdRunning() bool {
|
||||
cmd := exec.Command("systemctl", "is-active", "clicd")
|
||||
output, err := cmd.Output()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -110,6 +110,13 @@ export default defineConfig({
|
||||
head: [
|
||||
['link', { rel: 'icon', href: '/favicon.svg' }],
|
||||
],
|
||||
vite: {
|
||||
esbuild: {
|
||||
supported: {
|
||||
destructuring: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
locales: {
|
||||
root: {
|
||||
label: '简体中文',
|
||||
|
||||
@@ -30,6 +30,25 @@ bash build.sh
|
||||
|
||||
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
|
||||
|
||||
默认目标为 Linux amd64。需要构建 ARM64 包时可以指定:
|
||||
|
||||
```bash
|
||||
CLICD_GOARCH=arm64 bash build.sh
|
||||
```
|
||||
|
||||
需要同时构建 amd64 和 arm64 发布包时:
|
||||
|
||||
```bash
|
||||
CLICD_GOARCH=all bash build.sh
|
||||
```
|
||||
|
||||
构建完成后会生成:
|
||||
|
||||
- `dist/clicd-linux-amd64`
|
||||
- `dist/clicd-linux-amd64.tar.gz`
|
||||
- `dist/clicd-linux-arm64`
|
||||
- `dist/clicd-linux-arm64.tar.gz`
|
||||
|
||||
## 文档站构建
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
|
||||
|
||||
## Release 产物
|
||||
|
||||
安装脚本会优先下载 Linux AMD64 产物:
|
||||
安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64.tar.gz
|
||||
clicd-linux-arm64.tar.gz
|
||||
```
|
||||
|
||||
在部分场景中也会尝试下载单独二进制:
|
||||
|
||||
```text
|
||||
clicd-linux-amd64
|
||||
clicd-linux-arm64
|
||||
```
|
||||
|
||||
## 安装脚本行为
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Linux x86_64 宿主机。
|
||||
- Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
|
||||
- root 权限。
|
||||
- systemd。
|
||||
- 网络可访问 GitHub Release 下载地址。
|
||||
@@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`。
|
||||
脚本当前默认使用 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 或 `clicd-linux-arm64.tar.gz`。
|
||||
|
||||
## 安装指定版本
|
||||
|
||||
|
||||
@@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿
|
||||
|
||||
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
|
||||
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
|
||||
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。
|
||||
- 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 安装脚本默认安装哪个版本?
|
||||
|
||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
|
||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
|
||||
|
||||
## 可以固定安装某个版本吗?
|
||||
|
||||
|
||||
Generated
+110
-110
@@ -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": {
|
||||
@@ -2475,9 +2475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@
|
||||
"vitepress": "^1.6.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "6.4.2"
|
||||
"vite": "6.4.3",
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+11
-11
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.25",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.18.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -957,9 +957,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
@@ -1372,16 +1372,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.17",
|
||||
"version": "1.1.26",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,7 +12,7 @@
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.18.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -13,6 +13,7 @@ import Settings from './pages/Settings'
|
||||
import ImageManagement from './pages/ImageManagement'
|
||||
import Snapshots from './pages/Snapshots'
|
||||
import Routing from './pages/Routing'
|
||||
import Storage from './pages/Storage'
|
||||
import SubUserManagement from './pages/SubUserManagement'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
@@ -63,6 +64,7 @@ function App() {
|
||||
<Route path="security" element={<Security />} />
|
||||
<Route path="snapshots" element={<Snapshots />} />
|
||||
<Route path="routing" element={<Routing />} />
|
||||
<Route path="storage" element={<Storage />} />
|
||||
<Route path="audit-logs" element={<AuditLogs />} />
|
||||
<Route path="api-integration" element={<ApiIntegration />} />
|
||||
<Route path="host-report" element={<HostReport />} />
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useDialog } from './Dialog'
|
||||
|
||||
export default function BrowserDialogTranslator() {
|
||||
const { t } = useLanguage()
|
||||
const { alert: showAlert } = useDialog()
|
||||
|
||||
useEffect(() => {
|
||||
const originalAlert = window.alert
|
||||
const originalConfirm = window.confirm
|
||||
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
|
||||
window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) }
|
||||
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||
return () => {
|
||||
window.alert = originalAlert
|
||||
window.confirm = originalConfirm
|
||||
}
|
||||
}, [t])
|
||||
}, [showAlert, t])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>{container.network_bw_mbps} Mbps</span>
|
||||
<span>{formatNetworkLimit(container)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,3 +140,10 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatNetworkLimit(container: { network_bw_mbps?: number; network_down_mbps?: number; network_up_mbps?: number }) {
|
||||
const down = Math.max(0, Number(container.network_down_mbps || container.network_bw_mbps || 0))
|
||||
const up = Math.max(0, Number(container.network_up_mbps || container.network_bw_mbps || 0))
|
||||
if (down === 0 && up === 0) return '不限速'
|
||||
return `下 ${down || '不限'} / 上 ${up || '不限'} Mbps`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { CalendarClock, RefreshCw, X } from 'lucide-react'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, getStorageInfo, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, StorageInfo, Template } from '../services/api'
|
||||
import { useDialog } from './Dialog'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||
@@ -16,19 +17,29 @@ const defaultForm: CreateContainerRequest = {
|
||||
name: '',
|
||||
virtualization: 'lxc',
|
||||
template_id: '',
|
||||
storage_pool_id: '',
|
||||
vcpu: 1,
|
||||
cpu_percent: 100,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 0,
|
||||
network_up_mbps: 0,
|
||||
monthly_traffic_gb: 0,
|
||||
traffic_mode: 'total',
|
||||
traffic_in_gb: 0,
|
||||
traffic_out_gb: 0,
|
||||
io_speed_mbps: 0,
|
||||
io_read_mbps: 0,
|
||||
io_write_mbps: 0,
|
||||
extra_ports: [],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
lan_ipv4_mode: '',
|
||||
lan_interface: '',
|
||||
lan_ipv4_address: '',
|
||||
lan_ipv4_prefix_len: 24,
|
||||
lan_ipv4_gateway: '',
|
||||
snapshot_limit: 1,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
@@ -39,10 +50,13 @@ const defaultForm: CreateContainerRequest = {
|
||||
ssh_auth_mode: 'auto_password',
|
||||
ssh_password: '',
|
||||
ssh_public_key: '',
|
||||
allowed_image_ids: [],
|
||||
image_limit_configured: false,
|
||||
expires_at: '',
|
||||
}
|
||||
|
||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const { language } = useLanguage()
|
||||
const networkText = createNetworkText[language]
|
||||
@@ -51,6 +65,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(true)
|
||||
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
||||
const [nameError, setNameError] = useState('')
|
||||
|
||||
@@ -63,7 +80,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
setTemplates(data)
|
||||
setForm((prev) => {
|
||||
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
||||
return applyTemplateDefaults({ ...prev, template_id: templateID })
|
||||
const allowed = new Set(data.map((item) => item.id))
|
||||
const selectedAllowedIDs = (prev.allowed_image_ids || []).filter((id) => allowed.has(id))
|
||||
return applyTemplateDefaults({
|
||||
...prev,
|
||||
template_id: templateID,
|
||||
allowed_image_ids: prev.image_limit_configured ? selectedAllowedIDs : (templateID ? [templateID] : []),
|
||||
image_limit_configured: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
@@ -84,8 +108,30 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
getHostInfo()
|
||||
.then((res) => setHostInfo(res.data.data || null))
|
||||
.catch(() => setHostInfo(null))
|
||||
|
||||
getHostReport()
|
||||
.then((res) => setHostReport(res.data.data || null))
|
||||
.catch(() => setHostReport(null))
|
||||
|
||||
}, [isOpen, form.virtualization])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
let active = true
|
||||
setStorageLoading(true)
|
||||
getStorageInfo()
|
||||
.then((res) => {
|
||||
if (active) setStorageInfo(res.data.data || null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setStorageInfo(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setStorageLoading(false)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [isOpen])
|
||||
|
||||
const ipv6Available = !!ipv6Status?.available
|
||||
const ipv6Prefixes = ipv6Status?.prefixes || []
|
||||
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
|
||||
@@ -94,10 +140,27 @@ 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
|
||||
const storagePools = useMemo(() => {
|
||||
const content = form.virtualization === 'kvm' ? 'kvm' : 'lxc'
|
||||
return (storageInfo?.pools || []).filter((pool) => pool.enabled && pool.available !== false && (pool.content_types || []).includes(content))
|
||||
}, [storageInfo, form.virtualization])
|
||||
const storageReady = storagePools.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))
|
||||
}
|
||||
}, [hostInfo, kvmAvailable, form.virtualization])
|
||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||
const natEnabled = form.assign_nat !== false
|
||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
||||
const lanIPv4Enabled = form.lan_ipv4_mode === 'dhcp' || form.lan_ipv4_mode === 'static'
|
||||
const lanStaticEnabled = form.lan_ipv4_mode === 'static'
|
||||
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
|
||||
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
|
||||
const defaultLANInterface = lanInterfaces[0]?.name || ''
|
||||
const customNATPorts = form.extra_ports || []
|
||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2, customNATPorts.length + 1) : 0
|
||||
const linuxTemplate = !isWindowsTemplate(form.template_id)
|
||||
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
||||
|
||||
@@ -106,6 +169,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const count = natPortCount
|
||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||
}, [natEnabled, natPortCount])
|
||||
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
|
||||
|
||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||
const sshPortPreview = 22000
|
||||
@@ -149,11 +213,23 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
|
||||
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false && form.lan_ipv4_mode !== 'dhcp' && form.lan_ipv4_mode !== 'static') {
|
||||
dialog.alert('提示', '请勾选任意一个可用网络')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.lan_ipv4_mode === 'static') {
|
||||
if (!isIPv4Address(form.lan_ipv4_address || '') || !isIPv4Address(form.lan_ipv4_gateway || '') || !form.lan_ipv4_prefix_len) {
|
||||
dialog.alert('局域网 IPv4 配置有误', '请填写有效的 IPv4 地址、子网掩码和网关')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!storageReady) {
|
||||
dialog.alert('未配置存储', `请先在存储管理中为 ${form.virtualization === 'kvm' ? 'KVM 磁盘' : 'LXC 容器'}开启至少一块存储磁盘`)
|
||||
return
|
||||
}
|
||||
|
||||
const authError = validateSSHAuthInputs(form)
|
||||
if (authError) {
|
||||
dialog.alert('登录方式有误', authError)
|
||||
@@ -172,11 +248,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
...boundedForm,
|
||||
name,
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0,
|
||||
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2, (boundedForm.extra_ports || []).length + 1) : 0,
|
||||
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
|
||||
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
|
||||
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
|
||||
extra_ports: [],
|
||||
extra_ports: wantsNAT ? (boundedForm.extra_ports || []) : [],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,7 +262,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
await onSuccess(containers)
|
||||
onClose()
|
||||
setBatchCount(1)
|
||||
setForm({ ...defaultForm, template_id: templates[0]?.id || '' })
|
||||
setForm({ ...defaultForm, template_id: templates[0]?.id || '', allowed_image_ids: templates[0]?.id ? [templates[0].id] : [], image_limit_configured: true })
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
|
||||
@@ -199,7 +275,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-3xl max-h-[92vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-black">创建新容器</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
|
||||
@@ -207,8 +283,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="容器名称">
|
||||
<input
|
||||
type="text"
|
||||
@@ -230,15 +306,21 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
LXC 容器
|
||||
</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: '', storage_pool_id: '', allowed_image_ids: [], image_limit_configured: false }))
|
||||
}
|
||||
}}
|
||||
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>
|
||||
@@ -253,7 +335,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
) : (
|
||||
<select
|
||||
value={form.template_id}
|
||||
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
||||
onChange={(event) => {
|
||||
const templateID = event.target.value
|
||||
const allowed = new Set(form.allowed_image_ids || [])
|
||||
if (templateID) allowed.add(templateID)
|
||||
setForm(applyTemplateDefaults({ ...form, template_id: templateID, allowed_image_ids: Array.from(allowed), image_limit_configured: true }))
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
{templates.map((template) => (
|
||||
@@ -266,6 +353,71 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
</Field>
|
||||
|
||||
<Field label="存储磁盘">
|
||||
{storageLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
) : storagePools.length > 0 ? (
|
||||
<select
|
||||
value={form.storage_pool_id || ''}
|
||||
onChange={(event) => setForm({ ...form, storage_pool_id: event.target.value })}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">自动选择(默认盘优先,空间不足自动切换)</option>
|
||||
{storagePools.map((pool) => (
|
||||
<option key={pool.id} value={pool.id}>
|
||||
{pool.name} · {pool.mount_point || pool.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
<span>尚未开启{form.virtualization === 'kvm' ? ' KVM 磁盘' : ' LXC 容器'}存储,当前无法创建。</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClose(); navigate('/storage') }}
|
||||
className="shrink-0 rounded-md border border-amber-300 bg-white px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100"
|
||||
>
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<Field label="子用户可用镜像">
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs text-gray-500">默认勾选当前系统;取消后,子用户也不能重装该系统。</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{templates.map((template) => {
|
||||
const checked = (form.allowed_image_ids || []).includes(template.id)
|
||||
const current = template.id === form.template_id
|
||||
return (
|
||||
<label key={template.id} className={`flex cursor-pointer items-start gap-2 rounded border px-2.5 py-2 text-xs ${checked ? 'border-black bg-white' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const currentIDs = form.allowed_image_ids || []
|
||||
const next = checked ? currentIDs.filter((id) => id !== template.id) : [...currentIDs, template.id]
|
||||
setForm({ ...form, allowed_image_ids: next, image_limit_configured: true })
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-gray-800">{template.name}{current ? '(当前系统)' : ''}</span>
|
||||
<span className="block text-gray-500">{template.arch} · {template.distro} {template.release}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{linuxTemplate && (
|
||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
||||
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
||||
@@ -315,6 +467,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
@@ -325,7 +478,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
...form,
|
||||
assign_ipv4: event.target.checked,
|
||||
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
|
||||
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [] } : {}),
|
||||
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [], lan_ipv4_mode: '', lan_interface: '' } : {}),
|
||||
})}
|
||||
className="mt-1"
|
||||
/>
|
||||
@@ -391,6 +544,98 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${form.virtualization === 'lxc' && lanInterfaces.length > 0 ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lanIPv4Enabled}
|
||||
disabled={form.virtualization !== 'lxc' || lanInterfaces.length === 0}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked
|
||||
setForm({
|
||||
...form,
|
||||
lan_ipv4_mode: checked ? 'dhcp' : '',
|
||||
lan_interface: checked ? (form.lan_interface || defaultLANInterface) : '',
|
||||
assign_nat: checked ? false : form.assign_nat,
|
||||
port_mapping_count: checked ? 0 : form.port_mapping_count,
|
||||
extra_ports: checked ? [] : form.extra_ports,
|
||||
assign_ipv4: checked ? false : form.assign_ipv4,
|
||||
public_ipv4s: checked ? [] : form.public_ipv4s,
|
||||
ipv4_count: checked ? 0 : form.ipv4_count,
|
||||
})
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">局域网 DHCP</span>
|
||||
<span className="block text-xs text-gray-500">
|
||||
{lanInterfaces.length > 0 ? 'macvlan 独立局域网 IP' : '未检测到可用上联网卡'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{lanIPv4Enabled && (
|
||||
<select
|
||||
value={form.lan_interface || defaultLANInterface}
|
||||
onChange={(event) => setForm({ ...form, lan_interface: event.target.value })}
|
||||
className="h-9 w-32 shrink-0 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
|
||||
>
|
||||
{lanInterfaces.map((item) => (
|
||||
<option key={item.name} value={item.name}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{lanIPv4Enabled && (
|
||||
<div className="mt-3 space-y-3 pl-6">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, lan_ipv4_mode: 'dhcp' })}
|
||||
className={`rounded-md border px-3 py-2 text-xs font-medium ${form.lan_ipv4_mode === 'dhcp' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
DHCP 自动获取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, lan_ipv4_mode: 'static' })}
|
||||
className={`rounded-md border px-3 py-2 text-xs font-medium ${lanStaticEnabled ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
手动配置
|
||||
</button>
|
||||
</div>
|
||||
{lanStaticEnabled && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label="IPv4 地址">
|
||||
<input
|
||||
value={form.lan_ipv4_address || ''}
|
||||
onChange={(event) => setForm({ ...form, lan_ipv4_address: event.target.value })}
|
||||
className={inputClass}
|
||||
placeholder="192.168.2.250"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="子网掩码">
|
||||
<input
|
||||
value={subnetMaskFromPrefixLen(form.lan_ipv4_prefix_len || 24)}
|
||||
onChange={(event) => setForm({ ...form, lan_ipv4_prefix_len: prefixLenFromSubnetMask(event.target.value) || 24 })}
|
||||
className={inputClass}
|
||||
placeholder="255.255.255.0"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="网关">
|
||||
<input
|
||||
value={form.lan_ipv4_gateway || ''}
|
||||
onChange={(event) => setForm({ ...form, lan_ipv4_gateway: event.target.value })}
|
||||
className={inputClass}
|
||||
placeholder="192.168.2.202"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
@@ -404,7 +649,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>
|
||||
@@ -419,6 +664,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{form.assign_ipv6 && (
|
||||
<div className="mt-3 space-y-3 pl-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={(form.ipv6_addresses || []).length === 0}
|
||||
onChange={() => setForm({ ...form, ipv6_addresses: [] })}
|
||||
/>
|
||||
Random assign
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={(form.ipv6_addresses || []).length > 0}
|
||||
onChange={() => setForm({ ...form, ipv6_addresses: [''], ipv6_count: 1 })}
|
||||
/>
|
||||
Custom assign
|
||||
</label>
|
||||
</div>
|
||||
{(form.ipv6_addresses || []).length > 0 && (
|
||||
<textarea
|
||||
value={(form.ipv6_addresses || []).join('\n')}
|
||||
onChange={(event) => {
|
||||
const next = splitAddressLines(event.target.value)
|
||||
setForm({ ...form, ipv6_addresses: next.length ? next : [''], ipv6_count: Math.max(1, next.length || 1) })
|
||||
}}
|
||||
className={`${inputClass} min-h-20 font-mono text-xs`}
|
||||
placeholder="2001:db8:100::100"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
|
||||
@@ -434,7 +712,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
assign_nat: checked,
|
||||
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||
extra_ports: [],
|
||||
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0 } : {}),
|
||||
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0, lan_ipv4_mode: '', lan_interface: '' } : {}),
|
||||
})
|
||||
}}
|
||||
className="mt-1"
|
||||
@@ -446,25 +724,57 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{natEnabled && (
|
||||
{natEnabled && customNATPorts.length === 0 && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={natPortCount}
|
||||
min={2}
|
||||
max={64}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
|
||||
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true, extra_ports: [] })}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{natEnabled && (
|
||||
<div className="mt-2 pl-6">
|
||||
<div className="mt-2 space-y-2 pl-6">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={customNATPorts.length === 0}
|
||||
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
|
||||
/>
|
||||
Auto ports
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
checked={customNATPorts.length > 0}
|
||||
onChange={() => {
|
||||
const next = customNATPorts.length > 0 ? customNATPorts : [22002]
|
||||
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
|
||||
}}
|
||||
/>
|
||||
Custom ports
|
||||
</label>
|
||||
</div>
|
||||
{customNATPorts.length > 0 && (
|
||||
<textarea
|
||||
value={customNATPorts.join('\n')}
|
||||
onChange={(event) => {
|
||||
const next = parsePortList(event.target.value)
|
||||
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
|
||||
}}
|
||||
className={`${inputClass} min-h-16 font-mono text-xs`}
|
||||
placeholder={'22002\n8080\n8443'}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
|
||||
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{natPreviewPorts.map((port, index) => (
|
||||
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
@@ -472,8 +782,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Field label="vCPU">
|
||||
<NumberInput
|
||||
value={form.vcpu}
|
||||
@@ -496,9 +807,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
/>
|
||||
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="磁盘 (GB)">
|
||||
<NumberInput
|
||||
value={form.disk_gb}
|
||||
@@ -509,18 +817,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
/>
|
||||
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
|
||||
</Field>
|
||||
<Field label="带宽 (Mbps)">
|
||||
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
|
||||
<Field label="下行带宽 (Mbps)">
|
||||
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
|
||||
</Field>
|
||||
<Field label="IO 速度 (MB/s)">
|
||||
<NumberInput value={form.io_speed_mbps} min={0} onChange={(value) => setForm({ ...form, io_speed_mbps: value })} />
|
||||
<Field label="上行带宽 (Mbps)">
|
||||
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
|
||||
</Field>
|
||||
<Field label="读取 IO (MB/s)">
|
||||
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
|
||||
</Field>
|
||||
<Field label="写入 IO (MB/s)">
|
||||
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Traffic control */}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">月流量</label>
|
||||
<select
|
||||
value={form.traffic_mode}
|
||||
@@ -534,10 +844,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
{form.traffic_mode === 'total' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
|
||||
<span className="text-xs text-gray-400">GB (0=不限制)</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">GB</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="入站 (GB)">
|
||||
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
|
||||
</Field>
|
||||
@@ -547,7 +857,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.snapshot_limit}
|
||||
@@ -556,21 +865,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="到期时间">
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={form.expires_at}
|
||||
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
className={`${inputClass} pl-10`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-4 text-gray-400">不选则长期有效</p>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="到期时间">
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={form.expires_at}
|
||||
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
className={`${inputClass} pl-10`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1.5">不选择则长期有效;选择日期后,到期会自动关机。</p>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
@@ -579,7 +887,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
disabled={loading || storageLoading || !storageReady}
|
||||
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '创建中...' : '创建容器'}
|
||||
@@ -686,10 +994,15 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
||||
|
||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||
const normalized = applyTemplateDefaults(form)
|
||||
const wantsLANDHCP = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'dhcp'
|
||||
const wantsLANStatic = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'static'
|
||||
const wantsLANIPv4 = wantsLANDHCP || wantsLANStatic
|
||||
const wantsIPv4 = !!normalized.assign_ipv4
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
// IPv4 and NAT are mutually exclusive
|
||||
const wantsNAT = wantsIPv4 ? false : normalized.assign_nat !== false
|
||||
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
|
||||
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : []
|
||||
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0
|
||||
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||
return {
|
||||
@@ -698,13 +1011,19 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
assign_nat: wantsNAT,
|
||||
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
||||
port_mapping_count: portMappingCount,
|
||||
extra_ports: extraPorts,
|
||||
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
|
||||
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
|
||||
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
|
||||
lan_ipv4_prefix_len: wantsLANStatic ? clampInt(normalized.lan_ipv4_prefix_len || 24, 1, 32, 24) : 0,
|
||||
lan_ipv4_gateway: wantsLANStatic ? (normalized.lan_ipv4_gateway || '').trim() : '',
|
||||
assign_ipv4: wantsIPv4,
|
||||
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
|
||||
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
||||
assign_ipv6: wantsIPv6,
|
||||
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
|
||||
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
|
||||
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []).map((item) => item.trim()).filter(Boolean) : [],
|
||||
ssh_auth_mode: sshAuthMode,
|
||||
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
|
||||
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
|
||||
@@ -721,6 +1040,16 @@ function validateSSHAuthInputs(form: CreateContainerRequest) {
|
||||
return ''
|
||||
}
|
||||
|
||||
function getLANDHCPInterfaces(report: HostProbeReport | null) {
|
||||
const interfaces = report?.network_interfaces || []
|
||||
return interfaces.filter((item) => {
|
||||
const name = item.name || ''
|
||||
if (!name || name === 'lo') return false
|
||||
if (name.startsWith('lxc') || name.startsWith('docker') || name.startsWith('br-') || name.startsWith('veth') || name.startsWith('virbr') || name.startsWith('clmv-')) return false
|
||||
return (item.state || '').toLowerCase() === 'up'
|
||||
})
|
||||
}
|
||||
|
||||
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||
if (!isWindowsTemplate(form.template_id)) return form
|
||||
return {
|
||||
@@ -746,11 +1075,62 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||
return Math.min(Math.max(next, min), max ?? next)
|
||||
}
|
||||
|
||||
function parsePortList(value: string) {
|
||||
return normalizePortList(
|
||||
value
|
||||
.split(/[\s,,;;]+/)
|
||||
.map((item) => Number(item.trim()))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePortList(ports: number[]) {
|
||||
const seen = new Set<number>()
|
||||
const result: number[] = []
|
||||
for (const port of ports) {
|
||||
if (!Number.isFinite(port)) continue
|
||||
const next = Math.round(port)
|
||||
if (next < 1 || next > 65535 || seen.has(next)) continue
|
||||
seen.add(next)
|
||||
result.push(next)
|
||||
if (result.length >= 63) break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isIPv4Address(value: string) {
|
||||
const parts = value.trim().split('.')
|
||||
return parts.length === 4 && parts.every((part) => {
|
||||
if (!/^\d+$/.test(part)) return false
|
||||
const n = Number(part)
|
||||
return n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
|
||||
function splitAddressLines(value: string) {
|
||||
return value
|
||||
.split(/[\n,,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function subnetMaskFromPrefixLen(prefixLen: number) {
|
||||
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
|
||||
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||
return [24, 16, 8, 0].map((shift) => (mask >>> shift) & 255).join('.')
|
||||
}
|
||||
|
||||
function prefixLenFromSubnetMask(mask: string) {
|
||||
if (!isIPv4Address(mask)) return 0
|
||||
const bits = mask.split('.').map((part) => Number(part).toString(2).padStart(8, '0')).join('')
|
||||
if (!/^1*0*$/.test(bits)) return 0
|
||||
return bits.indexOf('0') === -1 ? 32 : bits.indexOf('0')
|
||||
}
|
||||
|
||||
const createNetworkText = {
|
||||
zh: {
|
||||
publicIPv4: '公网 IPv4',
|
||||
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
||||
publicIPv6: '公网 IPv6',
|
||||
publicIPv6: '可分配 IPv6 前缀',
|
||||
use: '使用',
|
||||
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
||||
publicNAT: '公网 NAT',
|
||||
@@ -759,7 +1139,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',
|
||||
@@ -767,6 +1147,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`
|
||||
@@ -779,5 +1174,14 @@ function formatNATPortCount(count: number, language: Language) {
|
||||
: `将分配 ${count} 个 NAT 端口`
|
||||
}
|
||||
|
||||
function symmetricLimit(a: number, b: number) {
|
||||
const left = Math.max(0, Number(a) || 0)
|
||||
const right = Math.max(0, Number(b) || 0)
|
||||
if (left === right) return left
|
||||
if (left === 0) return right
|
||||
if (right === 0) return left
|
||||
return Math.min(left, right)
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
||||
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type DialogType = 'confirm' | 'alert'
|
||||
|
||||
interface DialogState {
|
||||
open: boolean
|
||||
type: DialogType
|
||||
title: string
|
||||
message: string
|
||||
resolve?: (value: boolean) => void
|
||||
}
|
||||
|
||||
type ToastTone = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface ToastState {
|
||||
id: number
|
||||
title: string
|
||||
message: string
|
||||
tone: ToastTone
|
||||
}
|
||||
|
||||
interface DialogContextType {
|
||||
confirm: (title: string, message: string) => Promise<boolean>
|
||||
alert: (title: string, message: string) => Promise<void>
|
||||
@@ -19,67 +25,107 @@ interface DialogContextType {
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
|
||||
const toastStyles = {
|
||||
success: { icon: CheckCircle2, iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-300', borderClass: 'border-emerald-200 dark:border-emerald-800' },
|
||||
error: { icon: CircleAlert, iconClass: 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300', borderClass: 'border-red-200 dark:border-red-800' },
|
||||
warning: { icon: AlertTriangle, iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300', borderClass: 'border-amber-200 dark:border-amber-800' },
|
||||
info: { icon: Info, iconClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300', borderClass: 'border-gray-200 dark:border-gray-700' },
|
||||
}
|
||||
|
||||
function toastTone(title: string): ToastTone {
|
||||
if (/失败|错误|异常|不可用|failed|error/i.test(title)) return 'error'
|
||||
if (/提示|警告|未配置|格式|配额|封禁|warning/i.test(title)) return 'warning'
|
||||
if (/完成|成功|已保存|success/i.test(title)) return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, title: '', message: '' })
|
||||
const [toasts, setToasts] = useState<ToastState[]>([])
|
||||
const toastID = useRef(0)
|
||||
const toastTimers = useRef(new Map<number, number>())
|
||||
const { t } = useLanguage()
|
||||
|
||||
const confirm = useCallback((title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setDialog({ open: true, type: 'confirm', title, message, resolve })
|
||||
setDialog({ open: true, title, message, resolve })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
setToasts((current) => current.filter((toast) => toast.id !== id))
|
||||
const timer = toastTimers.current.get(id)
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
toastTimers.current.delete(id)
|
||||
}, [])
|
||||
|
||||
const alert = useCallback((title: string, message: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
|
||||
})
|
||||
const id = ++toastID.current
|
||||
setToasts((current) => [...current, { id, title, message, tone: toastTone(title) }].slice(-4))
|
||||
const timer = window.setTimeout(() => dismissToast(id), 4200)
|
||||
toastTimers.current.set(id, timer)
|
||||
return Promise.resolve()
|
||||
}, [dismissToast])
|
||||
|
||||
useEffect(() => () => {
|
||||
toastTimers.current.forEach((timer) => window.clearTimeout(timer))
|
||||
toastTimers.current.clear()
|
||||
}, [])
|
||||
|
||||
const close = (result: boolean) => {
|
||||
dialog.resolve?.(result)
|
||||
setDialog({ open: false, type: 'alert', title: '', message: '' })
|
||||
setDialog({ open: false, title: '', message: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ confirm, alert }}>
|
||||
{children}
|
||||
{dialog.open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
||||
{dialog.type === 'alert' && (
|
||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
||||
<X className="w-4 h-4" />
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[120] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-2" aria-live="polite" aria-atomic="true">
|
||||
{toasts.map((toast) => {
|
||||
const style = toastStyles[toast.tone]
|
||||
const ToastIcon = style.icon
|
||||
return (
|
||||
<div key={toast.id} className={`pointer-events-auto rounded-lg border bg-white shadow-lg dark:bg-gray-900 dark:shadow-black/40 ${style.borderClass}`} role="status">
|
||||
<div className="flex items-start gap-3 p-3.5">
|
||||
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
|
||||
<ToastIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
|
||||
<div className="mt-0.5 break-words text-sm leading-5 text-gray-600 dark:text-gray-300">{t(toast.message)}</div>
|
||||
</div>
|
||||
<button onClick={() => dismissToast(toast.id)} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{dialog.open && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70">
|
||||
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-700">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="flex-1 text-sm font-semibold text-black dark:text-white">{t(dialog.title)}</h3>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{t(dialog.message)}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
||||
{dialog.type === 'confirm' && (
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
{t('取消')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={() => close(false)}
|
||||
className="rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('取消')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => close(true)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
||||
dialog.type === 'confirm'
|
||||
? 'bg-black text-white hover:bg-gray-800'
|
||||
: 'bg-black text-white hover:bg-gray-800'
|
||||
}`}
|
||||
className="rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
|
||||
>
|
||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
||||
{t('确认')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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' : ''
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Code2,
|
||||
Cpu,
|
||||
Camera,
|
||||
HardDrive,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||
const isStoragePage = location.pathname.startsWith('/storage')
|
||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
{!collapsed && <span>路由管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/storage')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
isStoragePage
|
||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="w-4 h-4" />
|
||||
{!collapsed && <span>存储管理</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/audit-logs')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||
|
||||
@@ -83,6 +83,8 @@ body {
|
||||
/* Shadow */
|
||||
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
||||
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
||||
.dark .shadow-lg,
|
||||
.dark .shadow-xl { box-shadow: 0 12px 28px rgba(0,0,0,0.45) !important; }
|
||||
|
||||
/* bg-black buttons in dark mode -> light */
|
||||
.dark .bg-black { background-color: #f9fafb !important; }
|
||||
@@ -121,6 +123,7 @@ body {
|
||||
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||
.dark .bg-indigo-50 { background-color: #1e1b4b !important; }
|
||||
|
||||
/* Status badge text */
|
||||
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||
@@ -129,6 +132,14 @@ body {
|
||||
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
||||
.dark .text-emerald-600 { color: #6ee7b7 !important; }
|
||||
.dark .text-amber-800 { color: #fde68a !important; }
|
||||
.dark .text-indigo-700 { color: #a5b4fc !important; }
|
||||
|
||||
/* Colored notification borders */
|
||||
.dark .border-emerald-200 { border-color: #065f46 !important; }
|
||||
.dark .border-red-200 { border-color: #991b1b !important; }
|
||||
.dark .border-amber-200 { border-color: #92400e !important; }
|
||||
|
||||
/* Focus ring */
|
||||
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||
@@ -137,6 +148,37 @@ body {
|
||||
/* Accent */
|
||||
.dark .accent-black { accent-color: #f9fafb !important; }
|
||||
|
||||
/* Native form controls */
|
||||
.dark input,
|
||||
.dark select,
|
||||
.dark textarea { color-scheme: dark; }
|
||||
|
||||
/* Explicit dark variants take precedence over the compatibility overrides above. */
|
||||
.dark .dark\:bg-white { background-color: #f9fafb !important; }
|
||||
.dark .dark\:bg-gray-950 { background-color: #030712 !important; }
|
||||
.dark .dark\:bg-gray-900 { background-color: #111827 !important; }
|
||||
.dark .dark\:bg-gray-800 { background-color: #1f2937 !important; }
|
||||
.dark .dark\:bg-gray-700 { background-color: #374151 !important; }
|
||||
.dark .dark\:bg-emerald-950 { background-color: #022c22 !important; }
|
||||
.dark .dark\:bg-red-950 { background-color: #450a0a !important; }
|
||||
.dark .dark\:bg-amber-950 { background-color: #451a03 !important; }
|
||||
.dark .dark\:text-white { color: #f9fafb !important; }
|
||||
.dark .dark\:text-black { color: #111827 !important; }
|
||||
.dark .dark\:text-gray-300 { color: #d1d5db !important; }
|
||||
.dark .dark\:text-gray-400 { color: #9ca3af !important; }
|
||||
.dark .dark\:text-gray-500 { color: #6b7280 !important; }
|
||||
.dark .dark\:text-emerald-300 { color: #6ee7b7 !important; }
|
||||
.dark .dark\:text-red-300 { color: #fca5a5 !important; }
|
||||
.dark .dark\:text-amber-300 { color: #fcd34d !important; }
|
||||
.dark .dark\:border-gray-700 { border-color: #374151 !important; }
|
||||
.dark .dark\:border-emerald-800 { border-color: #065f46 !important; }
|
||||
.dark .dark\:border-red-800 { border-color: #991b1b !important; }
|
||||
.dark .dark\:border-amber-800 { border-color: #92400e !important; }
|
||||
.dark .dark\:hover\:bg-gray-800:hover { background-color: #1f2937 !important; color: inherit !important; }
|
||||
.dark .dark\:hover\:bg-gray-700:hover { background-color: #374151 !important; color: inherit !important; }
|
||||
.dark .dark\:hover\:bg-gray-200:hover { background-color: #e5e7eb !important; color: #111827 !important; }
|
||||
.dark .dark\:hover\:text-white:hover { color: #f9fafb !important; }
|
||||
|
||||
/* Spinner */
|
||||
.dark .border-black { border-color: #f9fafb !important; }
|
||||
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import api, { APIResponse, Container } from '../services/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
interface ApiKeyItem {
|
||||
@@ -80,7 +81,7 @@ const scopeGroups = [
|
||||
['container:delete', '删除容器'],
|
||||
['container:resize', '资源/到期'],
|
||||
['container:traffic', '流量管理'],
|
||||
['container:network', '端口映射'],
|
||||
['container:network', '网络与端口映射'],
|
||||
['container:password', '重置密码'],
|
||||
['ipv6:assign', '分配 IPv6'],
|
||||
],
|
||||
@@ -139,6 +140,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
endpoints: [
|
||||
['GET', '/api/v1/dashboard', '控制面板统计'],
|
||||
['GET', '/api/v1/host-info', '主机资源'],
|
||||
['GET', '/api/v1/host-history', '宿主机历史指标(后台每 30 秒采集)'],
|
||||
['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 段'],
|
||||
@@ -160,6 +163,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
|
||||
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
|
||||
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
|
||||
['GET', '/api/v1/containers/{id}/history', '容器历史指标(后台每 30 秒采集)'],
|
||||
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
|
||||
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
|
||||
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
|
||||
@@ -167,6 +171,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
|
||||
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
|
||||
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
|
||||
['PUT', '/api/v1/containers/{id}/public-ipv4', '更新独立公网 IPv4 地址'],
|
||||
['PUT', '/api/v1/containers/{id}/ipv6-addresses', '更新独立 IPv6 地址'],
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -192,6 +198,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
endpoints: [
|
||||
['GET', '/api/v1/templates', '模板列表'],
|
||||
['GET', '/api/v1/images', '镜像管理列表'],
|
||||
['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'],
|
||||
['POST', '/api/v1/images/download', '下载镜像'],
|
||||
['POST', '/api/v1/images/cancel', '取消镜像下载'],
|
||||
['DELETE', '/api/v1/images/delete', '删除镜像缓存'],
|
||||
@@ -210,6 +217,21 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
||||
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '主机与设置',
|
||||
endpoints: [
|
||||
['GET', '/api/v1/storage', '已挂载磁盘、存储池和空间占用'],
|
||||
['PUT', '/api/v1/storage', '更新各磁盘的存储用途和默认盘'],
|
||||
['GET', '/api/v1/task-queue/settings', '任务队列并发状态'],
|
||||
['PUT', '/api/v1/task-queue/settings', '调整任务并发数量'],
|
||||
['GET', '/api/v1/ssl', 'SSL 配置和证书状态'],
|
||||
['PUT', '/api/v1/ssl', '更新 SSL 配置'],
|
||||
['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'],
|
||||
['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'],
|
||||
['GET', '/api/v1/language', '面板语言'],
|
||||
['PUT', '/api/v1/language', '更新面板语言'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '账号与日志',
|
||||
endpoints: [
|
||||
@@ -238,6 +260,7 @@ const emptyForm = (): ApiKeyForm => ({
|
||||
})
|
||||
|
||||
export default function ApiIntegration() {
|
||||
const { t } = useLanguage()
|
||||
const [keys, setKeys] = useState<ApiKeyItem[]>([])
|
||||
const [containers, setContainers] = useState<Container[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -329,7 +352,7 @@ export default function ApiIntegration() {
|
||||
}
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
if (!window.confirm('确定删除这个 API Key 吗?')) return
|
||||
if (!window.confirm(t('确定删除这个 API Key 吗?'))) return
|
||||
try {
|
||||
await api.delete(`/api-keys/${id}`)
|
||||
setKeys(prev => prev.filter(k => k.id !== id))
|
||||
@@ -726,19 +749,31 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
name: 'demo-lxc-01',
|
||||
virtualization: 'lxc',
|
||||
template_id: 'debian-bookworm',
|
||||
storage_pool_id: 'disk-root',
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
disk_gb: 10,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 100,
|
||||
network_up_mbps: 20,
|
||||
monthly_traffic_gb: 0,
|
||||
traffic_mode: 'total',
|
||||
traffic_in_gb: 0,
|
||||
traffic_out_gb: 0,
|
||||
io_speed_mbps: 0,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
extra_ports: [8080],
|
||||
port_mapping_count: 2,
|
||||
assign_nat: true,
|
||||
lan_ipv4_mode: '',
|
||||
lan_interface: '',
|
||||
lan_ipv4_address: '',
|
||||
lan_ipv4_prefix_len: 24,
|
||||
lan_ipv4_gateway: '',
|
||||
snapshot_limit: 1,
|
||||
allowed_image_ids: ['debian-bookworm'],
|
||||
image_limit_configured: true,
|
||||
assign_ipv4: false,
|
||||
ipv4_count: 1,
|
||||
public_ipv4s: [],
|
||||
@@ -765,11 +800,23 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'PUT /api/v1/containers/{id}/resource-limit': {
|
||||
vcpu: 1,
|
||||
ram_mb: 512,
|
||||
io_speed_mbps: 0,
|
||||
network_bw_mbps: 0,
|
||||
network_down_mbps: 100,
|
||||
network_up_mbps: 20,
|
||||
io_read_mbps: 80,
|
||||
io_write_mbps: 30,
|
||||
network_bw_mbps: 20,
|
||||
io_speed_mbps: 30,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
||||
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||
mode: 'random',
|
||||
count: 1,
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||
mode: 'custom',
|
||||
addresses: ['2001:db8:100::1005'],
|
||||
},
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
container_port: 8080,
|
||||
host_port: 61320,
|
||||
@@ -782,6 +829,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
protocol: 'tcp',
|
||||
description: 'HTTP',
|
||||
},
|
||||
'POST /api/v1/containers/{id}/snapshots': { storage_pool_id: 'disk-root' },
|
||||
'POST /api/v1/containers/{id}/snapshots/schedule': {
|
||||
enabled: true,
|
||||
interval_hours: 24,
|
||||
@@ -792,6 +840,31 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
|
||||
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
|
||||
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
|
||||
'PUT /api/v1/storage': {
|
||||
pools: [
|
||||
{
|
||||
id: 'disk-root',
|
||||
name: 'system (/)',
|
||||
path: '/var/lib/clicd',
|
||||
mount_point: '/',
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/task-queue/settings': { concurrency: 4 },
|
||||
'PUT /api/v1/ssl': {
|
||||
enabled: true,
|
||||
mode: 'letsencrypt',
|
||||
target: 'panel.example.com',
|
||||
email: 'admin@example.com',
|
||||
apply_now: false,
|
||||
},
|
||||
'PUT /api/v1/webssh-origins': {
|
||||
origins: ['https://panel.example.com'],
|
||||
},
|
||||
'PUT /api/v1/language': { language: 'zh' },
|
||||
'PUT /api/v1/routing': {
|
||||
items: [
|
||||
{
|
||||
@@ -821,10 +894,11 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
||||
'POST /api/v1/security/check': { container_name: 'example-vm' },
|
||||
'PUT /api/v1/containers/{id}/firewall': {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: '', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
||||
{ id: '', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
||||
{ id: '', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
||||
{ id: '', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: '', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: '', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
'PUT /api/v1/security/settings': { auto_shutdown: false },
|
||||
@@ -899,10 +973,42 @@ const responseSamples: Record<string, unknown> = {
|
||||
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
|
||||
},
|
||||
},
|
||||
'GET /api/v1/host-history': {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
ts: 1784642400000,
|
||||
cpu: 8.4,
|
||||
memory: 21.3,
|
||||
network: 12288,
|
||||
network_rx: 10240,
|
||||
network_tx: 2048,
|
||||
disk_io: 1052672,
|
||||
disk_read: 4096,
|
||||
disk_write: 1048576,
|
||||
disk_usage_pct: 18.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
'GET /api/v1/host-report': {
|
||||
success: true,
|
||||
data: {
|
||||
generated_at: '2026-07-21 14:00:00',
|
||||
hostname: 'ubuntu',
|
||||
os: 'Ubuntu 22.04.5 LTS',
|
||||
kernel: 'Linux 6.8.0-1054-oracle aarch64 GNU/Linux',
|
||||
cpu: { model: 'Neoverse-N1', cores: 4, threads: 4, architecture: 'arm64', virtualization: true },
|
||||
memory: { total_mb: 11980, used_mb: 2100, free_mb: 9880, modules: [] },
|
||||
runtime: { lxc_available: true, kvm_available: false, support_mode: 'lxc_only' },
|
||||
public_ipv4: [{ address: '203.0.113.10', interface: 'eth0' }],
|
||||
ipv6_prefixes: [],
|
||||
},
|
||||
},
|
||||
'GET /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' },
|
||||
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' }],
|
||||
@@ -916,6 +1022,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' }],
|
||||
@@ -1002,6 +1110,12 @@ const responseSamples: Record<string, unknown> = {
|
||||
load15: 0.01,
|
||||
},
|
||||
},
|
||||
'GET /api/v1/containers/{id}/history': {
|
||||
success: true,
|
||||
data: [
|
||||
{ ts: 1784642400000, cpu: 1.2, memory: 5.6, network: 4096, network_rx: 3072, network_tx: 1024, disk_io: 8192, disk_read: 2048, disk_write: 6144 },
|
||||
],
|
||||
},
|
||||
'GET /api/v1/containers/{id}/traffic': {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -1022,6 +1136,16 @@ const responseSamples: Record<string, unknown> = {
|
||||
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
|
||||
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
|
||||
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
|
||||
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||
success: true,
|
||||
message: 'Public IPv4 assignments updated',
|
||||
data: { id: 5, name: 'example-vm', public_ipv4s: ['203.0.113.10'] },
|
||||
},
|
||||
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||
success: true,
|
||||
message: 'IPv6 assignments updated',
|
||||
data: { id: 5, name: 'example-vm', ipv6_addresses: ['2001:db8:100::1005'] },
|
||||
},
|
||||
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
|
||||
'POST /api/v1/containers/{id}/port-mappings': {
|
||||
success: true,
|
||||
@@ -1039,10 +1163,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
success: true,
|
||||
data: {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
||||
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
||||
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
||||
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1051,10 +1176,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
message: 'Firewall updated',
|
||||
data: {
|
||||
enabled: true,
|
||||
default_action: 'DROP',
|
||||
rules: [
|
||||
{ id: 'a1b2c3d4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH', enabled: true },
|
||||
{ id: 'e5f6g7h8', direction: 'in', protocol: 'tcp', port: '80,443', source_ip: '', action: 'ACCEPT', description: 'Allow HTTP/HTTPS', enabled: true },
|
||||
{ id: 'i9j0k1l2', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow all outbound TCP', enabled: true },
|
||||
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
|
||||
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
|
||||
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1084,10 +1210,57 @@ const responseSamples: Record<string, unknown> = {
|
||||
{ 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&container={id}': {
|
||||
success: true,
|
||||
data: [
|
||||
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true },
|
||||
],
|
||||
},
|
||||
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
|
||||
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
|
||||
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
|
||||
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
|
||||
'GET /api/v1/storage': {
|
||||
success: true,
|
||||
data: {
|
||||
pools: [
|
||||
{
|
||||
id: 'disk-root',
|
||||
name: 'system (/)',
|
||||
path: '/var/lib/clicd',
|
||||
mount_point: '/',
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
enabled: true,
|
||||
available: true,
|
||||
free_bytes: 54653493248,
|
||||
},
|
||||
],
|
||||
disks: [
|
||||
{ name: 'sda2', path: '/dev/sda2', fstype: 'ext4', mount_point: '/', size_bytes: 67331063808, used_bytes: 12677570560, free_bytes: 54653493248 },
|
||||
],
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
},
|
||||
},
|
||||
'PUT /api/v1/storage': {
|
||||
success: true,
|
||||
data: {
|
||||
pools: [{ id: 'disk-root', path: '/var/lib/clicd', mount_point: '/', content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'], enabled: true, available: true }],
|
||||
disks: [],
|
||||
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||
},
|
||||
},
|
||||
'GET /api/v1/task-queue/settings': { success: true, data: { concurrency: 4, active: 1, pending: 2 } },
|
||||
'PUT /api/v1/task-queue/settings': { success: true, message: '任务队列设置已保存', data: { concurrency: 4, active: 1, pending: 2 } },
|
||||
'GET /api/v1/ssl': {
|
||||
success: true,
|
||||
data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', email: 'admin@example.com', detected_host: 'panel.example.com', certificate: { subject: 'panel.example.com', issuer: "Let's Encrypt", dns_names: ['panel.example.com'], ip_names: [], valid: true } },
|
||||
},
|
||||
'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', 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' } },
|
||||
'GET /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||
'PUT /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||
'GET /api/v1/security/alerts': { success: true, data: [] },
|
||||
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
|
||||
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
|
||||
@@ -1095,11 +1268,11 @@ const responseSamples: Record<string, unknown> = {
|
||||
'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
|
||||
'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/swap': { success: true, message: 'SWAP 已调整为 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/swap': { success: true, message: 'SWAP adjusted to 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
|
||||
'POST /api/v1/batch-create': { success: true, data: ['task-12'] },
|
||||
'POST /api/v1/batch-action': { success: true, data: ['task-13'] },
|
||||
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
|
||||
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
|
||||
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
|
||||
'POST /api/v1/sub-user/create': {
|
||||
success: true,
|
||||
message: 'Sub-user created',
|
||||
@@ -1164,29 +1337,59 @@ function examplePathFor(path: string) {
|
||||
function endpointNoteFor(key: string) {
|
||||
const notes: string[] = []
|
||||
if (key === 'POST /api/v1/containers') {
|
||||
notes.push('Linux 创建支持 ssh_auth_mode=auto_password|password|key;公网 IPv4、IPv6 与 NAT 可通过 assign_nat、assign_ipv4、assign_ipv6 组合使用。')
|
||||
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
||||
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
||||
notes.push('storage_pool_id selects an enabled disk for the runtime. For an LXC with an independent LAN address, set lan_ipv4_mode=dhcp or static and set assign_nat=false; static mode also requires lan_ipv4_address, lan_ipv4_prefix_len, and lan_ipv4_gateway.')
|
||||
notes.push('allowed_image_ids and image_limit_configured define which downloaded images the container owner may use for reinstall. Include the initial template ID when it should remain reinstallable.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/reinstall') {
|
||||
notes.push('重装支持 ssh_auth_mode=keep|auto_password|password|key;keep 仅用于重装,未传 SSH 字段时保持原有行为。')
|
||||
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-create') {
|
||||
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
|
||||
notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
||||
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/firewall') {
|
||||
notes.push('启用防火墙后默认拒绝所有 TCP/UDP 入站和出站流量,仅放行 rules 中定义的规则。direction: in=入站, out=出站。action: ACCEPT=放行, DROP=拒绝。port 支持单端口(22)、多端口(80,443)、范围(8000-9000)。')
|
||||
notes.push('Backward compatible: default_action is optional; if omitted, the existing policy is kept. rule.network is optional; if omitted, it is treated as ipv4. default_action: DROP=deny unmatched traffic, ACCEPT=allow unmatched traffic. network: ipv4=IPv4 NAT/public IPv4, ipv6=IPv6, all=apply to both IPv4 and IPv6. For NAT inbound rules, port is the container internal port, not the host public port.')
|
||||
}
|
||||
if (key === 'POST /api/v1/batch-action') {
|
||||
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
|
||||
notes.push('When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/routing') {
|
||||
notes.push('更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。')
|
||||
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('扫描公网 IPv4 段需要 routing:write;verify=true 时会尝试校验地址可用性。')
|
||||
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||
}
|
||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。')
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。')
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。')
|
||||
if (key === 'GET /api/v1/host-history' || key === 'GET /api/v1/containers/{id}/history') {
|
||||
notes.push('Metrics are collected in the background every 30 seconds, even when the statistics page is closed.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/containers/{id}/public-ipv4' || key === 'PUT /api/v1/containers/{id}/ipv6-addresses') {
|
||||
notes.push('mode accepts random, custom, or clear. random uses count, custom uses addresses, and clear removes all assignments of that address family.')
|
||||
}
|
||||
if (key === 'GET /api/v1/images/enabled?type=lxc&container={id}') {
|
||||
notes.push('type accepts lxc or kvm. Supplying container applies that container image allowlist; omit container when listing images for a new container.')
|
||||
}
|
||||
if (key === 'POST /api/v1/containers/{id}/snapshots') {
|
||||
notes.push('storage_pool_id is optional. The selected pool must be enabled for snapshots; otherwise the server chooses an available snapshot pool by free space and default priority.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/storage') {
|
||||
notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.')
|
||||
}
|
||||
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins')) {
|
||||
notes.push('This endpoint requires an API key with admin:access.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/task-queue/settings') {
|
||||
notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/ssl') {
|
||||
notes.push('mode accepts disabled, letsencrypt, self_signed, or uploaded. uploaded mode uses cert_pem and key_pem. apply_now requests a service restart after saving.')
|
||||
}
|
||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
|
||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
|
||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
|
||||
return notes.join(' ')
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
assignIPv6,
|
||||
APIResponse,
|
||||
Container,
|
||||
ContainerMetricPoint as ContainerMetricSample,
|
||||
ContainerUsage,
|
||||
createSubUser,
|
||||
createContainerSnapshot,
|
||||
@@ -39,15 +40,21 @@ import {
|
||||
deleteContainerSnapshot,
|
||||
deletePortMapping,
|
||||
getContainer,
|
||||
getContainerHistory,
|
||||
getContainerSnapshots,
|
||||
getContainerUsage,
|
||||
getHostInfo,
|
||||
getStorageInfo,
|
||||
getTrafficInfo,
|
||||
HostInfo,
|
||||
TrafficInfo,
|
||||
getEnabledImages,
|
||||
getFirewall,
|
||||
PortMapping,
|
||||
PublicIPv4Info,
|
||||
FirewallRule,
|
||||
updatePublicIPv4Assignments,
|
||||
updateIPv6Assignments,
|
||||
reinstallContainer,
|
||||
resetSSHPassword,
|
||||
restartContainer,
|
||||
@@ -55,6 +62,7 @@ import {
|
||||
stopContainer,
|
||||
Snapshot,
|
||||
SnapshotSchedule,
|
||||
StorageInfo,
|
||||
Template,
|
||||
updateContainerExpiry,
|
||||
updateFirewall,
|
||||
@@ -69,6 +77,7 @@ import {
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import WebSSHViewer from '../components/WebSSHViewer'
|
||||
import WebVNCViewer from '../components/WebVNCViewer'
|
||||
import { RingStat } from '../components/RingStats'
|
||||
@@ -88,8 +97,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
|
||||
@@ -100,6 +113,8 @@ type MappingDraft = {
|
||||
protocol: string
|
||||
}
|
||||
|
||||
type IPAssignMode = 'clear' | 'random' | 'custom'
|
||||
|
||||
const emptyDraft: MappingDraft = {
|
||||
index: null,
|
||||
description: '',
|
||||
@@ -115,6 +130,7 @@ export default function ContainerDetail() {
|
||||
const navigate = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const { isSubUser } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [container, setContainer] = useState<Container | null>(null)
|
||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||
const [usage, setUsage] = useState<ContainerUsage | null>(null)
|
||||
@@ -128,6 +144,14 @@ export default function ContainerDetail() {
|
||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||
const [showNat, setShowNat] = useState(false)
|
||||
const [showIPAssign, setShowIPAssign] = useState(false)
|
||||
const [savingIPAssign, setSavingIPAssign] = useState(false)
|
||||
const [ipv4AssignMode, setIPv4AssignMode] = useState<IPAssignMode>('clear')
|
||||
const [ipv4AssignCount, setIPv4AssignCount] = useState(1)
|
||||
const [ipv4Selected, setIPv4Selected] = useState<string[]>([])
|
||||
const [ipv6AssignMode, setIPv6AssignMode] = useState<IPAssignMode>('clear')
|
||||
const [ipv6AssignCount, setIPv6AssignCount] = useState(1)
|
||||
const [ipv6DraftText, setIPv6DraftText] = useState('')
|
||||
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||
const [editExpiry, setEditExpiry] = useState('')
|
||||
@@ -148,7 +172,7 @@ export default function ContainerDetail() {
|
||||
const [trafficEdit, setTrafficEdit] = useState({ mode: 'total', monthly: 0, inGB: 0, outGB: 0 })
|
||||
const [savingTraffic, setSavingTraffic] = useState(false)
|
||||
const [showResourceEdit, setShowResourceEdit] = useState(false)
|
||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 })
|
||||
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, networkDownMbps: 0, networkUpMbps: 0, ioReadMbps: 0, ioWriteMbps: 0 })
|
||||
const [savingResource, setSavingResource] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showResetPassword, setShowResetPassword] = useState(false)
|
||||
@@ -162,12 +186,17 @@ export default function ContainerDetail() {
|
||||
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(!isSubUser)
|
||||
const [snapshotStoragePoolID, setSnapshotStoragePoolID] = useState('')
|
||||
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||
const [showFirewall, setShowFirewall] = useState(false)
|
||||
const [firewallEnabled, setFirewallEnabled] = useState(false)
|
||||
const [firewallDefaultAction, setFirewallDefaultAction] = useState<'ACCEPT' | 'DROP'>('DROP')
|
||||
const [firewallRules, setFirewallRules] = useState<FirewallRule[]>([])
|
||||
const [firewallSaving, setFirewallSaving] = useState(false)
|
||||
const [firewallMessage, setFirewallMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [editingFirewallRule, setEditingFirewallRule] = useState<FirewallRule | null>(null)
|
||||
const [showFirewallEditor, setShowFirewallEditor] = useState(false)
|
||||
|
||||
@@ -202,33 +231,36 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}, [containerIdentifier, container?.snapshot_limit])
|
||||
|
||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
||||
if (!containerIdentifier || !currentContainer) return
|
||||
|
||||
const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
|
||||
? nextUsage.memory_total_bytes
|
||||
: currentContainer.ram_mb * 1024 * 1024
|
||||
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 point: MetricPoint = {
|
||||
ts: Date.now(),
|
||||
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
||||
memory: clamp(memoryPct),
|
||||
network: networkBps,
|
||||
diskIO: diskIOBps,
|
||||
const fetchStorage = useCallback(async () => {
|
||||
if (isSubUser) {
|
||||
setStorageLoading(false)
|
||||
return
|
||||
}
|
||||
setStorageLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
setStorageInfo(res.data.data || null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch storage:', err)
|
||||
setStorageInfo(null)
|
||||
} finally {
|
||||
setStorageLoading(false)
|
||||
}
|
||||
}, [isSubUser])
|
||||
|
||||
setHistory((prev) => {
|
||||
const cutoff = Date.now() - statsRanges['1w']
|
||||
const next = [...prev.filter((item) => item.ts >= cutoff), point]
|
||||
localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}, [containerIdentifier])
|
||||
const fetchMetricHistory = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
try {
|
||||
const res = await getContainerHistory(containerIdentifier)
|
||||
const points = (res.data.data || []).map(normalizeContainerMetricSample)
|
||||
if (points.length > 0) {
|
||||
setHistory(points)
|
||||
localStorage.setItem(historyKey(container?.uuid || containerIdentifier), JSON.stringify(points))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch metric history:', err)
|
||||
}
|
||||
}, [containerIdentifier, container?.uuid])
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
if (!containerIdentifier) return
|
||||
@@ -236,12 +268,11 @@ export default function ContainerDetail() {
|
||||
const res = await getContainerUsage(containerIdentifier)
|
||||
if (res.data.data) {
|
||||
setUsage(res.data.data)
|
||||
appendUsagePoint(res.data.data, container)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch usage:', err)
|
||||
}
|
||||
}, [containerIdentifier, container, appendUsagePoint])
|
||||
}, [containerIdentifier])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerIdentifier) return
|
||||
@@ -284,8 +315,17 @@ export default function ContainerDetail() {
|
||||
}, [fetchUsage])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSnapshots) fetchSnapshots()
|
||||
}, [showSnapshots, fetchSnapshots])
|
||||
fetchMetricHistory()
|
||||
const timer = window.setInterval(fetchMetricHistory, 30000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [fetchMetricHistory])
|
||||
|
||||
useEffect(() => {
|
||||
if (showSnapshots) {
|
||||
fetchSnapshots()
|
||||
fetchStorage()
|
||||
}
|
||||
}, [showSnapshots, fetchSnapshots, fetchStorage])
|
||||
|
||||
// Poll task status for this container
|
||||
useEffect(() => {
|
||||
@@ -392,8 +432,10 @@ export default function ContainerDetail() {
|
||||
setResourceEdit({
|
||||
vcpu: container.vcpu,
|
||||
ramMb: container.ram_mb,
|
||||
ioMbps: container.io_speed_mbps || 0,
|
||||
bwMbps: container.network_bw_mbps || 0,
|
||||
networkDownMbps: resourceLimitValue(container.network_down_mbps, container.network_bw_mbps),
|
||||
networkUpMbps: resourceLimitValue(container.network_up_mbps, container.network_bw_mbps),
|
||||
ioReadMbps: resourceLimitValue(container.io_read_mbps, container.io_speed_mbps),
|
||||
ioWriteMbps: resourceLimitValue(container.io_write_mbps, container.io_speed_mbps),
|
||||
})
|
||||
setShowResourceEdit(true)
|
||||
}
|
||||
@@ -405,8 +447,12 @@ export default function ContainerDetail() {
|
||||
await updateResourceLimit(container.id, {
|
||||
vcpu: resourceEdit.vcpu,
|
||||
ram_mb: resourceEdit.ramMb,
|
||||
io_speed_mbps: resourceEdit.ioMbps,
|
||||
network_bw_mbps: resourceEdit.bwMbps,
|
||||
network_down_mbps: resourceEdit.networkDownMbps,
|
||||
network_up_mbps: resourceEdit.networkUpMbps,
|
||||
network_bw_mbps: symmetricLimit(resourceEdit.networkDownMbps, resourceEdit.networkUpMbps),
|
||||
io_read_mbps: resourceEdit.ioReadMbps,
|
||||
io_write_mbps: resourceEdit.ioWriteMbps,
|
||||
io_speed_mbps: symmetricLimit(resourceEdit.ioReadMbps, resourceEdit.ioWriteMbps),
|
||||
})
|
||||
setShowResourceEdit(false)
|
||||
fetchContainer()
|
||||
@@ -417,29 +463,59 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const openFirewall = () => {
|
||||
const syncFirewallState = (enabled: boolean, defaultAction: 'ACCEPT' | 'DROP', rules: FirewallRule[]) => {
|
||||
const nextRules = rules.map(r => ({ ...r }))
|
||||
setFirewallEnabled(enabled)
|
||||
setFirewallDefaultAction(defaultAction)
|
||||
setFirewallRules(nextRules)
|
||||
setContainer(prev => prev ? {
|
||||
...prev,
|
||||
firewall_enabled: enabled,
|
||||
firewall_default_action: defaultAction,
|
||||
firewall_rules: nextRules.map(r => ({ ...r })),
|
||||
} : prev)
|
||||
}
|
||||
|
||||
const openFirewall = async () => {
|
||||
if (!container) return
|
||||
setFirewallEnabled(container.firewall_enabled || false)
|
||||
setFirewallRules(container.firewall_rules ? [...container.firewall_rules.map(r => ({ ...r }))] : [])
|
||||
syncFirewallState(container.firewall_enabled || false, container.firewall_default_action || 'DROP', container.firewall_rules || [])
|
||||
setFirewallMessage(null)
|
||||
setShowFirewall(true)
|
||||
try {
|
||||
const res = await getFirewall(container.id)
|
||||
const data = res.data.data
|
||||
if (data) syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load firewall:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const saveFirewall = async () => {
|
||||
if (!container) return
|
||||
setFirewallSaving(true)
|
||||
try {
|
||||
await updateFirewall(container.id, { enabled: firewallEnabled, rules: firewallRules })
|
||||
const res = await updateFirewall(container.id, { enabled: firewallEnabled, default_action: firewallDefaultAction, rules: firewallRules })
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
syncFirewallState(data.enabled, data.default_action || 'DROP', data.rules || [])
|
||||
}
|
||||
setFirewallMessage({ type: 'success', text: '防火墙设置已保存并应用' })
|
||||
fetchContainer()
|
||||
} catch (err: any) {
|
||||
dialog.alert('错误', err?.response?.data?.message || '保存防火墙设置失败')
|
||||
const message = err?.response?.data?.message || '保存防火墙设置失败'
|
||||
setFirewallMessage({ type: 'error', text: message })
|
||||
dialog.alert('错误', message)
|
||||
} finally {
|
||||
setFirewallSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addFirewallRule = () => {
|
||||
const hasIPv4Firewall = (container?.public_ipv4s?.length || 0) > 0 || Math.max(container?.port_mapping_limit || 0, container?.port_mappings?.length || 0) > 0
|
||||
const hasIPv6Firewall = !!container?.ipv6 || (container?.ipv6_addresses?.length || 0) > 0
|
||||
setEditingFirewallRule({
|
||||
id: '',
|
||||
network: hasIPv4Firewall ? 'ipv4' : hasIPv6Firewall ? 'ipv6' : 'ipv4',
|
||||
direction: 'in',
|
||||
protocol: 'tcp',
|
||||
port: '',
|
||||
@@ -474,10 +550,12 @@ export default function ContainerDetail() {
|
||||
|
||||
const openReinstall = async () => {
|
||||
try {
|
||||
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
||||
const res = await getEnabledImages(container?.virtualization || 'lxc', containerIdentifier)
|
||||
if (res.data.data) {
|
||||
setTemplates(res.data.data)
|
||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
||||
const data = res.data.data
|
||||
setTemplates(data)
|
||||
const currentTemplate = container?.template || ''
|
||||
setSelectedTemplate(data.some((template) => template.id === currentTemplate) ? currentTemplate : (data[0]?.id || ''))
|
||||
}
|
||||
setReinstallAuthMode('keep')
|
||||
setReinstallPasswordDraft('')
|
||||
@@ -592,6 +670,42 @@ export default function ContainerDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const openIPAssign = () => {
|
||||
const currentIPv4 = (container?.public_ipv4s || []).map((item) => item.address).filter(Boolean)
|
||||
const currentIPv6 = (container?.ipv6_addresses || []).map((item) => item.address).filter(Boolean)
|
||||
setIPv4Selected(currentIPv4)
|
||||
setIPv4AssignMode(currentIPv4.length > 0 ? 'custom' : 'clear')
|
||||
setIPv4AssignCount(Math.max(1, currentIPv4.length || 1))
|
||||
setIPv6DraftText(currentIPv6.join('\n'))
|
||||
setIPv6AssignMode(currentIPv6.length > 0 ? 'custom' : 'clear')
|
||||
setIPv6AssignCount(Math.max(1, currentIPv6.length || 1))
|
||||
setShowIPAssign(true)
|
||||
}
|
||||
|
||||
const submitIPAssign = async () => {
|
||||
if (!containerIdentifier) return
|
||||
setSavingIPAssign(true)
|
||||
try {
|
||||
await updatePublicIPv4Assignments(containerIdentifier, {
|
||||
mode: ipv4AssignMode,
|
||||
count: Math.max(1, Math.round(ipv4AssignCount || 1)),
|
||||
addresses: ipv4AssignMode === 'custom' ? ipv4Selected : [],
|
||||
})
|
||||
await updateIPv6Assignments(containerIdentifier, {
|
||||
mode: ipv6AssignMode,
|
||||
count: Math.max(1, Math.round(ipv6AssignCount || 1)),
|
||||
addresses: ipv6AssignMode === 'custom' ? splitAddressLines(ipv6DraftText) : [],
|
||||
})
|
||||
await fetchContainer()
|
||||
setShowIPAssign(false)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('公网 IP 分配失败', error.response?.data?.message || '请检查地址是否可用或已被占用')
|
||||
} finally {
|
||||
setSavingIPAssign(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAddMapping = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
setDraft(emptyDraft)
|
||||
@@ -687,6 +801,10 @@ export default function ContainerDetail() {
|
||||
const handleCreateSnapshot = async () => {
|
||||
if (!containerIdentifier) return
|
||||
if (!(await ensureSubUserCanOperate())) return
|
||||
if (!snapshotStorageReady) {
|
||||
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||
return
|
||||
}
|
||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||
return
|
||||
@@ -700,7 +818,7 @@ export default function ContainerDetail() {
|
||||
}
|
||||
setSnapshotBusy('create')
|
||||
try {
|
||||
await createContainerSnapshot(containerIdentifier)
|
||||
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
|
||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
@@ -712,6 +830,10 @@ export default function ContainerDetail() {
|
||||
|
||||
const openSnapshotSchedule = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
if (!snapshotStorageReady) {
|
||||
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||
return
|
||||
}
|
||||
setSnapshotScheduleDraft({
|
||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||
time: snapshotSchedule?.time || '03:00',
|
||||
@@ -826,6 +948,7 @@ export default function ContainerDetail() {
|
||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||
const publicIPv4s = container.public_ipv4s || []
|
||||
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
||||
const allocatableIPv4s = mergeIPv4Choices(hostInfo?.network.public_ipv4_addresses || [], publicIPv4s)
|
||||
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||
const ipv6List = (container.ipv6_addresses || [])
|
||||
.map((item) => item.address)
|
||||
@@ -836,6 +959,10 @@ export default function ContainerDetail() {
|
||||
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
||||
const hasIndependentIPv6 = ipv6List.length > 0
|
||||
const defaultConnPort = isWindows ? 3389 : 22
|
||||
const snapshotStoragePools = (storageInfo?.pools || []).filter((pool) =>
|
||||
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('snapshots')
|
||||
)
|
||||
const snapshotStorageReady = isSubUser || snapshotStoragePools.length > 0
|
||||
|
||||
let publicEndpoint = '-'
|
||||
let sshCommand = ''
|
||||
@@ -868,14 +995,42 @@ 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 netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100)
|
||||
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_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(networkRxBps, networkDownLimit, 125000, 125000000),
|
||||
directionUsagePercent(networkTxBps, networkUpLimit, 125000, 125000000),
|
||||
)
|
||||
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(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)
|
||||
const hasNATQuota = mappingLimit > 0
|
||||
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
|
||||
const hasFirewallIPv4 = hasIndependentIPv4 || hasNATQuota
|
||||
const firewallNetworkOptions: Array<{ value: NonNullable<FirewallRule['network']>; label: string }> = []
|
||||
if (hasFirewallIPv4) {
|
||||
firewallNetworkOptions.push({
|
||||
value: 'ipv4',
|
||||
label: hasIndependentIPv4 ? 'IPv4(公网 IPv4)' : 'IPv4(NAT)',
|
||||
})
|
||||
}
|
||||
if (hasIndependentIPv6) {
|
||||
firewallNetworkOptions.push({ value: 'ipv6', label: 'IPv6' })
|
||||
}
|
||||
if (hasFirewallIPv4 && hasIndependentIPv6) {
|
||||
firewallNetworkOptions.push({ value: 'all', label: '全部网络' })
|
||||
}
|
||||
const managementUrl = subUser?.access_code
|
||||
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
||||
: ''
|
||||
@@ -903,16 +1058,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)},累计 ${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)},累计 ${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)}%`,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -997,7 +1160,7 @@ export default function ContainerDetail() {
|
||||
IPv4 NAT 管理
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton onClick={() => setShowFirewall(true)} disabled={isSubUserPolicyBlocked}>
|
||||
<ActionButton onClick={openFirewall} disabled={isSubUserPolicyBlocked}>
|
||||
<FirewallIcon className="w-3.5 h-3.5" />
|
||||
防火墙
|
||||
</ActionButton>
|
||||
@@ -1107,22 +1270,35 @@ export default function ContainerDetail() {
|
||||
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
||||
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
||||
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
||||
<PlainRow label="网络速率" value={container.network_bw_mbps > 0 ? `${container.network_bw_mbps} Mbps` : '不限制'} />
|
||||
<PlainRow label="IO 速度" value={container.io_speed_mbps > 0 ? `${container.io_speed_mbps} MB/s` : '不限制'} />
|
||||
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
|
||||
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
|
||||
</Panel>
|
||||
|
||||
<Panel title="实时状态">
|
||||
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
||||
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
||||
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
|
||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||
{!isSubUser && ipv6List.length === 0 && (
|
||||
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||
Assign
|
||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText}>
|
||||
{!isSubUser && (
|
||||
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</PlainRow>
|
||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||
{!isSubUser && (
|
||||
<>
|
||||
{ipv6List.length === 0 && (
|
||||
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||
Assign
|
||||
</button>
|
||||
)}
|
||||
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PlainRow>
|
||||
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
|
||||
<PlainRow label="创建时间" value={container.created_at} />
|
||||
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
|
||||
@@ -1355,7 +1531,7 @@ export default function ContainerDetail() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSnapshotSchedule}
|
||||
disabled={!!snapshotBusy}
|
||||
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
||||
snapshotSchedule?.enabled
|
||||
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||
@@ -1367,7 +1543,7 @@ export default function ContainerDetail() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateSnapshot}
|
||||
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||
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"
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
@@ -1377,6 +1553,20 @@ export default function ContainerDetail() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{storageLoading && !isSubUser && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
)}
|
||||
{!storageLoading && !snapshotStorageReady && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<span>尚未开启快照存储,无法新建或启用定时快照。</span>
|
||||
<button onClick={() => { setShowSnapshots(false); navigate('/storage') }} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
|
||||
<div>
|
||||
快照数量:
|
||||
@@ -1412,6 +1602,26 @@ export default function ContainerDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSubUser && snapshotStoragePools.length > 0 && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<Field label="新建快照存储磁盘">
|
||||
<select
|
||||
value={snapshotStoragePoolID}
|
||||
onChange={(event) => setSnapshotStoragePoolID(event.target.value)}
|
||||
className="w-72 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black"
|
||||
>
|
||||
<option value="">自动选择(默认盘优先,空间不足自动切换)</option>
|
||||
{snapshotStoragePools.map((pool) => (
|
||||
<option key={pool.id} value={pool.id}>
|
||||
{pool.name} · {pool.mount_point || pool.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="pb-2 text-xs text-gray-400">仅影响手动新建快照;定时快照使用默认磁盘。</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingSnapshotQuota && !isSubUser && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<Field label="子用户每台容器快照上限">
|
||||
@@ -1511,7 +1721,12 @@ export default function ContainerDetail() {
|
||||
{showFirewall && (
|
||||
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
|
||||
!isSubUser && (
|
||||
<button onClick={addFirewallRule} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
|
||||
<button
|
||||
onClick={addFirewallRule}
|
||||
disabled={firewallNetworkOptions.length === 0}
|
||||
title={firewallNetworkOptions.length === 0 ? '当前容器没有可配置的 NAT、公网 IPv4 或 IPv6' : undefined}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />添加规则
|
||||
</button>
|
||||
)
|
||||
@@ -1521,7 +1736,11 @@ export default function ContainerDetail() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800">防火墙</div>
|
||||
<div className="text-xs text-gray-500">启用后默认拒绝所有入站和出站流量,仅放行下方规则</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{firewallEnabled
|
||||
? (firewallDefaultAction === 'DROP' ? '已启用,未匹配规则的流量将被拒绝' : '已启用,未匹配规则的流量将被放行')
|
||||
: '未启用时不接管该容器流量'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFirewallEnabled(!firewallEnabled)}
|
||||
@@ -1531,12 +1750,47 @@ export default function ContainerDetail() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border border-gray-200 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800">默认动作</div>
|
||||
<div className="text-xs text-gray-500">没有命中下方规则时如何处理</div>
|
||||
</div>
|
||||
<select
|
||||
value={firewallDefaultAction}
|
||||
onChange={(e) => setFirewallDefaultAction(e.target.value as 'ACCEPT' | 'DROP')}
|
||||
disabled={isSubUser}
|
||||
className="rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-800 focus:border-black focus:outline-none focus:ring-2 focus:ring-black disabled:opacity-60"
|
||||
>
|
||||
<option value="DROP">未匹配拒绝</option>
|
||||
<option value="ACCEPT">未匹配放行</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
<div className="font-medium text-blue-900">网络范围</div>
|
||||
<div className="mt-1">
|
||||
{firewallNetworkOptions.length > 0
|
||||
? `可配置:${firewallNetworkOptions.filter((option) => option.value !== 'all').map((option) => option.label).join('、')}。`
|
||||
: '当前容器未分配 IPv4 NAT、独立公网 IPv4 或 IPv6,暂无可配置网络。'}
|
||||
{hasFirewallIPv4 ? ` IPv4 规则覆盖${hasIndependentIPv4 ? '独立公网 IPv4' : 'IPv4 NAT 端口映射'}。` : ''}
|
||||
{hasNATQuota && !hasIndependentIPv4 ? ' NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。' : ''}
|
||||
{hasIndependentIPv6 ? ' IPv6 规则覆盖该容器已分配的 IPv6 地址。' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{firewallMessage && (
|
||||
<div className={`rounded-md px-3 py-2 text-xs ${firewallMessage.type === 'success' ? 'border border-emerald-100 bg-emerald-50 text-emerald-700' : 'border border-red-100 bg-red-50 text-red-700'}`}>
|
||||
{firewallMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full 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">状态</th>
|
||||
<th className="px-3 py-2 text-left font-medium">网络</th>
|
||||
<th className="px-3 py-2 text-left font-medium">方向</th>
|
||||
<th className="px-3 py-2 text-left font-medium">协议</th>
|
||||
<th className="px-3 py-2 text-left font-medium">端口</th>
|
||||
@@ -1554,6 +1808,11 @@ export default function ContainerDetail() {
|
||||
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${rule.enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="inline-flex rounded bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-700">
|
||||
{(rule.network || 'ipv4') === 'ipv6' ? 'IPv6' : (rule.network || 'ipv4') === 'all' ? '全部' : 'IPv4'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.direction === 'in' ? 'bg-blue-50 text-blue-700' : 'bg-orange-50 text-orange-700'}`}>
|
||||
{rule.direction === 'in' ? '入站' : '出站'}
|
||||
@@ -1571,7 +1830,14 @@ export default function ContainerDetail() {
|
||||
{!isSubUser && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<button onClick={() => { setEditingFirewallRule({ ...rule }); setShowFirewallEditor(true) }} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
|
||||
<button onClick={() => {
|
||||
const currentNetwork = (rule.network || 'ipv4') as NonNullable<FirewallRule['network']>
|
||||
const network = firewallNetworkOptions.some((option) => option.value === currentNetwork)
|
||||
? currentNetwork
|
||||
: (firewallNetworkOptions[0]?.value || currentNetwork)
|
||||
setEditingFirewallRule({ ...rule, network })
|
||||
setShowFirewallEditor(true)
|
||||
}} className="p-1.5 text-gray-400 hover:text-gray-700 rounded hover:bg-gray-100">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => deleteFirewallRule(rule.id)} className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50">
|
||||
@@ -1583,7 +1849,7 @@ export default function ContainerDetail() {
|
||||
</tr>
|
||||
))}
|
||||
{firewallRules.length === 0 && (
|
||||
<tr><td colSpan={isSubUser ? 7 : 8} className="px-3 py-6 text-center text-xs text-gray-400">暂无防火墙规则</td></tr>
|
||||
<tr><td colSpan={isSubUser ? 8 : 9} className="px-3 py-6 text-center text-xs text-gray-400">暂无防火墙规则</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1605,6 +1871,17 @@ export default function ContainerDetail() {
|
||||
{showFirewallEditor && editingFirewallRule && (
|
||||
<Modal title={editingFirewallRule.id ? '编辑规则' : '添加规则'} onClose={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }}>
|
||||
<div className="space-y-4">
|
||||
<Field label="网络">
|
||||
{firewallNetworkOptions.length > 0 ? (
|
||||
<select value={editingFirewallRule.network || firewallNetworkOptions[0].value} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, network: e.target.value as FirewallRule['network'] })} className={inputClass}>
|
||||
{firewallNetworkOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input value="当前容器没有可配置网络" disabled className={`${inputClass} bg-gray-100 text-gray-400`} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="方向">
|
||||
<select value={editingFirewallRule.direction} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, direction: e.target.value as 'in' | 'out' })} className={inputClass}>
|
||||
<option value="in">入站 (Inbound)</option>
|
||||
@@ -1612,18 +1889,52 @@ export default function ContainerDetail() {
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="协议">
|
||||
<select value={editingFirewallRule.protocol} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, protocol: e.target.value as any })} className={inputClass}>
|
||||
<select
|
||||
value={editingFirewallRule.protocol}
|
||||
onChange={(e) => {
|
||||
const protocol = e.target.value as FirewallRule['protocol']
|
||||
setEditingFirewallRule({
|
||||
...editingFirewallRule,
|
||||
protocol,
|
||||
port: protocol === 'tcp' || protocol === 'udp' ? editingFirewallRule.port : '',
|
||||
})
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="icmp">ICMP</option>
|
||||
<option value="all">全部</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="端口" hint="留空为全部端口,支持: 22 | 80,443 | 8000-9000">
|
||||
<input value={editingFirewallRule.port} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })} placeholder="如: 22 或 80,443 或 8000-9000" className={inputClass} />
|
||||
<Field
|
||||
label="端口"
|
||||
hint={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp'
|
||||
? (editingFirewallRule.direction === 'in'
|
||||
? ((editingFirewallRule.network || 'ipv4') === 'ipv4' && hasNATQuota && !hasIndependentIPv4
|
||||
? 'NAT 入站填容器内部端口,例如公网 22023 -> 容器 22,这里填 22'
|
||||
: '入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
|
||||
: '出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000')
|
||||
: '端口仅适用于 TCP/UDP'}
|
||||
>
|
||||
<input
|
||||
value={editingFirewallRule.port}
|
||||
onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, port: e.target.value })}
|
||||
placeholder={editingFirewallRule.protocol === 'tcp' || editingFirewallRule.protocol === 'udp' ? '如: 22 或 80,443 或 8000-9000' : '当前协议不使用端口'}
|
||||
disabled={editingFirewallRule.protocol !== 'tcp' && editingFirewallRule.protocol !== 'udp'}
|
||||
className={`${inputClass} disabled:bg-gray-100 disabled:text-gray-400`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'} hint="留空为任意 IP,支持 CIDR: 192.168.1.0/24">
|
||||
<input value={editingFirewallRule.source_ip} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })} placeholder="如: 192.168.1.0/24" className={inputClass} />
|
||||
<Field
|
||||
label={editingFirewallRule.direction === 'in' ? '来源 IP' : '目标 IP'}
|
||||
hint={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '留空为任意 IPv6,支持 CIDR: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '留空为任意 IP,支持 IPv4/IPv6 CIDR' : '留空为任意 IPv4,支持 CIDR: 192.168.1.0/24'}
|
||||
>
|
||||
<input
|
||||
value={editingFirewallRule.source_ip}
|
||||
onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, source_ip: e.target.value })}
|
||||
placeholder={(editingFirewallRule.network || 'ipv4') === 'ipv6' ? '如: 2001:db8::/64' : (editingFirewallRule.network || 'ipv4') === 'all' ? '如: 192.168.1.0/24 或 2001:db8::/64' : '如: 192.168.1.0/24'}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="动作">
|
||||
<select value={editingFirewallRule.action} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, action: e.target.value as 'ACCEPT' | 'DROP' })} className={inputClass}>
|
||||
@@ -1642,6 +1953,88 @@ export default function ContainerDetail() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showIPAssign && (
|
||||
<Modal title="公网 IP 分配" onClose={() => setShowIPAssign(false)} wide>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">独立 IPv4</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">修改后会重放端口映射、SNAT 和防火墙规则。</p>
|
||||
</div>
|
||||
<Segmented value={ipv4AssignMode} onChange={setIPv4AssignMode} />
|
||||
{ipv4AssignMode === 'random' && (
|
||||
<Field label="随机数量">
|
||||
<input type="number" min={1} max={64} value={ipv4AssignCount} onChange={(e) => setIPv4AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||
</Field>
|
||||
)}
|
||||
{ipv4AssignMode === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
{allocatableIPv4s.length === 0 ? (
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{allocatableIPv4s.map((ip) => (
|
||||
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-xs text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ipv4Selected.includes(ip.address)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? Array.from(new Set([...ipv4Selected, ip.address]))
|
||||
: ipv4Selected.filter((value) => value !== ip.address)
|
||||
setIPv4Selected(next)
|
||||
setIPv4AssignCount(Math.max(1, next.length || 1))
|
||||
}}
|
||||
/>
|
||||
<span className="truncate font-mono">{ip.address}</span>
|
||||
<span className="shrink-0 text-gray-400">{ip.interface}</span>
|
||||
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">独立 IPv6</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">自定义地址必须落在路由管理配置的 IPv6 前缀内。</p>
|
||||
</div>
|
||||
<Segmented value={ipv6AssignMode} onChange={setIPv6AssignMode} />
|
||||
{ipv6AssignMode === 'random' && (
|
||||
<Field label="随机数量">
|
||||
<input type="number" min={1} max={64} value={ipv6AssignCount} onChange={(e) => setIPv6AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||
</Field>
|
||||
)}
|
||||
{ipv6AssignMode === 'custom' && (
|
||||
<Field label="IPv6 地址">
|
||||
<textarea
|
||||
value={ipv6DraftText}
|
||||
onChange={(e) => {
|
||||
setIPv6DraftText(e.target.value)
|
||||
setIPv6AssignCount(Math.max(1, splitAddressLines(e.target.value).length || 1))
|
||||
}}
|
||||
className={`${inputClass} min-h-32 font-mono text-xs`}
|
||||
placeholder="2001:db8:100::100 2001:db8:100::101"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2 border-t border-gray-200 pt-4">
|
||||
<button onClick={() => setShowIPAssign(false)} disabled={savingIPAssign} className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={submitIPAssign} disabled={savingIPAssign} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />
|
||||
{savingIPAssign ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showNat && !hasIndependentIPv4 && (
|
||||
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||
!isSubUser && canAddMapping && (
|
||||
@@ -1834,15 +2227,27 @@ export default function ContainerDetail() {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">网络速率 (Mbps,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.bwMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, bwMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
<label className="block text-xs text-gray-500 mb-1">下行带宽 (Mbps,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.networkDownMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, networkDownMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">IO 速度 (MB/s,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.ioMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, ioMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
<label className="block text-xs text-gray-500 mb-1">上行带宽 (Mbps,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.networkUpMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, networkUpMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">读取 IO (MB/s,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.ioReadMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, ioReadMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">写入 IO (MB/s,0=不限制)</label>
|
||||
<input type="number" min={0} value={resourceEdit.ioWriteMbps}
|
||||
onChange={(e) => setResourceEdit({ ...resourceEdit, ioWriteMbps: Math.max(0, Number(e.target.value) || 0) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -2255,6 +2660,28 @@ function Field({ label, children, hint }: { label: string; children: ReactNode;
|
||||
)
|
||||
}
|
||||
|
||||
function Segmented({ value, onChange }: { value: IPAssignMode; onChange: (value: IPAssignMode) => void }) {
|
||||
const items: Array<{ value: IPAssignMode; label: string }> = [
|
||||
{ value: 'clear', label: '不分配' },
|
||||
{ value: 'random', label: '随机分配' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
]
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1 rounded-md bg-gray-100 p-1">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
className={`rounded px-2 py-1.5 text-xs font-medium ${value === item.value ? 'bg-white text-black shadow-sm' : 'text-gray-600 hover:text-black'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
@@ -2292,6 +2719,45 @@ function readHistory(containerName: string): MetricPoint[] {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoint {
|
||||
return {
|
||||
ts: point.ts,
|
||||
cpu: clamp(point.cpu),
|
||||
memory: clamp(point.memory),
|
||||
network: point.network || 0,
|
||||
networkRx: point.network_rx || 0,
|
||||
networkTx: point.network_tx || 0,
|
||||
diskIO: point.disk_io || 0,
|
||||
diskRead: point.disk_read || 0,
|
||||
diskWrite: point.disk_write || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function splitAddressLines(value: string) {
|
||||
return value
|
||||
.split(/[\n,,\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function mergeIPv4Choices(candidates: PublicIPv4Info[], assigned: { address: string; interface?: string; prefix_len?: number; gateway?: string }[]) {
|
||||
const byAddress = new Map<string, PublicIPv4Info>()
|
||||
for (const item of candidates) {
|
||||
if (item.address) byAddress.set(item.address, item)
|
||||
}
|
||||
for (const item of assigned) {
|
||||
if (!item.address || byAddress.has(item.address)) continue
|
||||
byAddress.set(item.address, {
|
||||
address: item.address,
|
||||
interface: item.interface || '',
|
||||
prefix: item.prefix_len ? `${item.address}/${item.prefix_len}` : item.address,
|
||||
prefix_len: item.prefix_len,
|
||||
gateway: item.gateway,
|
||||
})
|
||||
}
|
||||
return Array.from(byAddress.values()).sort((a, b) => a.address.localeCompare(b.address, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
function historyKey(containerName: string) {
|
||||
return `clicd_container_metric_history:${containerName}`
|
||||
}
|
||||
@@ -2311,8 +2777,37 @@ function clampResourceInt(value: number, min: number, max?: number, fallback = m
|
||||
return Math.min(Math.max(next, min), max ?? next)
|
||||
}
|
||||
|
||||
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 resourceLimitValue(value?: number, fallback?: number) {
|
||||
return Math.max(0, Number(value || fallback || 0))
|
||||
}
|
||||
|
||||
function symmetricLimit(a: number, b: number) {
|
||||
const left = resourceLimitValue(a)
|
||||
const right = resourceLimitValue(b)
|
||||
if (left === right) return left
|
||||
if (left === 0) return right
|
||||
if (right === 0) return left
|
||||
return Math.min(left, right)
|
||||
}
|
||||
|
||||
function directionUsagePercent(bytesPerSecond: number, limit: number, bytesPerLimitUnit: number, fallbackBytesPerSecond: number) {
|
||||
const denominator = limit > 0 ? limit * bytesPerLimitUnit : fallbackBytesPerSecond
|
||||
return denominator > 0 ? clamp((bytesPerSecond / denominator) * 100) : 0
|
||||
}
|
||||
|
||||
function formatLimit(value: number, unit: string) {
|
||||
return value > 0 ? `${value} ${unit}` : '不限制'
|
||||
}
|
||||
|
||||
function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLabel: string, secondValue: number, unit: string) {
|
||||
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import CreateContainerModal from '../components/CreateContainerModal'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import {
|
||||
Container,
|
||||
CreateContainerRequest,
|
||||
@@ -391,14 +392,12 @@ export default function Containers() {
|
||||
{pageContainers.map((container) => {
|
||||
const isRunning = container.status === 'running'
|
||||
const isInitializing = container.status === 'initializing'
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||
const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask
|
||||
const isPlaceholder = !!container.isPlaceholder
|
||||
const isPolicyBlocked = !!container.policy_blocked
|
||||
const usage = usageByName[container.name]
|
||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||
|
||||
const cpuPct = isRunning
|
||||
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
|
||||
? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))
|
||||
: 0
|
||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||
? usage.memory_total_bytes
|
||||
@@ -583,12 +582,13 @@ type DisplayContainer = Container & {
|
||||
}
|
||||
|
||||
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||
const { t } = useLanguage()
|
||||
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
||||
if (policyBlocked) {
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
策略封禁
|
||||
{t('策略封禁')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -597,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
初始化失败
|
||||
{t('初始化失败')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -606,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||
初始化完成
|
||||
{t('初始化完成')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (task?.type === 'create' && task.status === 'running') {
|
||||
const detail = t(task.stage_detail || '正在初始化')
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
<span className="truncate">{detail}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -624,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
排队等待
|
||||
{t('排队等待')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -636,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
{taskLabels[task.type] || '处理中'}
|
||||
{t(taskLabels[task.type] || '处理中')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -645,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||
正在初始化
|
||||
{t('正在初始化')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -653,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
||||
return (
|
||||
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||
{running ? '在线' : '离线'}
|
||||
{t(running ? '在线' : '离线')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -704,6 +705,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
ram_mb: cfg.ram_mb,
|
||||
disk_gb: cfg.disk_gb,
|
||||
network_bw_mbps: cfg.network_bw_mbps,
|
||||
network_down_mbps: cfg.network_down_mbps,
|
||||
network_up_mbps: cfg.network_up_mbps,
|
||||
monthly_traffic_gb: cfg.monthly_traffic_gb,
|
||||
traffic_mode: cfg.traffic_mode || 'total',
|
||||
traffic_in_gb: cfg.traffic_in_gb || 0,
|
||||
@@ -712,6 +715,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
traffic_used_tx: 0,
|
||||
traffic_reset_date: '',
|
||||
io_speed_mbps: cfg.io_speed_mbps,
|
||||
io_read_mbps: cfg.io_read_mbps,
|
||||
io_write_mbps: cfg.io_write_mbps,
|
||||
status: 'creating',
|
||||
ip: '',
|
||||
public_ipv4s: [],
|
||||
@@ -725,6 +730,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
||||
port_mappings: [],
|
||||
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
|
||||
firewall_enabled: false,
|
||||
firewall_default_action: 'DROP',
|
||||
firewall_rules: [],
|
||||
snapshot_limit: cfg.snapshot_limit || 3,
|
||||
created_at: '',
|
||||
@@ -785,7 +791,7 @@ type ContainerFilters = {
|
||||
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
||||
const keyword = filters.search.trim().toLowerCase()
|
||||
return containers.filter((container) => {
|
||||
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
|
||||
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask
|
||||
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
||||
return false
|
||||
}
|
||||
@@ -862,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
|
||||
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
|
||||
return actionLabels[task.type] || '处理中...'
|
||||
}
|
||||
|
||||
@@ -870,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
onRefresh: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-black">任务队列</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">共 {tasks.length} 个任务</p>
|
||||
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{t(`共 ${tasks.length} 个任务`)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -884,26 +892,27 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
{t('刷新')}
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
|
||||
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title={t('关闭')}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-500">暂无任务</div>
|
||||
<div className="p-8 text-center text-sm text-gray-500">{t('暂无任务')}</div>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
||||
<th className="whitespace-nowrap px-4 py-2.5">状态</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">操作</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">容器</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">创建时间</th>
|
||||
<th className="px-4 py-2.5">错误</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('状态')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('操作')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">{t('创建时间')}</th>
|
||||
<th className="px-4 py-2.5">{t('错误')}</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -912,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
<tr key={task.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-2.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
|
||||
{taskStatusLabel(task.status)}
|
||||
{t(taskStatusLabel(task.status))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{t(actionLabel(task.type))}</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
|
||||
<td className="min-w-[210px] px-4 py-2.5 text-xs text-gray-700">
|
||||
{task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
|
||||
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
|
||||
<td className="whitespace-nowrap px-2 py-2.5">
|
||||
@@ -929,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="取消任务"
|
||||
title={t('取消任务')}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -964,6 +976,7 @@ function getTemplateName(id: string) {
|
||||
const map: Record<string, string> = {
|
||||
'ubuntu-noble': 'Ubuntu 24.04',
|
||||
'ubuntu-jammy': 'Ubuntu 22.04',
|
||||
'debian-trixie': 'Debian 13',
|
||||
'debian-bookworm': 'Debian 12',
|
||||
'debian-bullseye': 'Debian 11',
|
||||
'alpine-3.21': 'Alpine 3.21',
|
||||
@@ -973,6 +986,8 @@ function getTemplateName(id: string) {
|
||||
'rockylinux-10': 'Rocky 10',
|
||||
'kvm-ubuntu-noble': 'Ubuntu 24.04',
|
||||
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
|
||||
'kvm-debian-trixie': 'Debian 13',
|
||||
'kvm-debian-trixie-xfce': 'Debian 13 XFCE',
|
||||
'kvm-debian-bookworm': 'Debian 12',
|
||||
'kvm-debian-bullseye': 'Debian 11',
|
||||
'kvm-rockylinux-9': 'Rocky 9',
|
||||
|
||||
@@ -7,14 +7,18 @@ import ResourceStatsPanel, {
|
||||
StatsRangeKey,
|
||||
statsRanges,
|
||||
} from '../components/ResourceStatsPanel'
|
||||
import { DashboardStats, getDashboard, getHostInfo, HostInfo } from '../services/api'
|
||||
import { DashboardStats, getDashboard, getHostHistory, getHostInfo, HostInfo, HostMetricPoint as HostMetricSample } from '../services/api'
|
||||
|
||||
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'
|
||||
@@ -26,6 +30,19 @@ export default function Dashboard() {
|
||||
const [range, setRange] = useState<StatsRangeKey>('30m')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
try {
|
||||
const res = await getHostHistory()
|
||||
const points = (res.data.data || []).map(normalizeHostMetricSample)
|
||||
if (points.length > 0) {
|
||||
setHistory(points)
|
||||
localStorage.setItem(hostHistoryKey, JSON.stringify(points))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
|
||||
@@ -33,7 +50,6 @@ export default function Dashboard() {
|
||||
if (hostRes.data.data) {
|
||||
const nextHost = hostRes.data.data
|
||||
setHost(nextHost)
|
||||
appendHostPoint(nextHost, setHistory)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -43,10 +59,15 @@ export default function Dashboard() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory()
|
||||
fetchData()
|
||||
const interval = window.setInterval(fetchData, 5000)
|
||||
return () => window.clearInterval(interval)
|
||||
}, [fetchData])
|
||||
const historyInterval = window.setInterval(fetchHistory, 30000)
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
window.clearInterval(historyInterval)
|
||||
}
|
||||
}, [fetchData, fetchHistory])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -58,6 +79,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 +110,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)}`,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -156,23 +189,6 @@ function SummaryCard({
|
||||
)
|
||||
}
|
||||
|
||||
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
|
||||
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),
|
||||
}
|
||||
|
||||
setHistory((prev) => {
|
||||
const cutoff = Date.now() - statsRanges['1w']
|
||||
const next = [...prev.filter((item) => item.ts >= cutoff), point]
|
||||
localStorage.setItem(hostHistoryKey, JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function readHostHistory(): HostMetricPoint[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(hostHistoryKey)
|
||||
@@ -190,8 +206,25 @@ 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 normalizeHostMetricSample(point: HostMetricSample): HostMetricPoint {
|
||||
return {
|
||||
ts: point.ts,
|
||||
cpu: clamp(point.cpu),
|
||||
memory: clamp(point.memory),
|
||||
network: point.network || 0,
|
||||
networkRx: point.network_rx || 0,
|
||||
networkTx: point.network_tx || 0,
|
||||
diskIO: point.disk_io || 0,
|
||||
diskRead: point.disk_read || 0,
|
||||
diskWrite: point.disk_write || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number) {
|
||||
|
||||
@@ -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 || '-'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
@@ -11,15 +12,18 @@ import {
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { getImages, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||
import { getImages, getStorageInfo, downloadImage, cancelImageDownload, deleteImage, toggleImage, ImageInfo, StorageInfo } from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
|
||||
export default function ImageManagement() {
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const [images, setImages] = useState<ImageInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||
const [storageLoading, setStorageLoading] = useState(true)
|
||||
|
||||
const fetchImages = useCallback(async () => {
|
||||
try {
|
||||
@@ -33,9 +37,22 @@ export default function ImageManagement() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchStorage = useCallback(async () => {
|
||||
setStorageLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
setStorageInfo(res.data.data || null)
|
||||
} catch {
|
||||
setStorageInfo(null)
|
||||
} finally {
|
||||
setStorageLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages()
|
||||
}, [fetchImages])
|
||||
fetchStorage()
|
||||
}, [fetchImages, fetchStorage])
|
||||
|
||||
useEffect(() => {
|
||||
const hasDownloads = images.some((img) => img.downloading)
|
||||
@@ -101,6 +118,9 @@ export default function ImageManagement() {
|
||||
const downloadedCount = images.filter((img) => img.downloaded).length
|
||||
const lxcImages = images.filter((img) => img.type === 'lxc')
|
||||
const kvmImages = images.filter((img) => img.type === 'kvm')
|
||||
const imageStorageReady = (storageInfo?.pools || []).some((pool) =>
|
||||
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('images')
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -121,7 +141,7 @@ export default function ImageManagement() {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchImages}
|
||||
onClick={() => { fetchImages(); fetchStorage() }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
@@ -136,6 +156,25 @@ export default function ImageManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageLoading && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||||
正在检查存储配置...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!storageLoading && !imageStorageReady && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
尚未开启镜像缓存存储,无法下载新镜像。
|
||||
</div>
|
||||
<button onClick={() => navigate('/storage')} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
|
||||
去开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImageTable
|
||||
title="LXC 容器镜像"
|
||||
images={lxcImages}
|
||||
@@ -146,19 +185,25 @@ export default function ImageManagement() {
|
||||
onCancelDownload={handleCancelDownload}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
storageReady={imageStorageReady}
|
||||
storageLoading={storageLoading}
|
||||
/>
|
||||
|
||||
<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}
|
||||
storageReady={imageStorageReady}
|
||||
storageLoading={storageLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -173,6 +218,8 @@ function ImageTable({
|
||||
onCancelDownload,
|
||||
onDelete,
|
||||
onToggle,
|
||||
storageReady,
|
||||
storageLoading,
|
||||
}: {
|
||||
title: string
|
||||
images: ImageInfo[]
|
||||
@@ -183,6 +230,8 @@ function ImageTable({
|
||||
onCancelDownload: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
storageReady: boolean
|
||||
storageLoading: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -251,7 +300,8 @@ function ImageTable({
|
||||
{!img.downloaded && !img.downloading && (
|
||||
<button
|
||||
onClick={() => onDownload(img.id)}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || storageLoading || !storageReady}
|
||||
title={storageLoading ? '正在检查存储配置...' : storageReady ? '下载镜像' : '请先在存储管理中开启镜像缓存存储'}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{isBusy ? (
|
||||
@@ -279,7 +329,8 @@ function ImageTable({
|
||||
<>
|
||||
<button
|
||||
onClick={() => onToggle(img.id, img.enabled)}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || storageLoading || !storageReady}
|
||||
title={storageLoading ? '正在检查存储配置...' : storageReady ? (img.enabled ? '禁用镜像' : '启用镜像') : '请先在存储管理中开启镜像缓存存储'}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
img.enabled
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
|
||||
@@ -315,7 +366,7 @@ function ImageTable({
|
||||
function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
if (img.downloading) {
|
||||
const progress = Math.max(0, Math.min(100, img.progress || 0))
|
||||
const showProgress = img.stage === 'downloading' && progress > 0
|
||||
const showProgress = img.stage === 'downloading' && (progress > 0 || img.downloaded_bytes > 0)
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span
|
||||
@@ -327,7 +378,10 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
||||
</span>
|
||||
{showProgress && (
|
||||
<span className="block h-1 w-24 overflow-hidden rounded-full bg-amber-100">
|
||||
<span className="block h-full rounded-full bg-amber-500 transition-all" style={{ width: `${progress}%` }} />
|
||||
<span
|
||||
className={`block h-full rounded-full bg-amber-500 transition-all ${progress <= 0 ? 'animate-pulse' : ''}`}
|
||||
style={{ width: progress > 0 ? `${progress}%` : '35%' }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -373,6 +427,7 @@ function downloadStatusLabel(img: ImageInfo) {
|
||||
if (img.stage === 'converting') return '转换中'
|
||||
if (img.stage === 'lxc-create') return '下载中'
|
||||
if (img.progress > 0) return `下载中 ${Math.min(100, img.progress)}%`
|
||||
if (img.downloaded_bytes > 0) return `下载中 · ${formatSize(img.downloaded_bytes)}`
|
||||
return '下载中'
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.17</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.26</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,9 +4,14 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import {
|
||||
getRoutingInfo,
|
||||
updateRoutingIPv6Prefixes,
|
||||
updateRoutingIPv4Pool,
|
||||
updateRoutingPools,
|
||||
type IPv4Route,
|
||||
type IPv6Route,
|
||||
type LANDHCPRoute,
|
||||
type IPv6PrefixInfo,
|
||||
type NAT4PortRange,
|
||||
type NAT4Route,
|
||||
type PublicIPv4Info,
|
||||
type RoutingInfo,
|
||||
@@ -25,6 +30,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('')
|
||||
@@ -46,12 +57,16 @@ export default function Routing() {
|
||||
|
||||
const publicIPv4s = routing?.public_ipv4_addresses || []
|
||||
const ipv4Assignments = routing?.ipv4_assignments || []
|
||||
const lanDHCPAssignments = routing?.lan_dhcp_assignments || []
|
||||
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 +154,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
|
||||
@@ -188,12 +278,47 @@ export default function Routing() {
|
||||
</button>
|
||||
</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} />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<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={text.lanDHCP} watermark="LAN" remaining={String(routing?.lan_dhcp.used || 0)} total={routing?.lan_dhcp.total || 'DHCP'} used={routing?.lan_dhcp.used || 0} label={text.dhcpManagedByLAN} 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 +453,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,9 +490,102 @@ 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.lanDHCPAssignments} subtitle={formatAddressSubtitle(lanDHCPAssignments.length, lanDHCPAssignments.length, language)}>
|
||||
{lanDHCPAssignments.length === 0 ? (
|
||||
<EmptyState text={text.noLANDHCPAssignments} icon={<Network className="h-7 w-7" />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">{text.container}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{text.runtimeName}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{text.guestIPv4}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">模式</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{text.gateway}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">MAC</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{text.interface}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{text.status}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{lanDHCPAssignments.map((item: LANDHCPRoute) => (
|
||||
<tr key={`${item.container_id}-${item.interface}-${item.mac_address || item.address}`} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => navigate(`/container/${item.container_id}`)} className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline">
|
||||
<Server className="h-4 w-4 text-gray-400" />
|
||||
{item.container_name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.lxc_name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address ? `${item.address}${item.prefix_len ? `/${item.prefix_len}` : ''}` : '-'}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-600">{item.mode === 'static' ? '手动' : 'DHCP'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.gateway || '-'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.mac_address || '-'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={item.status} language={language} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
|
||||
{nat4Mappings.length === 0 ? (
|
||||
<EmptyState text={text.noIPv4NATMappings} icon={<Network className="h-7 w-7" />} />
|
||||
@@ -527,7 +756,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 +764,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 +773,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,8 +889,17 @@ const routingText = {
|
||||
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
|
||||
refresh: '刷新',
|
||||
nat4Ports: 'NAT4 端口',
|
||||
editNAT4Range: '编辑 NAT4 范围',
|
||||
rangeStart: '起始端口',
|
||||
rangeEnd: '结束端口',
|
||||
nat4RangeInvalid: 'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口',
|
||||
saveNAT4RangeFailed: '保存 NAT4 范围失败',
|
||||
remainingTotal: '剩余 / 总数',
|
||||
publicIPv4: '公网 IPv4',
|
||||
lanDHCP: '局域网 DHCP',
|
||||
dhcpManagedByLAN: '由局域网 DHCP 分配',
|
||||
lanDHCPAssignments: '局域网 DHCP 分配',
|
||||
noLANDHCPAssignments: '暂无局域网 DHCP 分配',
|
||||
publicIPv4Pool: '公网 IPv4 池',
|
||||
editPool: '编辑 IP 池',
|
||||
noPublicIPv4Pool: '暂未配置公网 IPv4 池',
|
||||
@@ -661,10 +927,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,8 +964,17 @@ 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',
|
||||
lanDHCP: 'LAN DHCP',
|
||||
dhcpManagedByLAN: 'Managed by LAN DHCP',
|
||||
lanDHCPAssignments: 'LAN DHCP assignments',
|
||||
noLANDHCPAssignments: 'No LAN DHCP assignments',
|
||||
publicIPv4Pool: 'Public IPv4 pool',
|
||||
editPool: 'Edit pool',
|
||||
noPublicIPv4Pool: 'No public IPv4 pool configured',
|
||||
@@ -720,10 +1002,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 +1052,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 +1094,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
|
||||
}
|
||||
|
||||
|
||||
+233
-73
@@ -1,23 +1,38 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
|
||||
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
|
||||
import { Clock, Globe, ListTodo, Lock, LogIn, Minus, Monitor, Plus, RefreshCw, Save, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
|
||||
import {
|
||||
changePassword,
|
||||
changeUsername,
|
||||
getLoginLogs,
|
||||
getSSLSettings,
|
||||
getTaskQueueSettings,
|
||||
getWebSSHOriginSettings,
|
||||
LoginLog,
|
||||
SSLSettings,
|
||||
TaskQueueSettings,
|
||||
updateTaskQueueSettings,
|
||||
updateSSLSettings,
|
||||
updateWebSSHOriginSettings,
|
||||
WebSSHOriginSettings,
|
||||
} from '../services/api'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type SettingsSection = 'tasks' | 'account' | 'webssh' | 'ssl' | 'logs'
|
||||
|
||||
const settingsSections = [
|
||||
{ id: 'tasks', label: '任务队列', icon: ListTodo },
|
||||
{ id: 'account', label: '账号设置', icon: UserCog },
|
||||
{ id: 'webssh', label: 'WebSSH 访问', icon: Terminal },
|
||||
{ id: 'ssl', label: 'SSL 证书', icon: ShieldCheck },
|
||||
{ id: 'logs', label: '登录日志', icon: LogIn },
|
||||
] as const
|
||||
|
||||
export default function Settings() {
|
||||
const dialog = useDialog()
|
||||
const { username } = useAuth()
|
||||
const { t } = useLanguage()
|
||||
const [logs, setLogs] = useState<LoginLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
@@ -39,6 +54,10 @@ export default function Settings() {
|
||||
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
|
||||
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
|
||||
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
|
||||
const [taskQueue, setTaskQueue] = useState<TaskQueueSettings | null>(null)
|
||||
const [taskConcurrency, setTaskConcurrency] = useState(2)
|
||||
const [savingTaskQueue, setSavingTaskQueue] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('tasks')
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
@@ -78,13 +97,49 @@ export default function Settings() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchTaskQueue = useCallback(async () => {
|
||||
try {
|
||||
const res = await getTaskQueueSettings()
|
||||
const data = res.data.data
|
||||
if (!data) return
|
||||
setTaskQueue(data)
|
||||
setTaskConcurrency(data.concurrency)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
fetchSSL()
|
||||
fetchWebSSHOrigins()
|
||||
const timer = setInterval(fetchLogs, 15000)
|
||||
return () => clearInterval(timer)
|
||||
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
|
||||
fetchTaskQueue()
|
||||
const logTimer = setInterval(fetchLogs, 15000)
|
||||
const taskTimer = setInterval(fetchTaskQueue, 5000)
|
||||
return () => {
|
||||
clearInterval(logTimer)
|
||||
clearInterval(taskTimer)
|
||||
}
|
||||
}, [fetchLogs, fetchSSL, fetchTaskQueue, fetchWebSSHOrigins])
|
||||
|
||||
const handleSaveTaskQueue = async () => {
|
||||
const concurrency = Math.max(1, Math.min(16, Math.round(taskConcurrency || 1)))
|
||||
setSavingTaskQueue(true)
|
||||
try {
|
||||
const res = await updateTaskQueueSettings(concurrency)
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setTaskQueue(data)
|
||||
setTaskConcurrency(data.concurrency)
|
||||
}
|
||||
dialog.alert('完成', '任务队列并发设置已保存并立即生效')
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('失败', e.response?.data?.message || '任务队列设置保存失败')
|
||||
} finally {
|
||||
setSavingTaskQueue(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
|
||||
setSSLMode(mode)
|
||||
@@ -190,72 +245,173 @@ export default function Settings() {
|
||||
const totalPages = Math.ceil(logs.length / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black">面板设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">账号、安全证书与登录日志</p>
|
||||
<h1 className="text-2xl font-bold text-black dark:text-white">面板设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">任务队列、账号、安全证书与访问记录</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
|
||||
<div className="space-y-6">
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[210px_minmax(0,1fr)]">
|
||||
<aside className="overflow-x-auto rounded-lg border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-900 lg:sticky lg:top-4">
|
||||
<nav className="flex min-w-max gap-1 lg:min-w-0 lg:flex-col" aria-label="设置分类">
|
||||
{settingsSections.map((section) => {
|
||||
const Icon = section.icon
|
||||
const active = activeSection === section.id
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors ${active ? 'bg-black text-white dark:bg-white dark:text-black' : 'text-gray-600 hover:bg-gray-100 hover:text-black dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white'}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{t(section.label)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<WebSSHOriginCard
|
||||
settings={webSSHOrigins}
|
||||
originsText={webSSHOriginsText}
|
||||
saving={savingWebSSHOrigins}
|
||||
onOriginsTextChange={setWebSSHOriginsText}
|
||||
onRefresh={fetchWebSSHOrigins}
|
||||
onSave={handleSaveWebSSHOrigins}
|
||||
/>
|
||||
<section className="min-w-0">
|
||||
{activeSection === 'tasks' && (
|
||||
<TaskQueueCard
|
||||
settings={taskQueue}
|
||||
concurrency={taskConcurrency}
|
||||
saving={savingTaskQueue}
|
||||
onConcurrencyChange={setTaskConcurrency}
|
||||
onRefresh={fetchTaskQueue}
|
||||
onSave={handleSaveTaskQueue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'account' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black dark:border-gray-700 dark:bg-gray-950 dark:text-white" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={handleSaveAccount} className="rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200">保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'webssh' && (
|
||||
<WebSSHOriginCard
|
||||
settings={webSSHOrigins}
|
||||
originsText={webSSHOriginsText}
|
||||
saving={savingWebSSHOrigins}
|
||||
onOriginsTextChange={setWebSSHOriginsText}
|
||||
onRefresh={fetchWebSSHOrigins}
|
||||
onSave={handleSaveWebSSHOrigins}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'ssl' && (
|
||||
<SSLCard
|
||||
ssl={ssl}
|
||||
sslEnabled={sslEnabled}
|
||||
sslMode={sslMode}
|
||||
sslTarget={sslTarget}
|
||||
sslEmail={sslEmail}
|
||||
certPEM={certPEM}
|
||||
keyPEM={keyPEM}
|
||||
applyNow={applyNow}
|
||||
savingSSL={savingSSL}
|
||||
onRefresh={fetchSSL}
|
||||
onEnabledChange={setSSLEnabled}
|
||||
onModeChange={handleSSLModeChange}
|
||||
onTargetChange={setSSLTarget}
|
||||
onEmailChange={setSSLEmail}
|
||||
onCertChange={setCertPEM}
|
||||
onKeyChange={setKeyPEM}
|
||||
onApplyNowChange={setApplyNow}
|
||||
onSave={handleSaveSSL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSection === 'logs' && (
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskQueueCardProps {
|
||||
settings: TaskQueueSettings | null
|
||||
concurrency: number
|
||||
saving: boolean
|
||||
onConcurrencyChange: (value: number) => void
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
function TaskQueueCard(props: TaskQueueCardProps) {
|
||||
const setBounded = (value: number) => props.onConcurrencyChange(Math.max(1, Math.min(16, value)))
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<ListTodo className="h-4 w-4" />任务队列
|
||||
</h2>
|
||||
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 divide-x divide-gray-200 border-y border-gray-100 bg-gray-50">
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[11px] text-gray-500">运行中</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.active ?? 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<UserCog className="h-4 w-4" />账号设置
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前用户名</label>
|
||||
<input type="text" value={username || ''} disabled className="w-full rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">新用户名,留空则不修改</label>
|
||||
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" />
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<label className="mb-1 block text-xs text-gray-500">新密码,留空则不修改</label>
|
||||
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">当前密码,验证身份</label>
|
||||
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" />
|
||||
</div>
|
||||
<button onClick={handleSaveAccount} className="w-full rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800">保存修改</button>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[11px] text-gray-500">等待中</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-gray-900">{props.settings?.pending ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginLogCard logs={logs} logPage={logPage} pageSize={pageSize} totalPages={totalPages} setLogPage={setLogPage} />
|
||||
<div className="mt-4">
|
||||
<label className="mb-1.5 block text-xs text-gray-500">总并发上限</label>
|
||||
<div className="flex h-9 items-stretch">
|
||||
<button type="button" onClick={() => setBounded(props.concurrency - 1)} disabled={props.concurrency <= 1} className="flex w-10 items-center justify-center rounded-l-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="减少并发">
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
value={props.concurrency}
|
||||
onChange={(event) => setBounded(Number(event.target.value) || 1)}
|
||||
className="min-w-0 flex-1 border-y border-gray-300 px-2 text-center text-sm font-medium text-black outline-none focus:ring-2 focus:ring-inset focus:ring-black"
|
||||
/>
|
||||
<button type="button" onClick={() => setBounded(props.concurrency + 1)} disabled={props.concurrency >= 16} className="flex w-10 items-center justify-center rounded-r-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-30" title="增加并发">
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存队列设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -292,7 +448,7 @@ interface WebSSHOriginCardProps {
|
||||
|
||||
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
|
||||
<Terminal className="h-4 w-4" />WebSSH Origin 白名单
|
||||
@@ -307,7 +463,7 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
<textarea
|
||||
value={props.originsText}
|
||||
onChange={(e) => props.onOriginsTextChange(e.target.value)}
|
||||
rows={5}
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
|
||||
/>
|
||||
</div>
|
||||
@@ -315,10 +471,12 @@ function WebSSHOriginCard(props: WebSSHOriginCardProps) {
|
||||
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>当前面板来源:{props.settings?.current_origin || '-'}</div>
|
||||
<div className="mt-1">默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。</div>
|
||||
</div>
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.saving} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.saving ? '保存中...' : '保存 Origin 白名单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -425,10 +583,12 @@ function SSLCard(props: SSLCardProps) {
|
||||
保存后自动重启服务并立即生效
|
||||
</label>
|
||||
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={props.onSave} disabled={props.savingSSL} className="inline-flex items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Upload className="h-4 w-4" />
|
||||
{props.savingSSL ? '保存中...' : '保存 SSL 设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, HardDrive, RefreshCw, Save } from 'lucide-react'
|
||||
import { getStorageInfo, updateStoragePools, StorageDisk, StorageInfo, StoragePool } from '../services/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
const contentOptions = [
|
||||
['lxc', 'LXC 容器'],
|
||||
['kvm', 'KVM 磁盘'],
|
||||
['images', '镜像缓存'],
|
||||
['snapshots', '快照'],
|
||||
['backups', '备份'],
|
||||
] as const
|
||||
|
||||
const contentLabels = Object.fromEntries(contentOptions)
|
||||
|
||||
const contentColors: Record<string, string> = {
|
||||
lxc: '#2563eb',
|
||||
kvm: '#7c3aed',
|
||||
images: '#d97706',
|
||||
snapshots: '#059669',
|
||||
backups: '#0891b2',
|
||||
}
|
||||
|
||||
export default function Storage() {
|
||||
const { t } = useLanguage()
|
||||
const [info, setInfo] = useState<StorageInfo | null>(null)
|
||||
const [pools, setPools] = useState<StoragePool[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getStorageInfo()
|
||||
const data = res.data.data || { pools: [], disks: [], content_types: [] }
|
||||
setInfo(data)
|
||||
setPools(data.pools || [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveMessage) return
|
||||
const timer = window.setTimeout(() => setSaveMessage(null), 3500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [saveMessage])
|
||||
|
||||
const mountedDisks = useMemo(() => (info?.disks || []).filter((disk) => !!disk.mount_point), [info?.disks])
|
||||
|
||||
const save = async () => {
|
||||
setSaveMessage(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const normalized = pools
|
||||
.map((pool) => ({
|
||||
...pool,
|
||||
id: (pool.id || pool.name || '').trim(),
|
||||
name: (pool.name || '').trim(),
|
||||
path: (pool.path || '').trim(),
|
||||
content_types: pool.content_types || [],
|
||||
default_contents: (pool.default_contents || []).filter((item) => (pool.content_types || []).includes(item)),
|
||||
enabled: pool.enabled !== false,
|
||||
}))
|
||||
const res = await updateStoragePools(normalized)
|
||||
const data = res.data.data
|
||||
if (data) {
|
||||
setInfo(data)
|
||||
setPools(data.pools || [])
|
||||
}
|
||||
setSaveMessage({ type: 'success', text: '存储配置已保存' })
|
||||
} catch (err: any) {
|
||||
setSaveMessage({ type: 'error', text: err?.response?.data?.message || '保存存储配置失败' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateDiskPool = (disk: StorageDisk, updater: (pool: StoragePool) => StoragePool) => {
|
||||
setPools((current) => {
|
||||
const index = current.findIndex((pool) => poolForDisk(pool, disk))
|
||||
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
|
||||
const nextPool = updater(base)
|
||||
if (index >= 0) {
|
||||
return current.map((item, i) => i === index ? nextPool : item)
|
||||
}
|
||||
return [...current, nextPool]
|
||||
})
|
||||
}
|
||||
|
||||
const toggleContent = (disk: StorageDisk, content: string) => {
|
||||
updateDiskPool(disk, (pool) => {
|
||||
const current = pool.content_types || []
|
||||
const enabled = current.includes(content)
|
||||
const contentTypes = enabled ? current.filter((item) => item !== content) : [...current, content]
|
||||
return {
|
||||
...pool,
|
||||
enabled: true,
|
||||
content_types: contentTypes,
|
||||
default_contents: (pool.default_contents || []).filter((item) => contentTypes.includes(item)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDefault = (disk: StorageDisk, content: string) => {
|
||||
setPools((current) => {
|
||||
const index = current.findIndex((pool) => poolForDisk(pool, disk))
|
||||
const base = index >= 0 ? current[index] : defaultPoolForDisk(disk)
|
||||
if (!(base.content_types || []).includes(content)) return current
|
||||
const hasDefault = (base.default_contents || []).includes(content)
|
||||
const baseDefaults = (base.default_contents || []).filter((value) => value !== content)
|
||||
const cleared = current.map((item) => ({
|
||||
...item,
|
||||
default_contents: (item.default_contents || []).filter((value) => value !== content),
|
||||
}))
|
||||
const nextPool = {
|
||||
...base,
|
||||
default_contents: hasDefault ? baseDefaults : [...baseDefaults, content],
|
||||
}
|
||||
if (index >= 0) {
|
||||
return cleared.map((item, i) => i === index ? nextPool : item)
|
||||
}
|
||||
return [...cleared, nextPool]
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-black dark:text-white">{t('存储管理')}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetchData} className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<RefreshCw className="h-4 w-4" />{t('刷新')}
|
||||
</button>
|
||||
<button onClick={save} disabled={saving} className="inline-flex items-center gap-2 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-4 w-4" />{t(saving ? '保存中...' : '保存')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMessage && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-2 text-sm ${
|
||||
saveMessage.type === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.type === 'success'
|
||||
? <CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
: <AlertCircle className="h-4 w-4 shrink-0" />}
|
||||
<span>{t(saveMessage.text)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||
<table className="w-full min-w-[1240px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('磁盘')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('空间分布')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('用于存储')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{mountedDisks.length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-4 py-10 text-center text-gray-400">{t('未检测到已挂载磁盘')}</td></tr>
|
||||
) : mountedDisks.map((disk) => {
|
||||
const pool = pools.find((item) => poolForDisk(item, disk))
|
||||
const contentUsage = contentUsageMap(pool?.content_usage || disk.content_usage || [])
|
||||
const clicdUsed = pool?.clicd_used_bytes || disk.clicd_used_bytes || 0
|
||||
return (
|
||||
<tr key={`${disk.path}-${disk.mount_point}`} className="align-top hover:bg-gray-50/70">
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-md bg-gray-100 text-gray-600">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-xs font-medium text-gray-900">{disk.path || disk.name}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{disk.model || disk.fstype || disk.type || '-'}</div>
|
||||
<div className="mt-1 font-mono text-xs text-gray-400">{disk.mount_point}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<DiskUsageBar disk={disk} contentUsage={contentUsage} clicdUsed={clicdUsed} />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex min-w-[620px] flex-nowrap items-start gap-2">
|
||||
{contentOptions.map(([value, label]) => {
|
||||
const checked = (pool?.content_types || []).includes(value)
|
||||
const isDefault = (pool?.default_contents || []).includes(value)
|
||||
return (
|
||||
<div key={value} className={`w-[116px] shrink-0 rounded-md border px-2.5 py-2 ${checked ? 'border-gray-300 bg-white' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-700">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleContent(disk, value)} />
|
||||
{t(label)}
|
||||
</label>
|
||||
{checked && (
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2 border-t border-gray-100 pt-1.5">
|
||||
<span className="text-[11px] text-gray-500">{t('默认盘')}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isDefault}
|
||||
title={isDefault ? `${t('关闭')} ${t(label)} ${t('默认盘')}` : `${t('设为')} ${t(label)} ${t('默认盘')}`}
|
||||
onClick={() => toggleDefault(disk, value)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 appearance-none items-center rounded-full border p-0 transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 ${isDefault ? 'border-black bg-black' : 'border-gray-300 bg-gray-200'}`}
|
||||
>
|
||||
<span className={`pointer-events-none absolute left-0.5 top-0.5 block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${isDefault ? 'translate-x-4' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskUsageBar({
|
||||
disk,
|
||||
contentUsage,
|
||||
clicdUsed,
|
||||
}: {
|
||||
disk: StorageDisk
|
||||
contentUsage: Record<string, number>
|
||||
clicdUsed: number
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const total = Math.max(0, disk.size_bytes || 0)
|
||||
const free = Math.max(0, Math.min(total, disk.free_bytes || 0))
|
||||
const used = Math.max(0, total - free)
|
||||
const rawContentSegments = contentOptions.map(([value, label]) => ({
|
||||
key: value,
|
||||
label,
|
||||
size: Math.max(0, contentUsage[value] || 0),
|
||||
color: contentColors[value],
|
||||
}))
|
||||
const rawContentTotal = rawContentSegments.reduce((sum, segment) => sum + segment.size, 0)
|
||||
const normalizedClicdUsed = Math.max(0, Math.min(used, Math.max(clicdUsed || 0, rawContentTotal)))
|
||||
const contentScale = rawContentTotal > normalizedClicdUsed && rawContentTotal > 0
|
||||
? normalizedClicdUsed / rawContentTotal
|
||||
: 1
|
||||
const contentSegments = rawContentSegments.map((segment) => ({ ...segment, size: segment.size * contentScale }))
|
||||
const categorizedClicdUsed = contentSegments.reduce((sum, segment) => sum + segment.size, 0)
|
||||
const unclassifiedClicdUsed = Math.max(0, normalizedClicdUsed - categorizedClicdUsed)
|
||||
const nonClicdUsed = Math.max(0, used - normalizedClicdUsed)
|
||||
const segments = [
|
||||
...contentSegments,
|
||||
{ key: 'clicd-other', label: 'CLICD 其他', size: unclassifiedClicdUsed, color: '#111827' },
|
||||
{ key: 'other', label: '非 CLICD', size: nonClicdUsed, color: '#4b5563' },
|
||||
{ key: 'free', label: '可用空间', size: free, color: '#e5e7eb' },
|
||||
].filter((segment) => segment.size > 0)
|
||||
|
||||
return (
|
||||
<div className="min-w-[420px] max-w-[620px]">
|
||||
<div className="flex items-center justify-between gap-4 text-xs text-gray-600">
|
||||
<span>{t('已用')} {formatBytes(used)} / {formatBytes(total)}</span>
|
||||
<span>{usagePct(used, total).toFixed(1)}% · {t('可用')} {formatBytes(free)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex h-8 w-full overflow-hidden rounded-md border border-gray-300 bg-gray-100">
|
||||
{segments.map((segment) => {
|
||||
const pct = usagePct(segment.size, total)
|
||||
return (
|
||||
<div
|
||||
key={segment.key}
|
||||
title={`${t(segment.label)}: ${formatBytes(segment.size)} (${pct.toFixed(2)}%)`}
|
||||
className="flex h-full items-center justify-center overflow-hidden border-r border-white/70 text-[10px] font-medium text-white last:border-r-0"
|
||||
style={{ width: `${pct}%`, minWidth: pct > 0 && pct < 0.6 ? '3px' : undefined, backgroundColor: segment.color }}
|
||||
>
|
||||
{pct >= 9 && <span className={segment.key === 'free' ? 'text-gray-600' : ''}>{t(segment.label)}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{segments.map((segment) => (
|
||||
<div key={segment.key} className="flex items-center gap-1.5 text-[11px] text-gray-600">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm border border-black/5" style={{ backgroundColor: segment.color }} />
|
||||
<span>{t(segment.label)}</span>
|
||||
<span className="font-medium text-gray-800">{formatBytes(segment.size)}</span>
|
||||
<span className="text-gray-400">{usagePct(segment.size, total).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function poolForDisk(pool: StoragePool, disk: StorageDisk) {
|
||||
if (!disk.mount_point) return false
|
||||
const mount = cleanPath(disk.mount_point)
|
||||
const poolMount = cleanPath(pool.mount_point || '')
|
||||
const poolPath = cleanPath(pool.path || '')
|
||||
return poolMount === mount || poolPath === mount || poolPath.startsWith(`${mount}/`)
|
||||
}
|
||||
|
||||
function defaultPoolForDisk(disk: StorageDisk): StoragePool {
|
||||
const mount = cleanPath(disk.mount_point || '/')
|
||||
const baseName = mount === '/' ? 'system' : mount.split('/').filter(Boolean).pop() || disk.name || 'disk'
|
||||
const primaryContents = mount === '/' ? contentOptions.map(([value]) => value) : []
|
||||
return {
|
||||
id: `disk-${slugID(mount === '/' ? 'root' : baseName)}`,
|
||||
name: `${baseName} (${disk.path || disk.name})`,
|
||||
path: mount === '/' ? '/var/lib/clicd' : `${mount}/clicd`,
|
||||
content_types: primaryContents,
|
||||
default_contents: [...primaryContents],
|
||||
enabled: true,
|
||||
mount_point: disk.mount_point,
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPath(value: string) {
|
||||
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || '/'
|
||||
}
|
||||
|
||||
function slugID(value: string) {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'storage'
|
||||
}
|
||||
|
||||
function contentUsageMap(items: Array<{ content_type: string; size_bytes: number }>) {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.content_type] = (acc[item.content_type] || 0) + (item.size_bytes || 0)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function usagePct(used: number, total: number) {
|
||||
if (!total || total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, (used / total) * 100))
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!bytes) return '-'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index++
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
|
||||
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
|
||||
import { useDialog } from '../components/Dialog'
|
||||
import api, { AuditLog, LoginLog } from '../services/api'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
|
||||
interface SubUserItem {
|
||||
@@ -9,6 +10,9 @@ interface SubUserItem {
|
||||
username: string
|
||||
container_names: string[]
|
||||
container_uuids: string[]
|
||||
allowed_image_ids?: string[]
|
||||
image_limit_configured?: boolean
|
||||
current_image_ids?: string[]
|
||||
container_name: string
|
||||
container_uuid: string
|
||||
access_code: string
|
||||
@@ -28,12 +32,18 @@ interface AuditLogExt extends AuditLog {
|
||||
|
||||
export default function SubUserManagement() {
|
||||
const dialog = useDialog()
|
||||
const { t } = useLanguage()
|
||||
const [users, setUsers] = useState<SubUserItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
|
||||
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
||||
const [modalTitle, setModalTitle] = useState('')
|
||||
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
||||
const [imageUser, setImageUser] = useState<SubUserItem | null>(null)
|
||||
const [images, setImages] = useState<ImageInfo[]>([])
|
||||
const [selectedImageIDs, setSelectedImageIDs] = useState<string[]>([])
|
||||
const [imagesLoading, setImagesLoading] = useState(false)
|
||||
const [savingImages, setSavingImages] = useState(false)
|
||||
const [rotatingPassword, setRotatingPassword] = useState(false)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
const [logPageSize, setLogPageSize] = useState(10)
|
||||
@@ -78,6 +88,46 @@ export default function SubUserManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const openImageLimit = async (user: SubUserItem) => {
|
||||
setImageUser(user)
|
||||
setSelectedImageIDs(user.allowed_image_ids || [])
|
||||
setImagesLoading(true)
|
||||
try {
|
||||
const res = await getImages()
|
||||
const currentIDs = new Set(user.current_image_ids || [])
|
||||
setImages((res.data.data || []).filter((image) => image.downloaded && (image.enabled || currentIDs.has(image.id))))
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('加载失败', error.response?.data?.message || '获取镜像列表失败')
|
||||
} finally {
|
||||
setImagesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleImageID = (id: string) => {
|
||||
setSelectedImageIDs((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])
|
||||
}
|
||||
|
||||
const saveImageLimit = async () => {
|
||||
if (!imageUser) return
|
||||
setSavingImages(true)
|
||||
try {
|
||||
const res = await updateSubUserImages(imageUser.id, selectedImageIDs)
|
||||
const updated = {
|
||||
...imageUser,
|
||||
allowed_image_ids: res.data.data?.allowed_image_ids || selectedImageIDs,
|
||||
image_limit_configured: true,
|
||||
}
|
||||
setUsers((prev) => prev.map((item) => (item.id === imageUser.id ? { ...item, allowed_image_ids: updated.allowed_image_ids, image_limit_configured: true } : item)))
|
||||
setImageUser(null)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
dialog.alert('保存失败', error.response?.data?.message || '保存可用镜像失败')
|
||||
} finally {
|
||||
setSavingImages(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showAuditLogs = async (user: SubUserItem) => {
|
||||
try {
|
||||
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
||||
@@ -125,8 +175,10 @@ export default function SubUserManagement() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-black dark:text-white">子用户管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">容器分配的子用户列表,共 {users.length} 个</p>
|
||||
<h1 className="text-xl font-semibold text-black dark:text-white">{t('子用户管理')}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('容器分配的子用户列表,共')} {users.length} {t('个')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
@@ -190,6 +242,14 @@ export default function SubUserManagement() {
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
登录日志
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openImageLimit(user)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors"
|
||||
title="可用镜像"
|
||||
>
|
||||
<HardDrive className="w-3.5 h-3.5" />
|
||||
可用镜像
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -253,6 +313,75 @@ export default function SubUserManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageUser && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">可用镜像</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{imageUser.username} · 默认勾选当前系统,取消后将禁止重装该系统</p>
|
||||
</div>
|
||||
<button onClick={() => setImageUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{imagesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-7 w-7 animate-spin rounded-full border-b-2 border-black" />
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-10 text-center text-sm text-gray-500">
|
||||
暂无已下载并启用的镜像
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{images.map((image) => {
|
||||
const checked = selectedImageIDs.includes(image.id)
|
||||
const current = (imageUser.current_image_ids || []).includes(image.id)
|
||||
return (
|
||||
<label
|
||||
key={image.id}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-3 text-sm transition-colors ${checked ? 'border-black bg-gray-50 dark:border-white dark:bg-gray-800' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleImageID(image.id)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-black dark:text-white">{image.name}{current ? '(当前系统)' : ''}</span>
|
||||
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||
{image.type.toUpperCase()} · {image.arch} · {image.distro} {image.release}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">已选择 {selectedImageIDs.length} 个镜像</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setImageUser(null)} className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 rounded-md">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={saveImageLimit}
|
||||
disabled={savingImages || imagesLoading}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{savingImages ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Modal */}
|
||||
{(auditLogs || loginLogs) && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
|
||||
+187
-10
@@ -47,6 +47,7 @@ export interface PortMapping {
|
||||
|
||||
export interface FirewallRule {
|
||||
id: string
|
||||
network?: 'ipv4' | 'ipv6' | 'all'
|
||||
direction: 'in' | 'out'
|
||||
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
|
||||
port: string
|
||||
@@ -74,11 +75,15 @@ export interface Container {
|
||||
uuid: string
|
||||
name: string
|
||||
virtualization?: string
|
||||
storage_pool_id?: string
|
||||
storage_path?: string
|
||||
template: string
|
||||
vcpu: number
|
||||
ram_mb: number
|
||||
disk_gb: number
|
||||
network_bw_mbps: number
|
||||
network_down_mbps: number
|
||||
network_up_mbps: number
|
||||
monthly_traffic_gb: number
|
||||
traffic_mode: string
|
||||
traffic_in_gb: number
|
||||
@@ -87,8 +92,16 @@ export interface Container {
|
||||
traffic_used_tx: number
|
||||
traffic_reset_date: string
|
||||
io_speed_mbps: number
|
||||
io_read_mbps: number
|
||||
io_write_mbps: number
|
||||
status: string
|
||||
ip: string
|
||||
lan_ipv4_mode?: string
|
||||
lan_interface?: string
|
||||
lan_ipv4_address?: string
|
||||
lan_ipv4_prefix_len?: number
|
||||
lan_ipv4_gateway?: string
|
||||
mac_address?: string
|
||||
public_ipv4s?: PublicIPv4Assignment[]
|
||||
ipv6: string
|
||||
ipv6_prefix_len: number
|
||||
@@ -100,6 +113,7 @@ export interface Container {
|
||||
port_mappings: PortMapping[]
|
||||
port_mapping_limit: number
|
||||
firewall_enabled: boolean
|
||||
firewall_default_action: 'ACCEPT' | 'DROP'
|
||||
firewall_rules: FirewallRule[]
|
||||
snapshot_limit: number
|
||||
created_at: string
|
||||
@@ -131,19 +145,29 @@ export interface CreateContainerRequest {
|
||||
name: string
|
||||
virtualization: string
|
||||
template_id: string
|
||||
storage_pool_id?: string
|
||||
vcpu: number
|
||||
cpu_percent: number
|
||||
ram_mb: number
|
||||
disk_gb: number
|
||||
network_bw_mbps: number
|
||||
network_down_mbps: number
|
||||
network_up_mbps: number
|
||||
monthly_traffic_gb: number
|
||||
traffic_mode: string
|
||||
traffic_in_gb: number
|
||||
traffic_out_gb: number
|
||||
io_speed_mbps: number
|
||||
io_read_mbps: number
|
||||
io_write_mbps: number
|
||||
extra_ports: number[]
|
||||
port_mapping_count: number
|
||||
assign_nat?: boolean
|
||||
lan_ipv4_mode?: string
|
||||
lan_interface?: string
|
||||
lan_ipv4_address?: string
|
||||
lan_ipv4_prefix_len?: number
|
||||
lan_ipv4_gateway?: string
|
||||
snapshot_limit: number
|
||||
assign_ipv4?: boolean
|
||||
ipv4_count?: number
|
||||
@@ -154,9 +178,56 @@ export interface CreateContainerRequest {
|
||||
ssh_auth_mode?: string
|
||||
ssh_password?: string
|
||||
ssh_public_key?: string
|
||||
allowed_image_ids?: string[]
|
||||
image_limit_configured?: boolean
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface StoragePool {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
content_types: string[]
|
||||
default_contents?: string[]
|
||||
enabled: boolean
|
||||
available?: boolean
|
||||
exists?: boolean
|
||||
size_bytes?: number
|
||||
used_bytes?: number
|
||||
free_bytes?: number
|
||||
mount_point?: string
|
||||
clicd_used_bytes?: number
|
||||
content_usage?: StorageContentUsage[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface StorageContentUsage {
|
||||
content_type: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export interface StorageDisk {
|
||||
name: string
|
||||
path: string
|
||||
type: string
|
||||
fstype: string
|
||||
mount_point: string
|
||||
model: string
|
||||
size_bytes: number
|
||||
used_bytes: number
|
||||
free_bytes: number
|
||||
storage_pool_id?: string
|
||||
storage_path?: string
|
||||
clicd_used_bytes?: number
|
||||
content_usage?: StorageContentUsage[]
|
||||
}
|
||||
|
||||
export interface StorageInfo {
|
||||
pools: StoragePool[]
|
||||
disks: StorageDisk[]
|
||||
content_types: string[]
|
||||
}
|
||||
|
||||
export interface ReinstallContainerOptions {
|
||||
ssh_auth_mode?: string
|
||||
ssh_password?: string
|
||||
@@ -225,6 +296,31 @@ 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 CreateSnapshotOptions {
|
||||
storage_pool_id?: string
|
||||
}
|
||||
|
||||
export interface HostMetricPoint {
|
||||
ts: number
|
||||
cpu: number
|
||||
memory: number
|
||||
network: number
|
||||
network_rx: number
|
||||
network_tx: number
|
||||
disk_io: number
|
||||
disk_read: number
|
||||
disk_write: number
|
||||
disk_usage_pct: number
|
||||
}
|
||||
|
||||
export interface HostProbeReport {
|
||||
@@ -335,6 +431,18 @@ export interface ContainerUsage {
|
||||
guest_metrics?: boolean
|
||||
}
|
||||
|
||||
export interface ContainerMetricPoint {
|
||||
ts: number
|
||||
cpu: number
|
||||
memory: number
|
||||
network: number
|
||||
network_rx: number
|
||||
network_tx: number
|
||||
disk_io: number
|
||||
disk_read: number
|
||||
disk_write: number
|
||||
}
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
@@ -374,6 +482,18 @@ export interface AuditLog {
|
||||
export const getLoginLogs = () =>
|
||||
api.get<APIResponse<LoginLog[]>>('/login-logs')
|
||||
|
||||
export interface TaskQueueSettings {
|
||||
concurrency: number
|
||||
active: number
|
||||
pending: number
|
||||
}
|
||||
|
||||
export const getTaskQueueSettings = () =>
|
||||
api.get<APIResponse<TaskQueueSettings>>('/task-queue/settings')
|
||||
|
||||
export const updateTaskQueueSettings = (concurrency: number) =>
|
||||
api.put<APIResponse<TaskQueueSettings>>('/task-queue/settings', { concurrency })
|
||||
|
||||
export interface SSLCertificateInfo {
|
||||
subject: string
|
||||
issuer: string
|
||||
@@ -457,6 +577,9 @@ export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
|
||||
export const getContainerUsage = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
|
||||
|
||||
export const getContainerHistory = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<ContainerMetricPoint[]>>(`/containers/${id}/history`)
|
||||
|
||||
export interface TrafficInfo {
|
||||
total_used_bytes: number
|
||||
rx_used_bytes: number
|
||||
@@ -486,8 +609,12 @@ export const updateTrafficLimit = (id: ContainerIdentifier, data: {
|
||||
export const updateResourceLimit = (id: ContainerIdentifier, data: {
|
||||
vcpu: number
|
||||
ram_mb: number
|
||||
io_speed_mbps: number
|
||||
network_bw_mbps: number
|
||||
io_speed_mbps?: number
|
||||
io_read_mbps?: number
|
||||
io_write_mbps?: number
|
||||
network_bw_mbps?: number
|
||||
network_down_mbps?: number
|
||||
network_up_mbps?: number
|
||||
}) =>
|
||||
api.put<APIResponse>(`/containers/${id}/resource-limit`, data)
|
||||
|
||||
@@ -501,10 +628,10 @@ export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
|
||||
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
|
||||
|
||||
export const getFirewall = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
|
||||
api.get<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
|
||||
|
||||
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; rules?: FirewallRule[] }) =>
|
||||
api.put<APIResponse<{ enabled: boolean; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||
export const updateFirewall = (id: ContainerIdentifier, data: { enabled?: boolean; default_action?: 'ACCEPT' | 'DROP'; rules?: FirewallRule[] }) =>
|
||||
api.put<APIResponse<{ enabled: boolean; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`, data)
|
||||
|
||||
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
|
||||
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
|
||||
@@ -515,12 +642,29 @@ export const getIPv6Status = () =>
|
||||
export const assignIPv6 = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
|
||||
|
||||
export interface IPAssignmentUpdateRequest {
|
||||
mode: 'clear' | 'random' | 'custom'
|
||||
count?: number
|
||||
addresses?: string[]
|
||||
}
|
||||
|
||||
export const updatePublicIPv4Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
|
||||
api.put<APIResponse<Container>>(`/containers/${id}/public-ipv4`, data)
|
||||
|
||||
export const updateIPv6Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
|
||||
api.put<APIResponse<Container>>(`/containers/${id}/ipv6-addresses`, data)
|
||||
|
||||
export interface RouteCapacity {
|
||||
used: number
|
||||
remaining: string
|
||||
total: string
|
||||
}
|
||||
|
||||
export interface NAT4PortRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface NAT4Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
@@ -545,6 +689,19 @@ export interface IPv4Route {
|
||||
gateway?: string
|
||||
}
|
||||
|
||||
export interface LANDHCPRoute {
|
||||
container_id: number
|
||||
container_name: string
|
||||
lxc_name: string
|
||||
status: string
|
||||
address: string
|
||||
interface: string
|
||||
prefix_len?: number
|
||||
gateway?: string
|
||||
mac_address?: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface IPv6Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
@@ -557,11 +714,14 @@ export interface IPv6Route {
|
||||
|
||||
export interface RoutingInfo {
|
||||
nat4: RouteCapacity
|
||||
nat4_port_range: NAT4PortRange
|
||||
ipv4: RouteCapacity
|
||||
lan_dhcp: RouteCapacity
|
||||
ipv6: RouteCapacity
|
||||
host_public_ipv4?: PublicIPv4Info
|
||||
public_ipv4_addresses: PublicIPv4Info[]
|
||||
ipv4_assignments: IPv4Route[]
|
||||
lan_dhcp_assignments: LANDHCPRoute[]
|
||||
nat4_mappings: NAT4Route[]
|
||||
ipv6_assignments: IPv6Route[]
|
||||
ipv6_prefixes: IPv6PrefixInfo[]
|
||||
@@ -576,7 +736,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[]) =>
|
||||
@@ -629,8 +789,8 @@ export const deleteImage = (templateId: string) =>
|
||||
export const toggleImage = (templateId: string, enabled: boolean) =>
|
||||
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
||||
|
||||
export const getEnabledImages = (virtualization = 'lxc') =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
|
||||
export const getEnabledImages = (virtualization = 'lxc', container?: ContainerIdentifier) =>
|
||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization, ...(container ? { container: String(container) } : {}) } })
|
||||
|
||||
// Dashboard
|
||||
export const getDashboard = () =>
|
||||
@@ -639,9 +799,18 @@ export const getDashboard = () =>
|
||||
export const getHostInfo = () =>
|
||||
api.get<APIResponse<HostInfo>>('/host-info')
|
||||
|
||||
export const getHostHistory = () =>
|
||||
api.get<APIResponse<HostMetricPoint[]>>('/host-history')
|
||||
|
||||
export const getHostReport = () =>
|
||||
api.get<APIResponse<HostProbeReport>>('/host-report')
|
||||
|
||||
export const getStorageInfo = () =>
|
||||
api.get<APIResponse<StorageInfo>>('/storage')
|
||||
|
||||
export const updateStoragePools = (pools: StoragePool[]) =>
|
||||
api.put<APIResponse<StorageInfo>>('/storage', { pools })
|
||||
|
||||
// Snapshots
|
||||
export interface Snapshot {
|
||||
id: string
|
||||
@@ -676,8 +845,8 @@ export const getSnapshots = () =>
|
||||
export const getContainerSnapshots = (id: ContainerIdentifier) =>
|
||||
api.get<APIResponse<ContainerSnapshotsResponse>>(`/containers/${id}/snapshots`)
|
||||
|
||||
export const createContainerSnapshot = (id: ContainerIdentifier) =>
|
||||
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, {}, { timeout: 600000 })
|
||||
export const createContainerSnapshot = (id: ContainerIdentifier, options?: CreateSnapshotOptions) =>
|
||||
api.post<APIResponse<Snapshot>>(`/containers/${id}/snapshots`, options || {}, { timeout: 600000 })
|
||||
|
||||
export const deleteContainerSnapshot = (id: ContainerIdentifier, snapshotId: string) =>
|
||||
api.delete<APIResponse>(`/containers/${id}/snapshots/${snapshotId}`, { timeout: 600000 })
|
||||
@@ -719,6 +888,8 @@ export interface Task {
|
||||
container_name: string
|
||||
status: string
|
||||
error?: string
|
||||
stage?: string
|
||||
stage_detail?: string
|
||||
created_at: string
|
||||
template_id?: string
|
||||
config?: CreateContainerRequest
|
||||
@@ -743,6 +914,9 @@ export interface SubUser {
|
||||
password?: string
|
||||
container_names: string[]
|
||||
container_uuids?: string[]
|
||||
allowed_image_ids?: string[]
|
||||
image_limit_configured?: boolean
|
||||
current_image_ids?: string[]
|
||||
access_code: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -750,6 +924,9 @@ export interface SubUser {
|
||||
export const createSubUser = (containerId: ContainerIdentifier) =>
|
||||
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
|
||||
|
||||
export const updateSubUserImages = (id: string, allowedImageIds: string[]) =>
|
||||
api.put<APIResponse<SubUser>>(`/sub-users/${id}/images`, { allowed_image_ids: allowedImageIds })
|
||||
|
||||
// Audit Logs
|
||||
export interface AuditLog {
|
||||
time: string
|
||||
|
||||
+250
-1
@@ -92,6 +92,22 @@ const exact: Record<string, string> = {
|
||||
'创建时间': 'Created At',
|
||||
'网络速率': 'Network Speed',
|
||||
'IO 速度': 'IO Speed',
|
||||
'下行带宽': 'Download Bandwidth',
|
||||
'上行带宽': 'Upload Bandwidth',
|
||||
'读取 IO': 'Read IO',
|
||||
'写入 IO': 'Write IO',
|
||||
'下行带宽 (Mbps)': 'Download Bandwidth (Mbps)',
|
||||
'上行带宽 (Mbps)': 'Upload Bandwidth (Mbps)',
|
||||
'读取 IO (MB/s)': 'Read IO (MB/s)',
|
||||
'写入 IO (MB/s)': 'Write IO (MB/s)',
|
||||
'下行带宽 (Mbps,0=不限制)': 'Download Bandwidth (Mbps, 0=unlimited)',
|
||||
'上行带宽 (Mbps,0=不限制)': 'Upload Bandwidth (Mbps, 0=unlimited)',
|
||||
'读取 IO (MB/s,0=不限制)': 'Read IO (MB/s, 0=unlimited)',
|
||||
'写入 IO (MB/s,0=不限制)': 'Write IO (MB/s, 0=unlimited)',
|
||||
'限速占用': 'Limit Usage',
|
||||
'支持独立限制上行/下行带宽和读/写 I/O 操作。': 'Supports independent upload/download bandwidth limits and read/write I/O limits.',
|
||||
'支持独立限制上行/下行带宽和读/写 I/O 操作。network_bw_mbps 与 io_speed_mbps 为旧版对称限制兼容别名,建议新对接使用 network_down_mbps、network_up_mbps、io_read_mbps、io_write_mbps。': 'Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.',
|
||||
'支持独立限制下行/上行带宽和读取/写入 I/O。未传字段保持原值,显式传 0 表示该方向不限速;network_bw_mbps 与 io_speed_mbps 为旧版对称限制兼容别名。': 'Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.',
|
||||
'月流量': 'Monthly Traffic',
|
||||
'统计信息': 'Statistics',
|
||||
'CPU 使用率': 'CPU Usage',
|
||||
@@ -376,6 +392,23 @@ const exact: Record<string, string> = {
|
||||
'暂未获取到宿主机信息': 'No host information available',
|
||||
'面板资源状态与容器概览': 'Panel resource status and container overview',
|
||||
'宿主机资源状态与容器概览': 'Host resource status and container overview',
|
||||
'存储管理': 'Storage Management',
|
||||
'只显示已挂载磁盘;勾选后,对应功能可以选择该磁盘保存数据。': 'Only mounted disks are shown. Enable a content type to make that disk available to the corresponding feature.',
|
||||
'空间分布': 'Space Distribution',
|
||||
'用于存储': 'Storage Usage',
|
||||
'未检测到已挂载磁盘': 'No mounted disks detected',
|
||||
'镜像缓存': 'Image Cache',
|
||||
'备份': 'Backups',
|
||||
'默认盘': 'Default Disk',
|
||||
'设为': 'Set as',
|
||||
'CLICD 其他': 'Other CLICD Data',
|
||||
'非 CLICD': 'Non-CLICD Data',
|
||||
'可用空间': 'Free Space',
|
||||
'存储配置已保存': 'Storage settings saved',
|
||||
'保存存储配置失败': 'Failed to save storage settings',
|
||||
'任务队列、账号、安全证书与访问记录': 'Task queue, account, certificates, and access records',
|
||||
'设置分类': 'Settings categories',
|
||||
'WebSSH 访问': 'WebSSH Access',
|
||||
'账号设置': 'Account Settings',
|
||||
'当前用户名': 'Current Username',
|
||||
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
|
||||
@@ -385,6 +418,12 @@ const exact: Record<string, string> = {
|
||||
'至少 6 位': 'At least 6 characters',
|
||||
'输入当前密码以确认修改': 'Enter current password to confirm changes',
|
||||
'保存修改': 'Save Changes',
|
||||
'总并发上限': 'Total Concurrency Limit',
|
||||
'减少并发': 'Decrease concurrency',
|
||||
'增加并发': 'Increase concurrency',
|
||||
'保存队列设置': 'Save Queue Settings',
|
||||
'任务队列并发设置已保存并立即生效': 'Task queue concurrency saved and applied immediately',
|
||||
'任务队列设置保存失败': 'Failed to save task queue settings',
|
||||
'SSL 证书': 'SSL Certificate',
|
||||
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
|
||||
'IP / 域名': 'IP / Domain',
|
||||
@@ -578,7 +617,11 @@ const exact: Record<string, string> = {
|
||||
'更新 Key': 'Update Key',
|
||||
'删除 Key': 'Delete Key',
|
||||
'总览': 'Overview',
|
||||
'NAT/IPv4/IPv6 路由': 'NAT / IPv4 / IPv6 Routing',
|
||||
'NAT/IPv6 路由': 'NAT / IPv6 Routing',
|
||||
'更新公网 IPv4/IPv6 池': 'Update Public IPv4 / IPv6 Pools',
|
||||
'扫描公网 IPv4 段': 'Scan Public IPv4 Prefixes',
|
||||
'公网 IPv4/IPv6 池': 'Public IPv4 / IPv6 Pools',
|
||||
'任务队列': 'Task Queue',
|
||||
'任务列表': 'Task List',
|
||||
'操作记录': 'audit records',
|
||||
@@ -588,6 +631,7 @@ const exact: Record<string, string> = {
|
||||
'管理员接口': 'Admin API',
|
||||
'控制面板统计': 'Dashboard Stats',
|
||||
'立即安全检查': 'Run Security Check',
|
||||
'路由配置': 'Routing Configuration',
|
||||
'返回响应样例': 'Response Example',
|
||||
'请求参数': 'Request Parameters',
|
||||
'响应字段': 'Response Fields',
|
||||
@@ -616,11 +660,14 @@ const exact: Record<string, string> = {
|
||||
'添加端口映射': 'Add Port Mapping',
|
||||
'更新端口映射': 'Update Port Mapping',
|
||||
'删除端口映射': 'Delete Port Mapping',
|
||||
'获取防火墙设置': 'Get Firewall Settings',
|
||||
'更新防火墙设置': 'Update Firewall Settings',
|
||||
'快照总览': 'Snapshot Overview',
|
||||
'容器快照': 'Container Snapshots',
|
||||
'计划快照': 'Scheduled Snapshots',
|
||||
'快照配额': 'Snapshot Quota',
|
||||
'模板列表': 'Template List',
|
||||
'镜像管理列表': 'Image Management List',
|
||||
'取消镜像下载': 'Cancel Image Download',
|
||||
'启用/禁用镜像': 'Enable / Disable Image',
|
||||
'安全连接日志': 'Security Connection Logs',
|
||||
@@ -650,7 +697,6 @@ const exact: Record<string, string> = {
|
||||
'WebVNC 票据': 'WebVNC Ticket',
|
||||
'容器列表(兼容 POST 写法)': 'Container List (compatible POST form)',
|
||||
'调整到期时间': 'Adjust Expiration Time',
|
||||
'镜像管理列表': 'Image Management List',
|
||||
'批量创建容器': 'Batch Create Containers',
|
||||
'创建 WebSSH 票据': 'Create WebSSH Ticket',
|
||||
'创建 WebVNC 票据': 'Create WebVNC Ticket',
|
||||
@@ -677,6 +723,12 @@ const exact: Record<string, string> = {
|
||||
'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts',
|
||||
'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB',
|
||||
'***60秒有效票据***': '***60-second valid ticket***',
|
||||
'Linux 创建支持 ssh_auth_mode=auto_password|password|key;公网 IPv4、IPv6 与 NAT 可通过 assign_nat、assign_ipv4、assign_ipv6 组合使用。': 'Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.',
|
||||
'重装支持 ssh_auth_mode=keep|auto_password|password|key;keep 仅用于重装,未传 SSH 字段时保持原有行为。': 'Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.',
|
||||
'批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。': 'Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.',
|
||||
'action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。': 'When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.',
|
||||
'更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。': 'Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.',
|
||||
'扫描公网 IPv4 段需要 routing:write;verify=true 时会尝试校验地址可用性。': 'Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.',
|
||||
'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".',
|
||||
'该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.',
|
||||
'样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.',
|
||||
@@ -732,6 +784,32 @@ const exact: Record<string, string> = {
|
||||
'初始化失败': 'Initialization failed',
|
||||
'初始化完成': 'Initialization complete',
|
||||
'排队等待': 'Queued',
|
||||
'当前阶段': 'Current Stage',
|
||||
'准备初始化环境': 'Preparing initialization environment',
|
||||
'检查模板与创建参数': 'Checking template and creation settings',
|
||||
'下载模板并创建基础文件系统': 'Downloading template and creating root filesystem',
|
||||
'复制容器数据到存储磁盘': 'Copying container data to storage disk',
|
||||
'创建容量限制磁盘并复制 rootfs': 'Creating quota disk and copying rootfs',
|
||||
'配置 CPU、内存与网络限制': 'Configuring CPU, memory, and network limits',
|
||||
'分配 IPv4、IPv6 与 NAT 端口': 'Allocating IPv4, IPv6, and NAT ports',
|
||||
'保存容器配置': 'Saving container configuration',
|
||||
'写入容器网络配置': 'Writing container network configuration',
|
||||
'安装并配置 SSH 服务': 'Installing and configuring SSH',
|
||||
'检测并预配置 SSH 服务': 'Detecting and preconfiguring SSH',
|
||||
'转换非特权容器文件权限': 'Converting unprivileged container permissions',
|
||||
'设置容器登录凭据': 'Setting container login credentials',
|
||||
'启动容器并等待网络就绪': 'Starting container and waiting for network',
|
||||
'启动虚拟机并等待网络就绪': 'Starting VM and waiting for network',
|
||||
'检查 KVM 镜像与创建参数': 'Checking KVM image and creation settings',
|
||||
'选择虚拟机存储磁盘': 'Selecting VM storage disk',
|
||||
'分配 IPv4 与 IPv6 地址': 'Allocating IPv4 and IPv6 addresses',
|
||||
'创建 Windows 虚拟磁盘': 'Creating Windows virtual disk',
|
||||
'生成 Windows 自动应答配置': 'Generating Windows unattended setup',
|
||||
'创建 KVM 系统磁盘': 'Creating KVM system disk',
|
||||
'生成 cloud-init 初始化配置': 'Generating cloud-init configuration',
|
||||
'注册 KVM 虚拟机': 'Registering KVM virtual machine',
|
||||
'分配并配置 NAT 端口': 'Allocating and configuring NAT ports',
|
||||
'保存虚拟机配置': 'Saving virtual machine configuration',
|
||||
'处理中': 'Processing',
|
||||
'未知系统': 'Unknown system',
|
||||
'处理失败': 'Failed',
|
||||
@@ -793,7 +871,32 @@ const exact: Record<string, string> = {
|
||||
'独立 IPv4': 'Dedicated IPv4',
|
||||
'添加规则': 'Add Rule',
|
||||
'启用后默认拒绝所有入站和出站流量,仅放行下方规则': 'When enabled, all inbound and outbound traffic is blocked by default. Only the rules below are allowed.',
|
||||
'已启用,未匹配规则的流量将被拒绝': 'Enabled. Traffic that does not match a rule will be denied.',
|
||||
'已启用,未匹配规则的流量将被放行': 'Enabled. Traffic that does not match a rule will be allowed.',
|
||||
'未启用时不接管该容器流量': 'Disabled. Container traffic is not managed by this firewall.',
|
||||
'默认动作': 'Default Action',
|
||||
'没有命中下方规则时如何处理': 'How to handle traffic that does not match the rules below',
|
||||
'未匹配拒绝': 'Deny unmatched',
|
||||
'未匹配放行': 'Allow unmatched',
|
||||
'网络范围': 'Network Scope',
|
||||
'可配置:': 'Configurable: ',
|
||||
'可配置:IPv4(公网 IPv4)。 IPv4 规则覆盖独立公网 IPv4。': 'Configurable: IPv4 (Public IPv4). IPv4 rules apply to the dedicated public IPv4.',
|
||||
'可配置:IPv4(NAT)。 IPv4 规则覆盖IPv4 NAT 端口映射。 NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。': 'Configurable: IPv4 (NAT). IPv4 rules apply to IPv4 NAT port mappings. NAT inbound ports are matched by the container internal port, not the host public port.',
|
||||
'可配置:IPv6。 IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'Configurable: IPv6. IPv6 rules apply to the IPv6 addresses assigned to this container.',
|
||||
'可配置:IPv4(公网 IPv4)、IPv6。 IPv4 规则覆盖独立公网 IPv4。 IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'Configurable: IPv4 (Public IPv4), IPv6. IPv4 rules apply to the dedicated public IPv4. IPv6 rules apply to the IPv6 addresses assigned to this container.',
|
||||
'可配置:IPv4(NAT)、IPv6。 IPv4 规则覆盖IPv4 NAT 端口映射。 NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。 IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'Configurable: IPv4 (NAT), IPv6. IPv4 rules apply to IPv4 NAT port mappings. NAT inbound ports are matched by the container internal port, not the host public port. IPv6 rules apply to the IPv6 addresses assigned to this container.',
|
||||
'当前容器未分配 IPv4 NAT、独立公网 IPv4 或 IPv6,暂无可配置网络。': 'This container has no IPv4 NAT, dedicated public IPv4, or IPv6 assigned, so no firewall network can be configured.',
|
||||
'当前容器没有可配置的 NAT、公网 IPv4 或 IPv6': 'This container has no configurable NAT, public IPv4, or IPv6',
|
||||
'当前容器没有可配置网络': 'This container has no configurable network',
|
||||
'IPv4 规则覆盖独立公网 IPv4。': 'IPv4 rules apply to the dedicated public IPv4.',
|
||||
'IPv4 规则覆盖IPv4 NAT 端口映射。': 'IPv4 rules apply to IPv4 NAT port mappings.',
|
||||
'NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。': 'NAT inbound ports are matched by the container internal port, not the host public port.',
|
||||
'IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'IPv6 rules apply to the IPv6 addresses assigned to this container.',
|
||||
'IPv4(公网 IPv4)': 'IPv4 (Public IPv4)',
|
||||
'IPv4(NAT)': 'IPv4 (NAT)',
|
||||
'全部网络': 'All Networks',
|
||||
'方向': 'Direction',
|
||||
'网络': 'Network',
|
||||
'来源/目标 IP': 'Source / Destination IP',
|
||||
'动作': 'Action',
|
||||
'入站': 'Inbound',
|
||||
@@ -802,23 +905,162 @@ const exact: Record<string, string> = {
|
||||
'放行': 'Allow',
|
||||
'拒绝': 'Deny',
|
||||
'暂无防火墙规则': 'No firewall rules',
|
||||
'防火墙设置已保存并应用': 'Firewall settings saved and applied',
|
||||
'保存防火墙设置失败': 'Failed to save firewall settings',
|
||||
'编辑规则': 'Edit Rule',
|
||||
'入站 (Inbound)': 'Inbound',
|
||||
'出站 (Outbound)': 'Outbound',
|
||||
'留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
|
||||
'入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'For inbound rules, enter the container service port. Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
|
||||
'出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'For outbound rules, enter the remote destination port. Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
|
||||
'NAT 入站填容器内部端口,例如公网 22023 -> 容器 22,这里填 22': 'For NAT inbound rules, enter the container internal port. For example, public 22023 -> container 22 means enter 22 here.',
|
||||
'端口仅适用于 TCP/UDP': 'Ports only apply to TCP/UDP',
|
||||
'如: 22 或 80,443 或 8000-9000': 'e.g. 22 or 80,443 or 8000-9000',
|
||||
'当前协议不使用端口': 'This protocol does not use ports',
|
||||
'来源 IP': 'Source IP',
|
||||
'目标 IP': 'Destination IP',
|
||||
'留空为任意 IP,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IP. Supports CIDR: 192.168.1.0/24',
|
||||
'留空为任意 IPv4,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IPv4. Supports CIDR: 192.168.1.0/24',
|
||||
'留空为任意 IPv6,支持 CIDR: 2001:db8::/64': 'Leave empty for any IPv6. Supports CIDR: 2001:db8::/64',
|
||||
'留空为任意 IP,支持 IPv4/IPv6 CIDR': 'Leave empty for any IP. Supports IPv4/IPv6 CIDR',
|
||||
'如: 192.168.1.0/24': 'e.g. 192.168.1.0/24',
|
||||
'如: 2001:db8::/64': 'e.g. 2001:db8::/64',
|
||||
'如: 192.168.1.0/24 或 2001:db8::/64': 'e.g. 192.168.1.0/24 or 2001:db8::/64',
|
||||
'放行 (ACCEPT)': 'Allow (ACCEPT)',
|
||||
'拒绝 (DROP)': 'Deny (DROP)',
|
||||
'规则描述': 'Rule description',
|
||||
'兼容旧请求:default_action 可不传,不传时保留现有策略;rule.network 可不传,不传按 ipv4 处理。default_action: DROP=未命中规则时拒绝, ACCEPT=未命中规则时放行。network: ipv4=IPv4 NAT/公网 IPv4, ipv6=IPv6, all=同时应用到 IPv4 和 IPv6。NAT 入站规则的 port 填容器内端口,不是宿主机公网端口。': 'Backward compatible: default_action is optional; if omitted, the existing policy is kept. rule.network is optional; if omitted, it is treated as ipv4. default_action: DROP=deny unmatched traffic, ACCEPT=allow unmatched traffic. network: ipv4=IPv4 NAT/public IPv4, ipv6=IPv6, all=apply to both IPv4 and IPv6. For NAT inbound rules, port is the container internal port, not the host public port.',
|
||||
'登录方式': 'SSH Auth Method',
|
||||
'保留当前密码': 'Keep current password',
|
||||
'生成新密码': 'Generate new password',
|
||||
'自定义密码': 'Custom password',
|
||||
'生成密码': 'Generate password',
|
||||
'不限速': 'Unlimited',
|
||||
'下': 'Down',
|
||||
'不限': 'Unlimited',
|
||||
'/ 上': '/ Up',
|
||||
'请选择登录方式': 'Select a login method',
|
||||
'未检测到可分配公网 IPv4': 'No allocatable public IPv4 detected',
|
||||
'使用': 'Use',
|
||||
'正在检测 IPv6 前缀...': 'Checking IPv6 prefixes...',
|
||||
'公网 NAT': 'Public NAT',
|
||||
'不分配 NAT 端口': 'Do not assign NAT ports',
|
||||
'未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。': 'No allocatable IPv6 prefix was detected. The host only has a single /128 IPv6 address, which cannot be assigned to containers.',
|
||||
'宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。': 'The host detected an IPv6 prefix, but the outbound IPv6 connectivity test failed.',
|
||||
'个可分配地址': 'allocatable addresses',
|
||||
'将分配': 'Will assign',
|
||||
'请勾选任意一个可用网络': 'Select at least one available network',
|
||||
'局域网 IPv4 配置有误': 'Invalid LAN IPv4 configuration',
|
||||
'请填写有效的 IPv4 地址、子网掩码和网关': 'Enter a valid IPv4 address, subnet mask, and gateway',
|
||||
'未配置存储': 'Storage not configured',
|
||||
'请先在存储管理中为': 'In Storage Management, enable storage for',
|
||||
'开启至少一块存储磁盘': 'Enable at least one storage disk',
|
||||
'登录方式有误': 'Invalid login method',
|
||||
'至': 'to',
|
||||
'当前宿主机不支持 KVM': 'The current host does not support KVM',
|
||||
'系统镜像,请先在「镜像管理」中下载镜像模板。': 'system images available. Download an image template from Images first.',
|
||||
'存储磁盘': 'Storage Disk',
|
||||
'自动选择(默认盘优先,空间不足自动切换)': 'Automatic selection (prefer default disk and switch when space is insufficient)',
|
||||
'尚未开启': 'Not enabled',
|
||||
'存储,当前无法创建。': 'storage is not enabled, so creation is currently unavailable.',
|
||||
'去开启': 'Configure Now',
|
||||
'默认勾选当前系统;取消后,子用户也不能重装该系统。': 'The current system is selected by default. Clearing it also prevents sub-users from reinstalling that system.',
|
||||
'局域网 DHCP': 'LAN DHCP',
|
||||
'macvlan 独立局域网 IP': 'Independent LAN IP via macvlan',
|
||||
'未检测到可用上联网卡': 'No available uplink interface detected',
|
||||
'DHCP 自动获取': 'Obtain automatically via DHCP',
|
||||
'子网掩码': 'Subnet Mask',
|
||||
'不选则长期有效': 'Leave blank for no expiration',
|
||||
'均': 'Avg',
|
||||
'/ 峰': '/ Peak',
|
||||
'到期': 'Expires',
|
||||
'未分配': 'Unassigned',
|
||||
'下行': 'Download',
|
||||
'上行': 'Upload',
|
||||
'修改公网 IP 分配': 'Change Public IP Assignment',
|
||||
'尚未开启快照存储,无法新建或启用定时快照。': 'Snapshot storage is not enabled. New and scheduled snapshots are unavailable.',
|
||||
'新建快照存储磁盘': 'Storage Disk for New Snapshots',
|
||||
'仅影响手动新建快照;定时快照使用默认磁盘。': 'Only affects manually created snapshots. Scheduled snapshots use the default disk.',
|
||||
'在': 'at',
|
||||
'IPv4 规则覆盖': 'IPv4 rules cover',
|
||||
'独立公网 IPv4': 'independent public IPv4',
|
||||
'公网 IP 分配': 'Public IP Assignment',
|
||||
'修改后会重放端口映射、SNAT 和防火墙规则。': 'Changing assignments reapplies port mappings, SNAT, and firewall rules.',
|
||||
'随机数量': 'Random Count',
|
||||
'没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。': 'No public IPv4 addresses are available. Configure the IPv4 pool in Routing first.',
|
||||
'独立 IPv6': 'Independent IPv6',
|
||||
'自定义地址必须落在路由管理配置的 IPv6 前缀内。': 'Custom addresses must be within an IPv6 prefix configured in Routing.',
|
||||
'未分配 IPv4 NAT 端口配额': 'No IPv4 NAT port quota assigned',
|
||||
'已达到管理员分配的 IPv4 NAT 端口配额': 'The administrator-assigned IPv4 NAT port quota has been reached',
|
||||
'不分配': 'Do Not Assign',
|
||||
'随机分配': 'Random Allocation',
|
||||
'自定义': 'Custom',
|
||||
'SSH Key 格式不正确': 'Invalid SSH key format',
|
||||
'公网 IP 分配失败': 'Public IP assignment failed',
|
||||
'请检查地址是否可用或已被占用': 'Check whether the address is available or already in use',
|
||||
'未分配 IPv4 NAT': 'IPv4 NAT not assigned',
|
||||
'该容器未分配 IPv4 NAT 端口配额。': 'This container has no IPv4 NAT port quota.',
|
||||
'未配置快照存储': 'Snapshot storage not configured',
|
||||
'请先在存储管理中为快照开启至少一块存储磁盘。': 'Enable at least one snapshot storage disk in Storage Management first.',
|
||||
'个月': 'months',
|
||||
'个任务': 'tasks',
|
||||
'剩余': 'Remaining',
|
||||
'磨损': 'Wear',
|
||||
'启停': 'Power Cycles',
|
||||
'线程': 'threads',
|
||||
'块硬盘': 'disks',
|
||||
'个进程': 'processes',
|
||||
'虚拟': 'Virtual',
|
||||
'尚未开启镜像缓存存储,无法下载新镜像。': 'Image cache storage is not enabled, so new images cannot be downloaded.',
|
||||
'请先在存储管理中开启镜像缓存存储': 'Enable image cache storage in Storage Management first',
|
||||
'正在检查存储配置...': 'Checking storage configuration...',
|
||||
'池内': 'In Pool',
|
||||
'范围': 'Range',
|
||||
'条映射': 'mappings',
|
||||
'模式': 'Mode',
|
||||
'NAT4、公网 IPv4 池和 IPv6 地址分配': 'NAT4, public IPv4 pool, and IPv6 address assignment',
|
||||
'编辑 NAT4 范围': 'Edit NAT4 Range',
|
||||
'起始端口': 'Start Port',
|
||||
'结束端口': 'End Port',
|
||||
'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口': 'The NAT4 range must be within 1-65535, and the start port cannot exceed the end port',
|
||||
'保存 NAT4 范围失败': 'Failed to save NAT4 range',
|
||||
'剩余 / 总数': 'Remaining / Total',
|
||||
'由局域网 DHCP 分配': 'Assigned by LAN DHCP',
|
||||
'局域网 DHCP 分配': 'LAN DHCP Assignments',
|
||||
'暂无局域网 DHCP 分配': 'No LAN DHCP assignments',
|
||||
'公网 IPv4 池': 'Public IPv4 Pool',
|
||||
'编辑 IP 池': 'Edit IP Pool',
|
||||
'暂未配置公网 IPv4 池': 'No public IPv4 pool configured',
|
||||
'掩码': 'Mask',
|
||||
'分配给': 'Assigned To',
|
||||
'空闲': 'Free',
|
||||
'编辑 IPv4 池': 'Edit IPv4 Pool',
|
||||
'IPv4 网关不能为空': 'IPv4 gateway is required',
|
||||
'IPv4 地址不能为空': 'IPv4 address is required',
|
||||
'保存 IPv4 池失败': 'Failed to save IPv4 pool',
|
||||
'打开容器': 'Open Container',
|
||||
'IPv4 池内暂无地址': 'No addresses in the IPv4 pool',
|
||||
'添加 IPv4': 'Add IPv4',
|
||||
'检测到的 IPv6 前缀': 'Detected IPv6 Prefixes',
|
||||
'暂无 IPv6 前缀': 'No IPv6 prefixes',
|
||||
'IPv6 网卡不能为空': 'IPv6 interface is required',
|
||||
'本机': 'Local',
|
||||
'暂无 IPv4 NAT 映射': 'No IPv4 NAT mappings',
|
||||
'运行时名称': 'Runtime Name',
|
||||
'客户机 IPv4': 'Guest IPv4',
|
||||
'宿主 IPv4': 'Host IPv4',
|
||||
'宿主端口': 'Host Port',
|
||||
'客户机端口': 'Guest Port',
|
||||
'大量': 'Large',
|
||||
'的快照吗?此操作不可恢复。': ' snapshot? This action cannot be undone.',
|
||||
'· 默认勾选当前系统,取消后将禁止重装该系统': ' · the current system is selected by default; clearing it prevents reinstalling that system',
|
||||
'暂无已下载并启用的镜像': 'No downloaded and enabled images',
|
||||
'已选择': 'Selected',
|
||||
'加载失败': 'Loading failed',
|
||||
'请填写 SSH 公钥': 'Enter an SSH public key',
|
||||
'SSH 公钥长度不能超过 8192 字符': 'The SSH public key cannot exceed 8192 characters',
|
||||
'SSH 公钥只能填写一行': 'The SSH public key must be on one line',
|
||||
'SSH 公钥格式不正确': 'Invalid SSH public key format',
|
||||
}
|
||||
|
||||
const artifactPatterns: RegExp[] = [
|
||||
@@ -872,6 +1114,7 @@ const replacements: Array<[RegExp, string]> = [
|
||||
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
||||
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*个\s*任务/g, 'Total $1 tasks'],
|
||||
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
|
||||
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
||||
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
||||
@@ -905,7 +1148,12 @@ const replacements: Array<[RegExp, string]> = [
|
||||
[/当前证书:/g, 'Current certificate: '],
|
||||
[/第\s*(\d+)\s*页/g, 'Page $1'],
|
||||
[/入\s*([^/,]+)\s*\/\s*出\s*([^,]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'],
|
||||
[/入\s*([^/,]+)\s*\/\s*出\s*([^,]+),限速占用\s*([^,]+),累计\s*(.+)$/g, 'In $1 / Out $2, limit usage $3, total $4'],
|
||||
[/下\s*([^/]+)\s*\/\s*上\s*(.+)$/g, 'Down $1 / Up $2'],
|
||||
[/下行\s*([^/]+)\s*\/\s*上行\s*(.+)$/g, 'Download $1 / Upload $2'],
|
||||
[/读取\s*([^/]+)\s*\/\s*写入\s*(.+)$/g, 'Read $1 / Write $2'],
|
||||
[/读\s*([^/,]+)\s*\/\s*写\s*([^,]+),累计\s*([^,]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'],
|
||||
[/读\s*([^/,]+)\s*\/\s*写\s*([^,]+),限速占用\s*([^,]+),累计\s*([^,]+),容量\s*(.+)$/g, 'Read $1 / Write $2, limit usage $3, total $4, capacity $5'],
|
||||
[/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'],
|
||||
[/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'],
|
||||
[/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'],
|
||||
@@ -916,6 +1164,7 @@ const replacements: Array<[RegExp, string]> = [
|
||||
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
|
||||
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
|
||||
[/阶段:(.+)$/g, 'Stage: $1'],
|
||||
[/正在初始化:(.+)$/g, 'Initializing: $1'],
|
||||
[/\$\{days\}天/g, '${days} days'],
|
||||
[/\$\{hours\}小时/g, '${hours} hours'],
|
||||
[/\$\{hours\}\s*小时/g, '${hours} hours'],
|
||||
|
||||
@@ -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 "=============================="
|
||||
+181
-49
@@ -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/LXC;KVM 功能当前支持 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
|
||||
@@ -470,13 +537,24 @@ remove_clicd_lxc_image_cache() {
|
||||
for image in \
|
||||
"ubuntu noble amd64" \
|
||||
"ubuntu jammy amd64" \
|
||||
"debian trixie amd64" \
|
||||
"debian bookworm amd64" \
|
||||
"debian bullseye amd64" \
|
||||
"alpine 3.21 amd64" \
|
||||
"centos 9-Stream amd64" \
|
||||
"archlinux current amd64" \
|
||||
"fedora 44 amd64" \
|
||||
"rockylinux 10 amd64"
|
||||
"rockylinux 10 amd64" \
|
||||
"ubuntu noble arm64" \
|
||||
"ubuntu jammy arm64" \
|
||||
"debian trixie 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 +1028,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 +1069,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 +1137,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 +1149,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 +1179,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 +1191,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 +1236,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
|
||||
}
|
||||
|
||||
@@ -1202,7 +1326,9 @@ setup_runtime_services() {
|
||||
|
||||
|
||||
libvirt_network_active() {
|
||||
virsh net-info default 2>/dev/null | awk -F: 'tolower($1) ~ /^[[:space:]]*active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' | grep -qx yes
|
||||
LC_ALL=C LANG=C virsh net-info default 2>/dev/null \
|
||||
| awk -F: '$1 ~ /^[[:space:]]*Active[[:space:]]*$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print tolower($2)}' \
|
||||
| grep -qx yes
|
||||
}
|
||||
|
||||
setup_default_libvirt_network() {
|
||||
@@ -1231,7 +1357,13 @@ EOF
|
||||
touch "$LIBVIRT_DEFAULT_MARKER"
|
||||
fi
|
||||
if ! libvirt_network_active; then
|
||||
virsh net-start default
|
||||
if ! start_output="$(LC_ALL=C LANG=C virsh net-start default 2>&1)"; then
|
||||
# Another process may have activated the network after our check.
|
||||
if ! libvirt_network_active; then
|
||||
printf '%s\n' "$start_output" >&2
|
||||
die "libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
virsh net-autostart default >/dev/null
|
||||
if ! libvirt_network_active; then
|
||||
@@ -1384,7 +1516,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 +1534,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 +1546,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 +1562,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 二进制,安装无法继续。"
|
||||
|
||||
Reference in New Issue
Block a user