Compare commits

..

57 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 11:22:14 +00:00
MengMengCode 49d8093f45 网络流量、磁盘 IO 图表显示优化 2026-06-17 19:16:28 +08:00
MengMengCode 98ed716225 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-17 18:42:19 +08:00
MengMengCode 78276d303b chore(docs): 升级 esbuild 至 0.28.1 并更新忽略规则 2026-06-17 18:42:12 +08:00
Meng Meng 30d2a4f4da Merge pull request #15 from StarVM-OpenSource/main
更新一键安装certbot脚本+增加更完整的webssh逻辑
2026-06-14 12:51:18 +08:00
a54e03b924 更新一键安装certbot脚本+增加更完整的webssh逻辑 2026-06-14 03:15:02 +08:00
Meng Meng 4cdc6e68ba Merge pull request #14 from MengMengCode/alert-autofix-29
Potential fix for code scanning alert no. 29: DOM text reinterpreted as HTML
2026-06-13 21:04:48 +08:00
Meng Meng 2ed42992ed Potential fix for code scanning alert no. 29: DOM text reinterpreted as HTML
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-13 21:04:10 +08:00
Meng Meng 4dfd7c0885 Merge pull request #13 from StarVM-OpenSource/main
修复防火墙无法创建 获取规则问题 优化防火墙创建规则UI
2026-06-13 12:01:43 +08:00
5ed5b4509d Merge branch 'main' of https://github.com/StarVM-OpenSource/CLICD-MoFang 2026-06-13 02:52:47 +08:00
18f297b988 修复防火墙功能 2026-06-13 02:49:23 +08:00
d5a236943b 修复防火墙至不支持的方法 等待Claude修复 2026-06-13 00:52:19 +08:00
MengMengCode c54f92f892 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-12 21:55:17 +08:00
MengMengCode 86f0d079ab update docs 2026-06-12 21:55:15 +08:00
Meng Meng 3a65d5d24a Remove version number from README title
Removed version number from the title in README.md
2026-06-12 16:03:38 +08:00
MengMengCode cbe9339316 release: v1.1.19 2026-06-12 16:01:52 +08:00
Meng Meng 79d6dad684 Merge pull request #12 from MengMengCode/chore/update-version-badge
chore: add version badge to README
2026-06-12 15:59:47 +08:00
MengMengCode 55c7a9796c chore: add version badge to README 2026-06-12 15:59:24 +08:00
Meng Meng f4a15a0d90 Merge pull request #11 from MengMengCode/feature/split-rate-limits
feat: split rate limits with configurable per-endpoint controls
2026-06-12 15:57:35 +08:00
MengMengCode c7742319b2 feat: split rate limits with configurable per-endpoint controls 2026-06-12 15:53:31 +08:00
Meng Meng 01c14ecba6 chore: update firewall i18n strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:42:22 +08:00
MengMengCode 989e1b6645 chore: update firewall i18n strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:40:59 +08:00
MengMengCode da5eea5193 release: v1.1.18 2026-06-12 13:19:07 +08:00
MengMengCode 4de86c458f 修复了一些已知问题 2026-06-12 13:18:54 +08:00
Meng Meng baf213e769 Potential fix for code scanning alert no. 28: DOM text reinterpreted as HTML
Patch

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-12 12:00:38 +08:00
Meng Meng 819a79e00d Merge pull request #8 from StarVM-OpenSource/main
魔方财务模块:支持独立IPv4 初步兼容防火墙
2026-06-12 11:54:46 +08:00
18bee369c1 Merge branch 'main' of https://github.com/StarVM-OpenSource/CLICD-MoFang 2026-06-12 10:20:59 +08:00
2463715e32 兼容独立IPv4 初步兼容防火墙 2026-06-12 10:19:51 +08:00
MengMengCode fbc539ea47 release: v1.1.17 2026-06-12 01:35:17 +08:00
MengMengCode 875cd4716b 增加了服务器防火墙功能 2026-06-12 01:35:04 +08:00
MengMengCode 6194b6e364 修复了一些已知问题 2026-06-11 23:29:22 +08:00
MengMengCode 30d6b2d9f7 release: v1.1.16 2026-06-11 23:09:49 +08:00
MengMengCode 2f94498df2 修复了一些已知问题 2026-06-11 23:09:04 +08:00
MengMengCode c303fe6d17 release: v1.1.15 2026-06-10 14:52:30 +08:00
MengMengCode eedb2d7fb0 修复了一些已知问题 2026-06-10 14:51:22 +08:00
MengMengCode bfc98d043b feat(security): 快照并持久化告警时的连接跟踪数据 2026-06-10 13:28:46 +08:00
MengMengCode 0a4cf5c0bd release: v1.1.14 2026-06-10 11:12:43 +08:00
MengMengCode 54a608c19d Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 11:12:32 +08:00
MengMengCode 73bd6934f9 修复预设密码以及KEY问题 2026-06-10 11:12:27 +08:00
Meng Meng a35595edcf Update README.md 2026-06-10 11:04:58 +08:00
MengMengCode b605df613e Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 10:35:13 +08:00
MengMengCode 3cc8f7df7f 修复了一些已知问题 2026-06-10 10:35:08 +08:00
Meng Meng 744246c0a8 Update thanks section formatting in README.md 2026-06-10 09:33:31 +08:00
MengMengCode aed512cb09 patch 2026-06-10 02:39:58 +08:00
MengMengCode b6f48acc4c Refactor code structure for improved readability and maintainability 2026-06-10 02:33:18 +08:00
52 changed files with 5933 additions and 609 deletions
+1
View File
@@ -68,3 +68,4 @@ linux.txt
push-release.ps1
deploy.ps1
backend/clicd
api.md
+176 -2
View File
@@ -10,6 +10,7 @@ README.md
handlers/
webssh.php
templates/
firewall.html
info.html
nat.html
```
@@ -93,11 +94,12 @@ Content-Type: application/json
## 客户区页面
模块提供个客户区选项卡:
模块提供个客户区选项卡:
```text
实例信息
NAT转发
防火墙
```
客户区按钮提供:
@@ -197,6 +199,65 @@ DELETE /api/v1/containers/{id}/port-mappings/{index}
}
```
## 防火墙
防火墙是独立客户区页面,支持:
- 查看防火墙启用状态、默认动作和规则列表
- 启用 / 停用防火墙
- 设置默认动作:未匹配拒绝或未匹配放行
- 添加规则
- 编辑规则
- 删除规则
- 单独启用 / 停用某条规则
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
使用的 CLICD API
```text
GET /api/v1/containers/{id}/firewall
PUT /api/v1/containers/{id}/firewall
```
更新防火墙时必须使用 JSON 请求体,例如:
```json
{
"enabled": true,
"default_action": "ACCEPT",
"rules": [
{
"id": "",
"network": "ipv4",
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ip": "",
"action": "ACCEPT",
"description": "Allow SSH",
"enabled": true
}
]
}
```
规则字段说明:
| 字段 | 说明 |
| --- | --- |
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
| `direction` | 方向,`in` 入站,`out` 出站 |
| `protocol` | 协议,`tcp``udp` |
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22``80,443``8000-9000` |
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
| `description` | 规则描述 |
| `enabled` | 是否启用该规则 |
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`
## WebSSH
WebSSH 按钮会调用:
@@ -252,6 +313,8 @@ https://www.example.com
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
| 查询防火墙 | `GET /api/v1/containers/{id}/firewall` |
| 更新防火墙 | `PUT /api/v1/containers/{id}/firewall` |
| WebSSH | `POST /api/v1/ssh-ticket` |
## 建议 API 权限
@@ -269,6 +332,7 @@ container:password
container:traffic
container:resize
container:port
container:firewall
task:read
ssh-ticket:create
```
@@ -316,6 +380,22 @@ curl --location --request PUT \
--data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}'
```
查询防火墙:
```bash
curl -H "X-API-Key: clicd_sk_xxxx" \
https://0.0.0.0:8999/api/v1/containers/10/firewall
```
更新防火墙:
```bash
curl --location --request PUT \
"https://0.0.0.0:8999/api/v1/containers/10/firewall" \
--header "X-API-Key: clicd_sk_xxxx" \
--header "Content-Type: application/json" \
--data-raw '{"enabled":true,"default_action":"ACCEPT","rules":[{"id":"","network":"ipv4","direction":"in","protocol":"tcp","port":"22","source_ip":"","action":"ACCEPT","description":"Allow SSH","enabled":true}]}'
```
创建 WebSSH 票据:
```bash
@@ -336,6 +416,41 @@ curl --location --request POST \
Content-Type: application/json
```
### 防火墙获取提示“不支持的方法”
请确认模块版本已经包含防火墙页签修复。客户区防火墙列表应通过模块公开的 `firewallList` 调用,再由模块向 CLICD 发起:
```text
GET /api/v1/containers/{id}/firewall
```
如果页面或二开代码直接把读取请求改成 `POST /api/v1/containers/{id}/firewall`,CLICD 会返回“不支持的方法”。
### 防火墙保存后规则为空
请确认更新接口最终发往 CLICD 的请求体是 JSON,并且包含 `rules` 数组。防火墙关闭时也可以保存规则,`enabled: false` 不应自动清空 `rules`
正确请求体示例:
```json
{
"enabled": false,
"default_action": "ACCEPT",
"rules": [
{
"id": "",
"network": "ipv4",
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ip": "",
"action": "ACCEPT",
"description": "Allow SSH",
"enabled": true
}
]
}
```
### 图表刚打开只有一条横线
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
@@ -344,7 +459,66 @@ CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
### WebSSH 打不开或提示不安全 WebSocket
### 防火墙
防火墙是独立客户区页面,支持:
- 查看防火墙启用状态、默认动作和规则列表
- 启用 / 停用防火墙
- 设置默认动作:未匹配拒绝或未匹配放行
- 添加规则
- 编辑规则
- 删除规则
- 单独启用 / 停用某条规则
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
使用的 CLICD API
```text
GET /api/v1/containers/{id}/firewall
PUT /api/v1/containers/{id}/firewall
```
更新防火墙时必须使用 JSON 请求体,例如:
```json
{
"enabled": true,
"default_action": "ACCEPT",
"rules": [
{
"id": "",
"network": "ipv4",
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ip": "",
"action": "ACCEPT",
"description": "Allow SSH",
"enabled": true
}
]
}
```
规则字段说明:
| 字段 | 说明 |
| --- | --- |
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
| `direction` | 方向,`in` 入站,`out` 出站 |
| `protocol` | 协议,`tcp``udp` |
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22``80,443``8000-9000` |
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
| `description` | 规则描述 |
| `enabled` | 是否启用该规则 |
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`
## WebSSH 打不开或提示不安全 WebSocket
请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS:
+379 -31
View File
@@ -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'] ?? '',
+17 -2
View File
@@ -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
+53 -39
View File
@@ -2,10 +2,10 @@
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
</p>
<h1 align="center">CLICD</h1>
<h1 align="center">CLICD <sub></sub></h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.22-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
@@ -27,41 +27,11 @@
<img alt="TLS" src="https://img.shields.io/badge/TLS-Let's_Encrypt-003A70?style=flat-square&logo=letsencrypt&logoColor=white">
</p>
CLICD is a lightweight virtualization management panel for LXC and KVM, featuring a web console, CLI management, batch operations, image management, NAT networking, IPv6 allocation, WebSSH, VNC access, resource controls, bandwidth limiting, and security alerting.
It is designed for managing LXC containers and KVM virtual machines on VPS servers, and is particularly suitable for environments that require bulk provisioning and delegated access management through sub-user management links.
CLICD is a lightweight virtualization management panel for LXC and KVM. It combines a web console, CLI tools, REST API, NAT/IPv6 networking, WebSSH/WebVNC access, resource quotas, traffic limits, snapshots, delegated sub-user access, and security alerts into a single deployable service.
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSHVNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,集成 Web 控制台、CLI、REST API、NAT/IPv6 网络、WebSSH/WebVNC、资源配额、流量限制、快照、子用户授权和安全告警能力,适合 VPS 商家、实验室、开发者自建虚拟化节点以及需要批量开通容器的场景。
## Features / 功能介绍
### English
1. Supports Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other operating system images. Images can be downloaded on demand through the image management interface. For hosts with limited resources, lightweight distributions such as Alpine are recommended.
2. Supports WebSSH management, allowing users to access container terminals directly from the browser without manually copying SSH credentials.
3. Supports NAT4 port quotas, port forwarding, and protocol restrictions, as well as public IPv6 allocation. IPv6 assignment requires the host machine to have a routable IPv6 prefix.
4. Supports both inbound and outbound traffic limits. Containers are automatically powered off when configured limits are reached, preventing bandwidth overuse.
5. Supports container expiration dates. Expired containers are automatically shut down, and delegated users lose access until an administrator extends the expiration period.
6. Includes lightweight conntrack-based security monitoring. The system does not store full logs of normal connections, but generates audit alerts for suspicious activities such as port scanning, lateral scanning, brute-force attempts, SMTP abuse, UDP reflection attacks, cryptocurrency mining ports, and proxy/VPN/Tor usage.
7. Supports delegated management links. Administrators can assign specific containers to sub-users, while ensuring that each user can only manage the containers explicitly authorized to them.
8. Provides a REST API for automating the management of containers, tasks, images, networking, traffic controls, and security alerts.
9. Supports operating entirely through the CLI. When the web console is not required, administrators can stop and disable the systemd service and launch CLI-only mode using `clicd cli --no-web`.
### 中文
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
## Technology Stack / 技术栈
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.22
![alt text](/img/image-1.png)
## Installation / 安装
@@ -77,9 +47,52 @@ One-click Uninstall / 一键卸载:
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
```
![alt text](/img/image.png)
![alt text](/img/image-1.png)
## Features / 功能介绍
### English
| Area | What CLICD provides |
| --- | --- |
| Virtualization | Manage LXC containers and KVM virtual machines from one panel, including create, reinstall, start, stop, restart, delete, password reset, expiry control, and batch actions. |
| Images and templates | Built-in template and image management for Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other common distributions. Images can be enabled, disabled, downloaded, cancelled, or removed from cache. |
| Networking | NAT4 port quotas, random available port allocation, TCP/UDP port mappings, public IPv4 pool management, IPv6 prefix detection, IPv6 status checks, and per-container IPv6 assignment. |
| Resource control | CPU, memory, disk, swap, bandwidth usage, traffic reset, traffic limit, and resource limit management, with automatic shutdown behavior for expired or over-quota containers. |
| Console access | Browser-based WebSSH and WebVNC ticket access, so users can open terminals or consoles without manually exchanging credentials. |
| Snapshots | Snapshot overview, per-container snapshots, create/delete/restore operations, scheduled snapshots, and quota controls. |
| Security | Conntrack-based security alerts for port scans, lateral scans, brute-force behavior, SMTP abuse, UDP reflection, mining ports, proxy/VPN/Tor usage, plus security logs, summaries, and configurable settings. |
| Accounts and audit | Delegated sub-user links, sub-user password rotation, per-user container permissions, audit logs, login logs, and API key management. |
| Automation | Versioned REST API under `/api/v1`, task queue endpoints, batch create/action endpoints, and a Mofang finance integration module packaged automatically by GitHub Actions. |
| Operations | Dashboard statistics, host resource overview, routing overview, swap management, CLI-only mode, and release artifacts generated by GitHub Actions. |
### 中文
| 模块 | CLICD 提供的能力 |
| --- | --- |
| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 |
| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 |
| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 |
| 资源限制 | 支持 CPU、内存、磁盘、Swap、独立上行/下行带宽、读/写 I/O 限速、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 |
| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 |
| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 |
| 账号与审计 | 支持子用户管理链接、子用户密码轮换、按容器授权、操作日志、登录日志和 API Key 管理,适合分发给下游用户或拼车用户。 |
| 自动化接入 | 全量接口统一使用 `/api/v1`,覆盖任务队列、容器、镜像、网络、流量、安全、批量创建和批量操作;同时提供魔方财务对接模块,并由 GitHub Actions 自动打包发布。 |
| 运维入口 | 提供总览统计、主机资源、路由概览、Swap 管理、CLI-only 模式和 GitHub Actions 自动发布产物,便于在小型节点上长期维护。 |
## Technology Stack / 技术栈
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.24
## Preview / 预览
![alt text](/img/image-2.png)
![alt text](/img/image-3.png)
![alt text](/img/image-4.png)
![alt text](/img/image-5.png)
## Disclaimer/免责声明
@@ -99,10 +112,11 @@ This open-source software is intended solely for educational purposes, specifica
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
## Thanks/鸣谢
## Thanks / 鸣谢
- [Nodeseek.com](https://www.nodeseek.com) — 一个专注于服务器的社区
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
+1 -1
View File
@@ -90,7 +90,7 @@ func hasScope(r *http.Request, scope string) bool {
func subUserScopeAllowed(scope string) bool {
switch scope {
case "container:read", "container:power", "container:reinstall", "container:network",
case "container:read", "container:power", "container:reinstall", "container:password", "container:network",
"dashboard:read", "image:read", "task:read", "snapshot:read", "snapshot:create", "snapshot:delete", "snapshot:restore", "snapshot:schedule",
"terminal:ssh", "terminal:vnc":
return true
+257
View File
@@ -0,0 +1,257 @@
package api
import (
"encoding/json"
"math/rand"
"net/http"
"net/netip"
"strconv"
"strings"
"clicd/internal/config"
"clicd/internal/lxc"
)
func generateFirewallRuleID() string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = chars[rand.Intn(len(chars))]
}
return string(b)
}
func getFirewall(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]interface{}{
"enabled": c.FirewallEnabled,
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
"rules": c.FirewallRules,
},
})
}
func updateFirewall(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
var req struct {
Enabled *bool `json:"enabled"`
DefaultAction *string `json:"default_action"`
Rules *[]config.FirewallRule `json:"rules"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
oldEnabled := c.FirewallEnabled
oldDefaultAction := c.FirewallDefaultAction
oldRules := append([]config.FirewallRule(nil), c.FirewallRules...)
if req.Enabled != nil {
c.FirewallEnabled = *req.Enabled
}
if req.DefaultAction != nil {
action := normalizeFirewallDefaultAction(*req.DefaultAction)
if action == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid default action"})
return
}
c.FirewallDefaultAction = action
} else if strings.TrimSpace(c.FirewallDefaultAction) == "" {
c.FirewallDefaultAction = "DROP"
}
if req.Rules != nil {
// Validate and assign IDs to new rules
rules := *req.Rules
for i := range rules {
rules[i].Direction = strings.ToLower(strings.TrimSpace(rules[i].Direction))
rules[i].Protocol = strings.ToLower(strings.TrimSpace(rules[i].Protocol))
rules[i].Action = strings.ToUpper(strings.TrimSpace(rules[i].Action))
rules[i].Network = normalizeFirewallNetwork(rules[i].Network)
rules[i].SourceIP = strings.TrimSpace(rules[i].SourceIP)
rules[i].Port = strings.TrimSpace(rules[i].Port)
if rules[i].Network == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid network"})
return
}
if rules[i].Direction != "in" && rules[i].Direction != "out" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid direction: " + rules[i].Direction})
return
}
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" && rules[i].Protocol != "icmp" && rules[i].Protocol != "all" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid protocol: " + rules[i].Protocol})
return
}
if rules[i].Action != "ACCEPT" && rules[i].Action != "DROP" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + rules[i].Action})
return
}
if rules[i].SourceIP != "" {
if err := validateFirewallIPSpec(rules[i].SourceIP, rules[i].Network); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid IP: " + err.Error()})
return
}
}
if rules[i].ID == "" || strings.HasPrefix(rules[i].ID, "tmp-") {
rules[i].ID = generateFirewallRuleID()
}
// Validate port spec
if rules[i].Port != "" {
if rules[i].Protocol != "tcp" && rules[i].Protocol != "udp" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Ports are only supported for TCP and UDP rules"})
return
}
if err := validatePortSpec(rules[i].Port); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port: " + err.Error()})
return
}
}
}
c.FirewallRules = rules
}
// Apply firewall rules to iptables if container is running
if c.Status == "running" {
if err := lxc.ApplyFirewallRules(id); err != nil {
c.FirewallEnabled = oldEnabled
c.FirewallDefaultAction = oldDefaultAction
c.FirewallRules = oldRules
_ = lxc.ApplyFirewallRules(id)
config.SaveConfig()
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to apply firewall rules: " + err.Error()})
return
}
} else if !c.FirewallEnabled {
// If disabled and not running, clean any lingering rules
lxc.CleanFirewallRules(id)
}
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Firewall updated",
Data: map[string]interface{}{
"enabled": c.FirewallEnabled,
"default_action": normalizeFirewallDefaultAction(c.FirewallDefaultAction),
"rules": c.FirewallRules,
},
})
}
func normalizeFirewallDefaultAction(action string) string {
action = strings.ToUpper(strings.TrimSpace(action))
if action == "ACCEPT" || action == "DROP" {
return action
}
return ""
}
func normalizeFirewallNetwork(network string) string {
network = strings.ToLower(strings.TrimSpace(network))
switch network {
case "", "ipv4", "nat4":
return "ipv4"
case "ipv6":
return "ipv6"
case "all", "both":
return "all"
default:
return ""
}
}
func validatePortSpec(port string) error {
port = strings.TrimSpace(port)
if port == "" {
return nil
}
// Support: "22", "80,443", "8000-9000", "80,443,8000-9000"
partCount := 0
for _, part := range strings.Split(port, ",") {
part = strings.TrimSpace(part)
if part == "" {
return &portValidationError{port}
}
partCount++
if strings.Contains(part, "-") {
// Range
bounds := strings.SplitN(part, "-", 2)
lo, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
if err != nil || lo < 1 || lo > 65535 {
return &portValidationError{part}
}
hi, err := strconv.Atoi(strings.TrimSpace(bounds[1]))
if err != nil || hi < 1 || hi > 65535 {
return &portValidationError{part}
}
if hi < lo {
return &portValidationError{part}
}
} else {
p, err := strconv.Atoi(part)
if err != nil || p < 1 || p > 65535 {
return &portValidationError{part}
}
}
}
if partCount > 15 {
return &portValidationError{"too many ports; maximum 15 items per rule"}
}
return nil
}
func validateFirewallIPSpec(value string, network string) error {
var addr netip.Addr
if strings.Contains(value, "/") {
prefix, err := netip.ParsePrefix(value)
if err != nil {
return err
}
addr = prefix.Addr()
} else {
parsed, err := netip.ParseAddr(value)
if err != nil {
return err
}
addr = parsed
}
switch network {
case "ipv4":
if !addr.Is4() {
return &ipValidationError{"IPv4 rule requires an IPv4 address or CIDR: " + value}
}
case "ipv6":
if !addr.Is6() || addr.Is4In6() {
return &ipValidationError{"IPv6 rule requires an IPv6 address or CIDR: " + value}
}
}
return nil
}
type ipValidationError struct {
value string
}
func (e *ipValidationError) Error() string {
return e.value
}
type portValidationError struct {
port string
}
func (e *portValidationError) Error() string {
return "invalid port value: " + e.port
}
+168 -14
View File
@@ -2,6 +2,8 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
@@ -182,6 +184,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return
}
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case action == "firewall" && r.Method == http.MethodGet:
if !requireScope(w, r, "container:network") {
return
}
getFirewall(w, r, id)
case action == "firewall" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updateFirewall(w, r, id)
case r.Method == http.MethodGet:
if !requireScope(w, r, "container:read") {
return
@@ -200,10 +212,21 @@ func listContainers(w http.ResponseWriter, r *http.Request) {
func createContainer(w http.ResponseWriter, r *http.Request) {
var cfg lxc.ContainerConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
_ = json.Unmarshal(body, &fields)
if err := normalizeCreateResourceLimits(&cfg, fields); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.Name == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return
@@ -376,10 +399,14 @@ func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) {
func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
var req struct {
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
IOMBps int `json:"io_speed_mbps"`
BWMbps int `json:"network_bw_mbps"`
VCPU *float64 `json:"vcpu"`
RAMMB *int `json:"ram_mb"`
IOMBps *int `json:"io_speed_mbps"`
IOReadMBps *int `json:"io_read_mbps"`
IOWriteMBps *int `json:"io_write_mbps"`
BWMbps *int `json:"network_bw_mbps"`
NetworkDownMbps *int `json:"network_down_mbps"`
NetworkUpMbps *int `json:"network_up_mbps"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
@@ -394,21 +421,35 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Update config
nextVCPU := c.VCPU
nextRAMMB := c.RAMMB
if req.VCPU > 0 {
nextVCPU = req.VCPU
if req.VCPU != nil {
nextVCPU = *req.VCPU
}
if req.RAMMB > 0 {
nextRAMMB = req.RAMMB
if req.RAMMB != nil {
nextRAMMB = *req.RAMMB
}
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
for name, value := range map[string]*int{
"network_bw_mbps": req.BWMbps,
"network_down_mbps": req.NetworkDownMbps,
"network_up_mbps": req.NetworkUpMbps,
"io_speed_mbps": req.IOMBps,
"io_read_mbps": req.IOReadMBps,
"io_write_mbps": req.IOWriteMBps,
} {
if err := rejectNegativeLimit(name, value); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
}
c.VCPU = nextVCPU
c.RAMMB = nextRAMMB
c.IOSpeedMBps = req.IOMBps
c.NetworkBWMbps = req.BWMbps
applyNetworkLimitPatch(c, req.BWMbps, req.NetworkDownMbps, req.NetworkUpMbps)
applyIOLimitPatch(c, req.IOMBps, req.IOReadMBps, req.IOWriteMBps)
config.NormalizeContainerResourceAliases(c)
config.SaveConfig()
// Re-apply resource limits to running container
@@ -426,6 +467,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 {
@@ -433,9 +582,14 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
return
}
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
// Try random ports
for tries := 0; tries < 100; tries++ {
port := 10000 + (int(time.Now().UnixNano()) % 55535)
start, end := config.NATPortRange()
capacity := end - start + 1
offset := 0
if capacity > 0 {
offset = int(time.Now().UnixNano() % int64(capacity))
}
for tries := 0; tries < capacity; tries++ {
port := start + ((offset + tries) % capacity)
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
return
+30 -7
View File
@@ -17,6 +17,11 @@ type routeCapacity struct {
Total string `json:"total"`
}
type nat4PortRange struct {
Start int `json:"start"`
End int `json:"end"`
}
type nat4Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
@@ -53,6 +58,7 @@ type ipv6Route struct {
type routingResponse struct {
NAT4 routeCapacity `json:"nat4"`
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
IPv4 routeCapacity `json:"ipv4"`
IPv6 routeCapacity `json:"ipv6"`
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
@@ -64,9 +70,10 @@ type routingResponse struct {
}
type routingPoolsRequest struct {
Addresses *[]string `json:"addresses"`
Items *[]config.PublicIPv4Assignment `json:"items"`
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
Addresses *[]string `json:"addresses"`
Items *[]config.PublicIPv4Assignment `json:"items"`
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
NAT4PortRange *nat4PortRange `json:"nat4_port_range"`
}
type publicIPv4ScanRequest struct {
@@ -120,13 +127,12 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
ipv4Assignments := make([]ipv4Route, 0)
ipv6Assignments := make([]ipv6Route, 0)
const nat4StartPort = 20000
const nat4EndPort = 65535
nat4StartPort, nat4EndPort := config.NATPortRange()
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
for _, pm := range c.PortMappings {
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
if config.NATPortInRange(pm.HostPort) {
usedPorts[pm.HostPort] = true
}
nat4Mappings = append(nat4Mappings, nat4Route{
@@ -189,7 +195,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
totalNAT4Ports := config.NATPortCapacity()
nat4Used := len(usedPorts)
nat4Remaining := totalNAT4Ports - nat4Used
if nat4Remaining < 0 {
@@ -216,6 +222,10 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
Remaining: strconv.Itoa(nat4Remaining),
Total: strconv.Itoa(totalNAT4Ports),
},
NAT4PortRange: nat4PortRange{
Start: nat4StartPort,
End: nat4EndPort,
},
IPv4: routeCapacity{
Used: ipv4Used,
Remaining: strconv.Itoa(ipv4Remaining),
@@ -246,6 +256,19 @@ func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
return
}
if req.NAT4PortRange != nil {
start, end, err := config.NormalizeNATPortRange(req.NAT4PortRange.Start, req.NAT4PortRange.End)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
config.AppConfig.NATPortStart = start
config.AppConfig.NATPortEnd = end
if config.AppConfig.NextSSHPort < start || config.AppConfig.NextSSHPort > end {
config.AppConfig.NextSSHPort = start
}
}
if req.Items != nil || req.Addresses != nil {
items := []config.PublicIPv4Assignment{}
if req.Items != nil {
+1
View File
@@ -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)
}
+165 -53
View File
@@ -49,15 +49,19 @@ type connEntry struct {
}
type trafficStats struct {
total int
totalSynSent int
destCounts map[string]int
destPorts map[string]map[int]int
portDestCounts map[int]map[string]int
portTotalCounts map[int]int
udpDestCounts map[int]map[string]int
udpTotalCounts map[int]int
synSentByDst map[string]int
total int
totalSynSent int
destCounts map[string]int
destPorts map[string]map[int]int
portDestCounts map[int]map[string]int
portTotalCounts map[int]int
udpDestCounts map[int]map[string]int
udpTotalCounts map[int]int
udpDestTotalCounts map[string]int
synSentByDst map[string]int
tcpSynDestPorts map[string]map[int]int
tcpSynPortDestCounts map[int]map[string]int
tcpSynPortTotalCounts map[int]int
}
var scanner *SecurityScanner
@@ -180,6 +184,12 @@ func (ss *SecurityScanner) monitorLoop() {
}
}
func (ss *SecurityScanner) alertCount() int {
ss.mu.Lock()
defer ss.mu.Unlock()
return len(ss.alerts)
}
func (ss *SecurityScanner) checkAllContainers() {
for _, c := range config.AppConfig.Containers {
if c.Status != "running" || c.IP == "" {
@@ -208,6 +218,7 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
return
}
alertBefore := ss.alertCount()
ss.detectPortScans(name, ip, stats)
ss.detectBruteForce(name, ip, stats)
ss.detectSpam(name, ip, stats)
@@ -216,17 +227,26 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
ss.detectMining(name, ip, stats)
ss.detectProxyAndTor(name, ip, stats)
ss.detectMalware(name, ip, stats)
// If new alerts were generated, snapshot the conntrack data for later retrieval.
if ss.alertCount() > alertBefore {
config.SaveConntrackSnapshot(ip, lines)
}
}
func newTrafficStats() *trafficStats {
return &trafficStats{
destCounts: make(map[string]int),
destPorts: make(map[string]map[int]int),
portDestCounts: make(map[int]map[string]int),
portTotalCounts: make(map[int]int),
udpDestCounts: make(map[int]map[string]int),
udpTotalCounts: make(map[int]int),
synSentByDst: make(map[string]int),
destCounts: make(map[string]int),
destPorts: make(map[string]map[int]int),
portDestCounts: make(map[int]map[string]int),
portTotalCounts: make(map[int]int),
udpDestCounts: make(map[int]map[string]int),
udpTotalCounts: make(map[int]int),
synSentByDst: make(map[string]int),
udpDestTotalCounts: make(map[string]int),
tcpSynDestPorts: make(map[string]map[int]int),
tcpSynPortDestCounts: make(map[int]map[string]int),
tcpSynPortTotalCounts: make(map[int]int),
}
}
@@ -252,52 +272,64 @@ func (ts *trafficStats) add(conn connEntry) {
}
ts.udpDestCounts[conn.dstPort][conn.dstIP]++
ts.udpTotalCounts[conn.dstPort]++
ts.udpDestTotalCounts[conn.dstIP]++
}
}
if conn.state == "SYN_SENT" {
if conn.proto == "tcp" && conn.state == "SYN_SENT" {
ts.totalSynSent++
ts.synSentByDst[conn.dstIP]++
if conn.dstPort > 0 {
if ts.tcpSynDestPorts[conn.dstIP] == nil {
ts.tcpSynDestPorts[conn.dstIP] = make(map[int]int)
}
ts.tcpSynDestPorts[conn.dstIP][conn.dstPort]++
if ts.tcpSynPortDestCounts[conn.dstPort] == nil {
ts.tcpSynPortDestCounts[conn.dstPort] = make(map[string]int)
}
ts.tcpSynPortDestCounts[conn.dstPort][conn.dstIP]++
ts.tcpSynPortTotalCounts[conn.dstPort]++
}
}
}
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
for dstIP, portCounts := range stats.destPorts {
for dstIP, portCounts := range stats.tcpSynDestPorts {
uniquePorts := len(portCounts)
switch {
case uniquePorts >= 20:
case uniquePorts >= 25:
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
"")
case uniquePorts >= 8:
case uniquePorts >= 12:
ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0,
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
"")
}
}
for port, targets := range stats.portDestCounts {
for port, targets := range stats.tcpSynPortDestCounts {
uniqueTargets := len(targets)
if service, ok := bruteForcePorts[port]; ok {
if uniqueTargets >= 30 {
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
} else if uniqueTargets >= 10 {
} else if uniqueTargets >= 12 {
ss.addAlert(name, "brute_force", "high", ip, "*", port,
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
}
continue
}
if uniqueTargets >= 40 {
if uniqueTargets >= 50 {
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
fmt.Sprintf("横向扫描: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
"")
} else if uniqueTargets >= 15 {
} else if uniqueTargets >= 20 {
ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port,
fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
fmt.Sprintf("可疑横向探测: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
"")
}
}
@@ -311,13 +343,25 @@ func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats
continue
}
if count >= 20 {
synCount := 0
if ports := stats.tcpSynDestPorts[dstIP]; ports != nil {
synCount = ports[port]
}
if synCount >= 25 {
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
fmt.Sprintf("暴力破解: %s(%d) 当前连接 %d", service, port, count),
fmt.Sprintf("暴力破解: %s(%d) 当前 TCP 半开连接 %d", service, port, synCount),
"")
} else if count >= 10 {
} else if synCount >= 12 {
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接 %d", service, port, count),
fmt.Sprintf("疑似暴力破解: %s(%d) 当前 TCP 半开连接 %d", service, port, synCount),
"")
} else if count >= 60 {
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
"")
} else if count >= 30 {
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
"")
}
}
@@ -344,30 +388,41 @@ func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) {
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
targets := len(stats.destCounts)
switch {
case targets >= 100:
case targets >= 120 && stats.total >= 600:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets),
fmt.Sprintf("大规模对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
"")
case targets >= 35:
case targets >= 60 && stats.total >= 300:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
fmt.Sprintf("大量对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
"")
}
synTargets := len(stats.synSentByDst)
switch {
case stats.total >= 500:
case stats.totalSynSent >= 250 || (synTargets >= 80 && stats.totalSynSent >= 160):
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total),
fmt.Sprintf("大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
"")
case stats.total >= 200:
case stats.totalSynSent >= 100 || (synTargets >= 35 && stats.totalSynSent >= 70):
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("连接: 当前 conntrack 出站记录 %d 条", stats.total),
fmt.Sprintf("可疑大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
"")
}
if stats.totalSynSent >= 100 {
udpTargets := len(stats.udpDestTotalCounts)
udpTotal := 0
for _, count := range stats.udpTotalCounts {
udpTotal += count
}
switch {
case udpTargets >= 120 && udpTotal >= 300:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent),
fmt.Sprintf("UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
"")
case udpTargets >= 50 && udpTotal >= 120:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("可疑 UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
"")
}
@@ -392,11 +447,18 @@ func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *traffic
continue
}
if targets >= 30 || total >= 100 {
criticalTargets, criticalTotal := 40, 120
highTargets, highTotal := 15, 45
if port == 53 {
criticalTargets, criticalTotal = 75, 300
highTargets, highTotal = 25, 100
}
if targets >= criticalTargets && total >= criticalTotal {
ss.addAlert(name, "reflection", "critical", ip, "*", port,
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
} else if targets >= 10 || total >= 30 {
} else if targets >= highTargets && total >= highTotal {
ss.addAlert(name, "reflection", "high", ip, "*", port,
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
@@ -633,6 +695,9 @@ func severityRank(severity string) int {
}
func autoShutdownAlertContainer(containerName, alertType, severity string) {
if !config.AppConfig.SecurityAutoShutdown {
return
}
c := config.FindContainerByName(containerName)
if c == nil || c.Status != "running" {
return
@@ -648,6 +713,24 @@ func autoShutdownAlertContainer(containerName, alertType, severity string) {
}
}
func clearSecurityPolicyBlocks() int {
cleared := 0
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if !c.PolicyBlocked || !isSecurityPolicyBlockReason(c.PolicyBlockedReason) {
continue
}
config.SetContainerPolicyBlock(c.ID, false, "")
config.AddAuditLog("security_policy_unblock", c.Name, "关闭安全告警自动关机后解除策略临时封禁", "system")
cleared++
}
return cleared
}
func isSecurityPolicyBlockReason(reason string) bool {
return strings.Contains(reason, "告警触发策略临时封禁")
}
// HandleSecurityAlerts returns all security alerts.
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -687,9 +770,17 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
cancelledTasks := 0
clearedBlocks := 0
if !req.AutoShutdown {
cancelledTasks = globalQueue.CancelPendingSecurityStops()
clearedBlocks = clearSecurityPolicyBlocks()
}
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
"cancelled_tasks": cancelledTasks,
"cleared_blocks": clearedBlocks,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
@@ -759,28 +850,49 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
func getConnectionLogs(ip string) []map[string]interface{} {
logs := make([]map[string]interface{}, 0)
seen := map[string]bool{}
for _, line := range readConntrackLines(ip) {
parseLine := func(line string) map[string]interface{} {
srcIP := extractField(line, "src=")
dstIP := extractField(line, "dst=")
srcPort := extractField(line, "sport=")
dstPort := extractField(line, "dport=")
sPort, _ := strconv.Atoi(srcPort)
dPort, _ := strconv.Atoi(dstPort)
logs = append(logs, map[string]interface{}{
return map[string]interface{}{
"src_ip": srcIP,
"dst_ip": dstIP,
"src_port": sPort,
"dst_port": dPort,
"protocol": extractProtocol(line),
"state": extractConnState(line),
})
}
}
// First, load stored snapshots from database (persisted at alert time).
for _, line := range config.GetConntrackSnapshotLines(ip) {
if len(logs) >= 100 {
break
}
key := strings.TrimSpace(line)
if key == "" || seen[key] {
continue
}
seen[key] = true
logs = append(logs, parseLine(line))
}
// Then, merge live conntrack data (deduplicated).
for _, line := range readConntrackLines(ip) {
if len(logs) >= 100 {
break
}
key := strings.TrimSpace(line)
if key == "" || seen[key] {
continue
}
seen[key] = true
logs = append(logs, parseLine(line))
}
return logs
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"fmt"
"testing"
"clicd/internal/config"
)
func TestDetectReflectionAbuseIgnoresSingleDNSResolver(t *testing.T) {
resetSecurityTestConfig()
stats := newTrafficStats()
for i := 0; i < 180; i++ {
stats.add(connEntry{
dstIP: "1.1.1.1",
dstPort: 53,
proto: "udp",
state: "UNREPLIED",
})
}
ss := newSecurityScanner()
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
if len(ss.alerts) != 0 {
t.Fatalf("normal DNS queries to one resolver should not trigger reflection alert: %+v", ss.alerts)
}
}
func TestDetectReflectionAbuseFlagsWideDNSFanout(t *testing.T) {
resetSecurityTestConfig()
stats := newTrafficStats()
for i := 0; i < 120; i++ {
stats.add(connEntry{
dstIP: fmt.Sprintf("203.0.113.%d", i),
dstPort: 53,
proto: "udp",
state: "UNREPLIED",
})
}
ss := newSecurityScanner()
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
if len(ss.alerts) != 1 {
t.Fatalf("expected one reflection alert, got %+v", ss.alerts)
}
if got := ss.alerts[0].Type; got != "reflection" {
t.Fatalf("expected reflection alert, got %q", got)
}
}
func TestDetectPortScansUsesHalfOpenConnections(t *testing.T) {
resetSecurityTestConfig()
established := newTrafficStats()
for port := 8000; port < 8020; port++ {
established.add(connEntry{
dstIP: "198.51.100.10",
dstPort: port,
proto: "tcp",
state: "ESTABLISHED",
})
}
ss := newSecurityScanner()
ss.detectPortScans("ct-web", "10.0.0.3", established)
if len(ss.alerts) != 0 {
t.Fatalf("established multi-port connections should not trigger port scan alert: %+v", ss.alerts)
}
halfOpen := newTrafficStats()
for port := 8000; port < 8012; port++ {
halfOpen.add(connEntry{
dstIP: "198.51.100.10",
dstPort: port,
proto: "tcp",
state: "SYN_SENT",
})
}
ss.detectPortScans("ct-web", "10.0.0.3", halfOpen)
if len(ss.alerts) != 1 {
t.Fatalf("expected one port scan alert, got %+v", ss.alerts)
}
if got := ss.alerts[0].Type; got != "port_scan" {
t.Fatalf("expected port_scan alert, got %q", got)
}
}
func TestCancelPendingSecurityStops(t *testing.T) {
resetSecurityTestConfig()
q := &TaskQueue{
tasks: map[string]*Task{},
}
securityTask := &Task{
ID: "task-1",
Type: TaskStop,
ContainerID: 1,
Status: "pending",
User: "system:security",
}
userTask := &Task{
ID: "task-2",
Type: TaskStop,
ContainerID: 2,
Status: "pending",
User: "admin",
}
runningSecurityTask := &Task{
ID: "task-3",
Type: TaskStop,
ContainerID: 3,
Status: "running",
User: "system:security",
}
q.tasks[securityTask.ID] = securityTask
q.tasks[userTask.ID] = userTask
q.tasks[runningSecurityTask.ID] = runningSecurityTask
q.opQueue = []*Task{securityTask, userTask, runningSecurityTask}
if got := q.CancelPendingSecurityStops(); got != 1 {
t.Fatalf("expected one pending security stop to be cancelled, got %d", got)
}
if _, ok := q.tasks[securityTask.ID]; ok {
t.Fatal("pending security stop task was not removed")
}
if _, ok := q.tasks[userTask.ID]; !ok {
t.Fatal("user stop task should not be removed")
}
if _, ok := q.tasks[runningSecurityTask.ID]; !ok {
t.Fatal("running security stop task should be left for worker-side skip")
}
if len(q.opQueue) != 2 {
t.Fatalf("expected op queue to keep two tasks, got %d", len(q.opQueue))
}
}
func resetSecurityTestConfig() {
config.AppConfig = &config.ClicdConfig{
Containers: []config.Container{},
AuditLogs: []config.AuditLog{},
Tasks: []config.SavedTask{},
}
}
+10 -1
View File
@@ -373,6 +373,15 @@ func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return
}
imagesEnabledPath := "/api/images/enabled"
if strings.HasPrefix(path, "/api/v1/") {
imagesEnabledPath = "/api/v1/images/enabled"
}
if path == imagesEnabledPath && r.Method == http.MethodGet {
next(w, r)
return
}
if path == containerListPath {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
@@ -503,7 +512,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
return method == http.MethodPost
case strings.HasPrefix(action, "snapshots/"):
return method == http.MethodDelete || method == http.MethodPost
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
case action == "start" || action == "stop" || action == "restart" || action == "reinstall" || action == "reset-password":
return method == http.MethodPost
case strings.HasPrefix(action, "port-mappings/"):
return method == http.MethodPut
+76 -19
View File
@@ -98,6 +98,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 +163,7 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
var result []string
for _, cfg := range configs {
cfgCopy := cfg
cfgCopy.NormalizeResourceAliases()
id := q.nextID
q.nextID++
task := &Task{
@@ -211,6 +213,10 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
}
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
if !config.AppConfig.SecurityAutoShutdown {
return "", false
}
q.mu.Lock()
defer q.mu.Unlock()
@@ -228,6 +234,34 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
return taskID, true
}
func (q *TaskQueue) CancelPendingSecurityStops() int {
q.mu.Lock()
defer q.mu.Unlock()
cancelled := 0
newOpQueue := make([]*Task, 0, len(q.opQueue))
for _, task := range q.opQueue {
if isSecurityStopTask(task) && task.Status == "pending" {
delete(q.tasks, task.ID)
cancelled++
continue
}
newOpQueue = append(newOpQueue, task)
}
q.opQueue = newOpQueue
for id, task := range q.tasks {
if isSecurityStopTask(task) && task.Status == "pending" {
delete(q.tasks, id)
cancelled++
}
}
if cancelled > 0 {
q.persistTasks()
}
return cancelled
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
// If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}.
@@ -246,6 +280,7 @@ func (q *TaskQueue) createWorker() {
if task.Config.Name == "" {
task.Config.Name = task.ContainerName
}
task.Config.NormalizeResourceAliases()
if task.Config.Name == "" {
task.Status = "failed"
task.Error = "container name is required"
@@ -321,6 +356,7 @@ func (q *TaskQueue) opWorker() {
q.mu.Unlock()
var err error
skipped := false
err = resolveTaskContainer(task)
// Block operations on expired or traffic-exceeded containers (except stop/delete)
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
@@ -333,27 +369,32 @@ func (q *TaskQueue) opWorker() {
}
}
}
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
skipped = true
}
if err == nil {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
if !skipped {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
}
case TaskReinstall:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
case TaskReinstall:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
}
@@ -367,6 +408,9 @@ func (q *TaskQueue) opWorker() {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else if skipped {
task.Status = "done"
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
} else {
task.Status = "done"
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
@@ -388,6 +432,10 @@ func (q *TaskQueue) opWorker() {
}
}
func isSecurityStopTask(task *Task) bool {
return task != nil && task.Type == TaskStop && task.User == "system:security"
}
func clearPolicyBlockAfterAdminRecovery(task *Task) {
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
return
@@ -592,6 +640,11 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1
}
if err := rejectNegativeCreateLimits(req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
req.Containers[i].NormalizeResourceAliases()
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512
@@ -809,6 +862,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
// RestoreTasks restores task queue from config
func RestoreTasks() {
for _, st := range config.AppConfig.Tasks {
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
continue
}
var cfg lxc.ContainerConfig
if st.Config != "" {
json.Unmarshal([]byte(st.Config), &cfg)
@@ -820,6 +876,7 @@ func RestoreTasks() {
if cfg.Name == "" {
cfg.Name = containerName
}
cfg.NormalizeResourceAliases()
containerID := st.ContainerID
if containerID <= 0 && containerName != "" {
if c := config.FindContainerByName(containerName); c != nil {
+2 -1
View File
@@ -34,7 +34,7 @@ var cliTranslations = map[string]string{
"请选择操作": "Select an action",
"再见": "Goodbye",
"无效选择": "Invalid choice",
"CLICD - LXC 容器管理器": "CLICD - LXC Container Manager",
"CLICD - LXC 容器管理器": "CLICD - Container Manager",
"Web 面板": "Web panel",
"端口": "port",
"运行中": "running",
@@ -388,6 +388,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 {
+215 -9
View File
@@ -22,6 +22,18 @@ type PortMapping struct {
Description string `json:"description"`
}
type FirewallRule struct {
ID string `json:"id"`
Network string `json:"network,omitempty"` // "ipv4", "ipv6", or "all"; empty defaults to "ipv4"
Direction string `json:"direction"` // "in" or "out"
Protocol string `json:"protocol"` // "tcp", "udp", "icmp", "all"
Port string `json:"port"` // "" = all, "22", "80,443", "8000-9000"
SourceIP string `json:"source_ip"` // "" = any
Action string `json:"action"` // "ACCEPT" or "DROP"
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
type PublicIPv4Assignment struct {
Address string `json:"address"`
Interface string `json:"interface,omitempty"`
@@ -103,6 +115,8 @@ type Container struct {
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
@@ -111,6 +125,8 @@ 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"`
IP string `json:"ip"`
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
@@ -124,6 +140,9 @@ type Container struct {
SSHHostKey string `json:"ssh_host_key,omitempty"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
FirewallEnabled bool `json:"firewall_enabled"`
FirewallDefaultAction string `json:"firewall_default_action"`
FirewallRules []FirewallRule `json:"firewall_rules"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
@@ -353,6 +372,8 @@ type ClicdConfig struct {
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
NATPortStart int `json:"nat_port_start"`
NATPortEnd int `json:"nat_port_end"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
@@ -375,6 +396,11 @@ var AppConfig *ClicdConfig
const DefaultSnapshotLimit = 3
const (
DefaultNATPortStart = 20000
DefaultNATPortEnd = 65535
)
func getConfigPath() string {
if configPath != "" {
return configPath
@@ -490,6 +516,8 @@ func InitConfig() (*ClicdConfig, error) {
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
NATPortStart: DefaultNATPortStart,
NATPortEnd: DefaultNATPortEnd,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
@@ -533,6 +561,9 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.NextSSHPort = 22000
changed = true
}
if normalizeNATPortRangeDefaults() {
changed = true
}
if AppConfig.NextContainerID == 0 {
AppConfig.NextContainerID = 1
changed = true
@@ -689,6 +720,9 @@ func migrateLoadedConfig() bool {
if ensureContainerNetworkAssignments() {
changed = true
}
if ensureContainerResourceAliases() {
changed = true
}
if ensureContainerSnapshotScheduleDefaults() {
changed = true
}
@@ -787,6 +821,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 {
@@ -871,6 +990,7 @@ func AddContainer(c Container) {
c.UUID = NewContainerUUID()
}
c.Virtualization = NormalizeVirtualization(c.Virtualization)
NormalizeContainerResourceAliases(&c)
AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig()
}
@@ -1060,16 +1180,102 @@ func UpdateVNC(containers []Container) {
SaveConfig()
}
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() int {
used := collectAllHostPorts()
port := AppConfig.NextSSHPort
for used[port] {
port++
func NormalizeNATPortRange(start, end int) (int, int, error) {
if start == 0 && end == 0 {
return DefaultNATPortStart, DefaultNATPortEnd, nil
}
AppConfig.NextSSHPort = port + 1
SaveConfig()
return port
if start == 0 {
start = DefaultNATPortStart
}
if end == 0 {
end = DefaultNATPortEnd
}
if start < 1 || start > 65535 {
return 0, 0, fmt.Errorf("NAT port start must be 1-65535")
}
if end < 1 || end > 65535 {
return 0, 0, fmt.Errorf("NAT port end must be 1-65535")
}
if start > end {
return 0, 0, fmt.Errorf("NAT port start cannot be greater than end")
}
return start, end, nil
}
func NATPortRange() (int, int) {
if AppConfig == nil {
return DefaultNATPortStart, DefaultNATPortEnd
}
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
if err != nil {
return DefaultNATPortStart, DefaultNATPortEnd
}
return start, end
}
func NATPortCapacity() int {
start, end := NATPortRange()
return end - start + 1
}
func NATPortInRange(port int) bool {
start, end := NATPortRange()
return port >= start && port <= end
}
func SetNATPortRange(start, end int) error {
start, end, err := NormalizeNATPortRange(start, end)
if err != nil {
return err
}
AppConfig.NATPortStart = start
AppConfig.NATPortEnd = end
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
AppConfig.NextSSHPort = start
}
return SaveConfig()
}
func normalizeNATPortRangeDefaults() bool {
if AppConfig == nil {
return false
}
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
if err != nil {
start, end = DefaultNATPortStart, DefaultNATPortEnd
}
changed := AppConfig.NATPortStart != start || AppConfig.NATPortEnd != end
AppConfig.NATPortStart = start
AppConfig.NATPortEnd = end
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
AppConfig.NextSSHPort = start
changed = true
}
return changed
}
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() (int, error) {
used := collectAllHostPorts()
start, end := NATPortRange()
port := AppConfig.NextSSHPort
if port < start || port > end {
port = start
}
capacity := end - start + 1
for i := 0; i < capacity; i++ {
candidate := start + ((port - start + i) % capacity)
if used[candidate] {
continue
}
AppConfig.NextSSHPort = candidate + 1
if AppConfig.NextSSHPort > end {
AppConfig.NextSSHPort = start
}
SaveConfig()
return candidate, nil
}
return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end)
}
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
+46
View File
@@ -0,0 +1,46 @@
package config
import "testing"
func TestAllocateSSHPortUsesConfiguredNATRange(t *testing.T) {
AppConfig = &ClicdConfig{
NATPortStart: 30000,
NATPortEnd: 30002,
NextSSHPort: 22000,
Containers: []Container{{
PortMappings: []PortMapping{
{HostPort: 30000},
{HostPort: 30001},
},
}},
}
port, err := AllocateSSHPort()
if err != nil {
t.Fatal(err)
}
if port != 30002 {
t.Fatalf("expected port 30002, got %d", port)
}
if AppConfig.NextSSHPort != 30000 {
t.Fatalf("expected next port to wrap to 30000, got %d", AppConfig.NextSSHPort)
}
}
func TestAllocateSSHPortErrorsWhenConfiguredRangeIsFull(t *testing.T) {
AppConfig = &ClicdConfig{
NATPortStart: 31000,
NATPortEnd: 31001,
NextSSHPort: 31000,
Containers: []Container{{
PortMappings: []PortMapping{
{HostPort: 31000},
{HostPort: 31001},
},
}},
}
if port, err := AllocateSSHPort(); err == nil {
t.Fatalf("expected exhausted NAT range error, got port %d", port)
}
}
+239 -28
View File
@@ -28,11 +28,15 @@ type savedTaskConfig struct {
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"`
@@ -55,10 +59,12 @@ func parseSavedTaskConfig(raw string) savedTaskConfig {
}
var cfg savedTaskConfig
_ = json.Unmarshal([]byte(raw), &cfg)
normalizeSavedTaskConfigLimits(&cfg)
return cfg
}
func encodeSavedTaskConfig(cfg savedTaskConfig) string {
normalizeSavedTaskConfigLimits(&cfg)
data, err := json.Marshal(cfg)
if err != nil {
return ""
@@ -66,6 +72,41 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
return string(data)
}
func normalizeSavedTaskConfigLimits(cfg *savedTaskConfig) {
if cfg == nil {
return
}
if cfg.NetworkBWMbps < 0 {
cfg.NetworkBWMbps = 0
}
if cfg.NetworkDownMbps < 0 {
cfg.NetworkDownMbps = 0
}
if cfg.NetworkUpMbps < 0 {
cfg.NetworkUpMbps = 0
}
if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 {
cfg.NetworkDownMbps = cfg.NetworkBWMbps
cfg.NetworkUpMbps = cfg.NetworkBWMbps
}
cfg.NetworkBWMbps = LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps)
if cfg.IOSpeedMBps < 0 {
cfg.IOSpeedMBps = 0
}
if cfg.IOReadMBps < 0 {
cfg.IOReadMBps = 0
}
if cfg.IOWriteMBps < 0 {
cfg.IOWriteMBps = 0
}
if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 {
cfg.IOReadMBps = cfg.IOSpeedMBps
cfg.IOWriteMBps = cfg.IOSpeedMBps
}
cfg.IOSpeedMBps = LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps)
}
func encodeStringSlice(values []string) string {
if len(values) == 0 {
return ""
@@ -148,6 +189,8 @@ func ensureSchema() error {
ram_mb INTEGER,
disk_gb INTEGER,
network_bw_mbps INTEGER,
network_down_mbps INTEGER NOT NULL DEFAULT 0,
network_up_mbps INTEGER NOT NULL DEFAULT 0,
monthly_traffic_gb INTEGER,
traffic_mode TEXT,
traffic_in_gb INTEGER,
@@ -156,6 +199,8 @@ func ensureSchema() error {
traffic_used_tx INTEGER,
traffic_reset_date TEXT,
io_speed_mbps INTEGER,
io_read_mbps INTEGER NOT NULL DEFAULT 0,
io_write_mbps INTEGER NOT NULL DEFAULT 0,
status TEXT,
ip TEXT,
ipv6 TEXT,
@@ -254,6 +299,14 @@ func ensureSchema() error {
success INTEGER,
error TEXT
)`,
`CREATE TABLE IF NOT EXISTS security_conntrack_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
container_ip TEXT NOT NULL,
line TEXT NOT NULL,
captured_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_conntrack_snapshots_ip_time
ON security_conntrack_snapshots(container_ip, captured_at)`,
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
type TEXT,
@@ -274,11 +327,15 @@ func ensureSchema() error {
cfg_ram_mb INTEGER,
cfg_disk_gb INTEGER,
cfg_network_bw_mbps INTEGER,
cfg_network_down_mbps INTEGER NOT NULL DEFAULT 0,
cfg_network_up_mbps INTEGER NOT NULL DEFAULT 0,
cfg_monthly_traffic_gb INTEGER,
cfg_traffic_mode TEXT,
cfg_traffic_in_gb INTEGER,
cfg_traffic_out_gb INTEGER,
cfg_io_speed_mbps INTEGER,
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
cfg_port_mapping_count INTEGER,
cfg_assign_nat INTEGER,
cfg_snapshot_limit INTEGER,
@@ -332,6 +389,7 @@ func ensureSchema() error {
}
func ensureSchemaMigrations() error {
added := map[string]bool{}
for _, column := range []struct {
table string
name string
@@ -344,6 +402,10 @@ 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"},
@@ -356,18 +418,61 @@ func ensureSchemaMigrations() error {
{"port_mappings", "host_ip", "TEXT"},
{"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"},
{"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
{"containers", "firewall_rules", "TEXT"},
} {
if err := ensureColumn(column.table, column.name, column.def); err != nil {
wasAdded, err := ensureColumn(column.table, column.name, column.def)
if err != nil {
return err
}
if wasAdded {
added[column.table+"."+column.name] = true
}
}
if added["containers.network_down_mbps"] || added["containers.network_up_mbps"] {
if _, err := db.Exec(`UPDATE containers
SET network_down_mbps = COALESCE(NULLIF(network_down_mbps, 0), COALESCE(network_bw_mbps, 0)),
network_up_mbps = COALESCE(NULLIF(network_up_mbps, 0), COALESCE(network_bw_mbps, 0))
WHERE COALESCE(network_bw_mbps, 0) > 0`); err != nil {
return err
}
}
if added["containers.io_read_mbps"] || added["containers.io_write_mbps"] {
if _, err := db.Exec(`UPDATE containers
SET io_read_mbps = COALESCE(NULLIF(io_read_mbps, 0), COALESCE(io_speed_mbps, 0)),
io_write_mbps = COALESCE(NULLIF(io_write_mbps, 0), COALESCE(io_speed_mbps, 0))
WHERE COALESCE(io_speed_mbps, 0) > 0`); err != nil {
return err
}
}
if added["tasks.cfg_network_down_mbps"] || added["tasks.cfg_network_up_mbps"] {
if _, err := db.Exec(`UPDATE tasks
SET cfg_network_down_mbps = COALESCE(NULLIF(cfg_network_down_mbps, 0), COALESCE(cfg_network_bw_mbps, 0)),
cfg_network_up_mbps = COALESCE(NULLIF(cfg_network_up_mbps, 0), COALESCE(cfg_network_bw_mbps, 0))
WHERE COALESCE(cfg_network_bw_mbps, 0) > 0`); err != nil {
return err
}
}
if added["tasks.cfg_io_read_mbps"] || added["tasks.cfg_io_write_mbps"] {
if _, err := db.Exec(`UPDATE tasks
SET cfg_io_read_mbps = COALESCE(NULLIF(cfg_io_read_mbps, 0), COALESCE(cfg_io_speed_mbps, 0)),
cfg_io_write_mbps = COALESCE(NULLIF(cfg_io_write_mbps, 0), COALESCE(cfg_io_speed_mbps, 0))
WHERE COALESCE(cfg_io_speed_mbps, 0) > 0`); err != nil {
return err
}
}
return nil
}
func ensureColumn(table, name, def string) error {
func ensureColumn(table, name, def string) (bool, error) {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return err
return false, err
}
defer rows.Close()
for rows.Next() {
@@ -376,17 +481,17 @@ func ensureColumn(table, name, def string) error {
var notNull, pk int
var defaultValue interface{}
if err := rows.Scan(&cid, &columnName, &columnType, &notNull, &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) {
@@ -419,6 +524,8 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
NextContainerID: atoi(meta["next_container_id"]),
NextVNCPort: atoi(meta["next_vnc_port"]),
NextSSHPort: atoi(meta["next_ssh_port"]),
NATPortStart: atoi(meta["nat_port_start"]),
NATPortEnd: atoi(meta["nat_port_end"]),
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
Language: meta["language"],
@@ -546,6 +653,8 @@ func saveMeta(tx *sql.Tx) error {
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"nat_port_start": strconv.Itoa(AppConfig.NATPortStart),
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"language": NormalizeLanguage(AppConfig.Language),
@@ -567,24 +676,31 @@ func saveMeta(tx *sql.Tx) error {
func saveContainers(tx *sql.Tx) error {
for _, c := range AppConfig.Containers {
NormalizeContainerResourceAliases(&c)
if _, err := tx.Exec(`INSERT INTO containers (
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
policy_blocked, policy_blocked_reason, policy_blocked_at,
firewall_enabled, firewall_default_action, firewall_rules
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules),
); err != nil {
return err
}
@@ -642,6 +758,59 @@ func saveAPIKeys(tx *sql.Tx) error {
return nil
}
// SaveConntrackSnapshot stores raw conntrack lines for a container IP.
func SaveConntrackSnapshot(containerIP string, lines []string) {
if db == nil || len(lines) == 0 || strings.TrimSpace(containerIP) == "" {
return
}
now := time.Now().Format("2006-01-02 15:04:05")
tx, err := db.Begin()
if err != nil {
return
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO security_conntrack_snapshots (container_ip, line, captured_at) VALUES (?, ?, ?)`)
if err != nil {
return
}
defer stmt.Close()
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
stmt.Exec(containerIP, line, now)
}
tx.Commit()
// Cleanup old snapshots (>1 hour)
db.Exec(`DELETE FROM security_conntrack_snapshots WHERE captured_at < ?`,
time.Now().Add(-1*time.Hour).Format("2006-01-02 15:04:05"))
}
// GetConntrackSnapshotLines returns stored conntrack lines for a container IP.
func GetConntrackSnapshotLines(containerIP string) []string {
if db == nil || strings.TrimSpace(containerIP) == "" {
return nil
}
rows, err := db.Query(
`SELECT line FROM security_conntrack_snapshots WHERE container_ip = ? ORDER BY captured_at DESC LIMIT 200`,
containerIP,
)
if err != nil {
return nil
}
defer rows.Close()
var lines []string
for rows.Next() {
var line string
if rows.Scan(&line) == nil {
lines = append(lines, line)
}
}
return lines
}
func saveAuditLogs(tx *sql.Tx) error {
for _, log := range AppConfig.AuditLogs {
successSet := 0
@@ -666,15 +835,19 @@ 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_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) 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.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,
@@ -722,13 +895,16 @@ func saveSnapshots(tx *sql.Tx) error {
func loadContainers() ([]Container, error) {
rows, err := db.Query(`SELECT
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps,
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at
policy_blocked, policy_blocked_reason, policy_blocked_at,
firewall_enabled, firewall_default_action, firewall_rules
FROM containers ORDER BY id`)
if err != nil {
return nil, err
@@ -738,21 +914,32 @@ func loadContainers() ([]Container, error) {
result := []Container{}
for rows.Next() {
var c Container
var scheduleEnabled, policyBlocked int
var scheduleEnabled, policyBlocked, firewallEnabled int
var firewallDefaultAction string
var firewallRulesJSON sql.NullString
if err := rows.Scan(
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
); err != nil {
return nil, err
}
c.SnapshotScheduleEnabled = scheduleEnabled != 0
c.PolicyBlocked = policyBlocked != 0
c.FirewallEnabled = firewallEnabled != 0
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
}
NormalizeContainerResourceAliases(&c)
result = append(result, c)
}
if err := rows.Err(); err != nil {
@@ -946,8 +1133,10 @@ 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_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
FROM tasks ORDER BY created_at, id`)
@@ -967,8 +1156,10 @@ func loadTasks() ([]SavedTask, error) {
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, &cfg.SnapshotLimit,
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
); err != nil {
@@ -993,6 +1184,7 @@ func loadTasks() ([]SavedTask, error) {
cfg.SSHAuthMode = sshAuthMode.String
cfg.SSHPassword = sshPassword.String
cfg.SSHPublicKey = sshPublicKey.String
normalizeSavedTaskConfigLimits(&cfg)
result = append(result, t)
configs = append(configs, cfg)
}
@@ -1106,6 +1298,25 @@ func boolInt(value bool) int {
return 0
}
func marshalFirewallRules(rules []FirewallRule) interface{} {
if len(rules) == 0 {
return nil
}
data, err := json.Marshal(rules)
if err != nil {
return nil
}
return string(data)
}
func normalizeFirewallDefaultAction(action string) string {
action = strings.ToUpper(strings.TrimSpace(action))
if action == "ACCEPT" {
return "ACCEPT"
}
return "DROP"
}
func boolPtrInt(value *bool) interface{} {
if value == nil {
return nil
+318 -72
View File
@@ -84,6 +84,7 @@ var (
lastTrafficSnapshot = map[string]trafficSample{}
kvmSnapshotMu sync.Mutex
kvmSSHEnsureLocks sync.Map
knownSSHHostKeys sync.Map // TOFU host key store: host:port → ssh.PublicKey
portMapApplyMu sync.Mutex
lastPortMapApply = map[int]time.Time{}
windowsMetricsMu sync.Mutex
@@ -345,6 +346,7 @@ func normalizeQCOW2(ctx context.Context, src, target string) error {
}
func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
cfg.NormalizeResourceAliases()
image := FindImage(cfg.TemplateID)
if image == nil {
return fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
@@ -407,6 +409,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
mac := randomMAC()
sshPassword := generateRandomString(16)
sshPublicKey := ""
sshAuthMode := ""
if !IsWindowsImage(image.ID) {
sshAccess, err := lxc.ResolveCreateSSHAccess(cfg)
if err != nil {
@@ -414,6 +417,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
sshPassword = sshAccess.Password
sshPublicKey = sshAccess.PublicKey
sshAuthMode = sshAccess.Mode
}
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
@@ -429,7 +433,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
ipv6Assignments = assigned
}
ipv6List := configIPv6AssignmentAddresses(ipv6Assignments)
defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s)
ipv4List := configIPv4AssignmentAddresses(publicIPv4s)
// NAT4 port mappings should bind to the host IP, not the VM's independent public IPv4.
defaultHostIP := ""
var xml string
winAdminPassword := ""
@@ -448,10 +454,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
winAdminPassword = generateWindowsPassword()
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List); err != nil {
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
return nil, err
}
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
} else {
if image.Desktop != "" {
if cfg.RAMMB < 2048 {
@@ -464,10 +470,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err
}
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, *image); err != nil {
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
return nil, err
}
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps, image.Desktop != "")
}
xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -481,7 +487,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPort := 0
portMappings := []config.PortMapping{}
if allocatePorts && cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
sshPort, err = config.AllocateSSHPort()
if err != nil {
return nil, err
}
if IsWindowsImage(image.ID) {
// Windows: RDP (3389) instead of SSH (22)
portMappings = []config.PortMapping{{
@@ -537,12 +546,16 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
RAMMB: cfg.RAMMB,
DiskGB: cfg.DiskGB,
NetworkBWMbps: cfg.NetworkBWMbps,
NetworkDownMbps: cfg.NetworkDownMbps,
NetworkUpMbps: cfg.NetworkUpMbps,
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
TrafficMode: trafficMode,
TrafficInGB: cfg.TrafficInGB,
TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: now[:7],
IOSpeedMBps: cfg.IOSpeedMBps,
IOReadMBps: cfg.IOReadMBps,
IOWriteMBps: cfg.IOWriteMBps,
PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments,
Status: "stopped",
@@ -585,7 +598,7 @@ func (m *Manager) StartContainer(id int) error {
}
}
}
config.UpdateContainerStatus(id, "running")
config.UpdateContainerStatus(id, "initializing")
// Detect VNC port
if _, err := m.RefreshVNCPort(id); err != nil {
fmt.Printf("Warning: failed to refresh VNC port for %s: %v\n", name, err)
@@ -621,7 +634,15 @@ func (m *Manager) StartContainer(id int) error {
if err := lxc.NewManager().ApplyPortMappings(id); err != nil {
return err
}
if err := lxc.ApplyFirewallRules(id); err != nil {
fmt.Printf("Warning: failed to apply firewall rules: %v\n", err)
}
}
// Wait for cloud-init to finish and SSH to be reachable (password-only mode)
if !isWindows && c.IP != "" {
m.waitForCloudInitReady(name, c.IP, c.SSHPassword)
}
config.UpdateContainerStatus(id, "running")
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
return err
@@ -632,12 +653,71 @@ func (m *Manager) StartContainer(id int) error {
return nil
}
// waitForCloudInitReady waits for cloud-init to finish and SSH to be reachable.
// tofuHostKeyCallback implements Trust-On-First-Use host key verification.
// On the first connection to a host, the key is accepted and remembered.
// Subsequent connections must present the same key or the connection is rejected.
func tofuHostKeyCallback(hostname string, remote net.Addr, key ssh.PublicKey) error {
if stored, ok := knownSSHHostKeys.Load(hostname); ok {
if bytes.Equal(stored.(ssh.PublicKey).Marshal(), key.Marshal()) {
return nil
}
return fmt.Errorf("host key mismatch for %s (possible MitM attack)", hostname)
}
knownSSHHostKeys.Store(hostname, key)
return nil
}
func (m *Manager) waitForCloudInitReady(vmName, ip, password string) {
if ip == "" || password == "" {
return
}
deadline := time.Now().Add(3 * time.Minute)
target := net.JoinHostPort(ip, "22")
sshWasUp := false
qgaAttempted := false
for time.Now().Before(deadline) {
client, err := ssh.Dial("tcp", target, &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password(password)},
HostKeyCallback: tofuHostKeyCallback,
Timeout: 5 * time.Second,
})
if err == nil {
client.Close()
if !sshWasUp {
sshWasUp = true
fmt.Printf("KVM %s SSH up, waiting for cloud-init to settle...\n", vmName)
time.Sleep(10 * time.Second)
continue
}
fmt.Printf("KVM %s ready\n", vmName)
return
}
// Try guest agent ONCE to speed things up, with timeout to avoid blocking
if !qgaAttempted && qemuGuestPing(vmName) == nil {
qgaAttempted = true
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
cmd := exec.CommandContext(ctx, "virsh", "qemu-agent-command", vmName,
`{"execute":"guest-exec","arguments":{"path":"/bin/sh","arg":["-c","cloud-init status --wait 2>/dev/null; systemctl restart sshd 2>/dev/null || systemctl restart ssh 2>/dev/null; true"],"capture-output":false}}`)
cmd.Run()
cancel()
}
time.Sleep(5 * time.Second)
}
fmt.Printf("Warning: KVM %s not ready after 3 minutes\n", vmName)
}
func (m *Manager) StopContainer(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
_ = lxc.NewManager().CleanPortMappings(id)
lxc.CleanFirewallRules(id)
name := c.VirshName()
status, _ := m.GetContainerStatus(name)
if status != "running" {
@@ -709,11 +789,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lx
RAMMB: c.RAMMB,
DiskGB: c.DiskGB,
NetworkBWMbps: c.NetworkBWMbps,
NetworkDownMbps: c.NetworkDownMbps,
NetworkUpMbps: c.NetworkUpMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
TrafficMode: c.TrafficMode,
TrafficInGB: c.TrafficInGB,
TrafficOutGB: c.TrafficOutGB,
IOSpeedMBps: c.IOSpeedMBps,
IOReadMBps: c.IOReadMBps,
IOWriteMBps: c.IOWriteMBps,
PortMappingCount: c.PortMappingLimit,
SnapshotLimit: c.SnapshotLimit,
ExpiresAt: c.ExpiresAt,
@@ -810,6 +894,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
if c == nil || !c.IsKVM() {
return nil
}
config.NormalizeContainerResourceAliases(c)
if c.Status == "running" {
// Config already saved; domain definition will be refreshed on next start
return nil
@@ -821,10 +906,10 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
if IsWindowsImage(c.Template) {
winISO := ImagePath(c.Template)
unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName()))
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps)
} else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps, isKVMDesktopTemplate(c.Template))
}
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -841,15 +926,16 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
if c == nil || !c.IsKVM() || c.DiskImage == "" || c.MACAddress == "" {
return nil
}
config.NormalizeContainerResourceAliases(c)
var xml string
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if IsWindowsImage(c.Template) {
winISO := ImagePath(c.Template)
unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName()))
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps)
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps)
} else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template))
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps, isKVMDesktopTemplate(c.Template))
}
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return err
@@ -1115,6 +1201,7 @@ func (m *Manager) prepareVMForColdCopy(id int, name string) (bool, error) {
time.Sleep(time.Second)
} else {
_ = lxc.NewManager().CleanPortMappings(id)
lxc.CleanFirewallRules(id)
}
return wasRunning, nil
}
@@ -1389,7 +1476,7 @@ func (m *Manager) RefreshNetwork(id int) (string, error) {
if changed {
config.SaveConfig()
}
if c.Status == "running" && len(c.PortMappings) > 0 && shouldApplyPortMappings(id, changed) {
if c.Status == "running" && shouldApplyPortMappings(id, changed) {
if err := lxc.NewManager().ApplyPortMappings(id); err != nil {
return ip, err
}
@@ -1596,7 +1683,7 @@ func createEmptyDisk(target string, diskGB int) error {
return nil
}
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string) error {
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error {
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
if tool == "" {
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
@@ -1622,13 +1709,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []st
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil {
return err
}
_ = os.Remove(target)
@@ -1738,7 +1825,7 @@ exit /b 0
`
}
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
func windowsFirstLogonPowerShell(adminPassword, mac string, ipv6s []string, ipv4s []string) string {
commands := []string{
"$ErrorActionPreference='Continue'",
"$ProgressPreference='SilentlyContinue'",
@@ -1748,9 +1835,9 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
"net user Administrator " + shellQuoteWindows(adminPassword) + " /active:yes",
"Set-LocalUser -Name 'Administrator' -PasswordNeverExpires $true -ErrorAction SilentlyContinue",
"Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force",
"$iface=$null",
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
"$iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1",
windowsAdapterDiscoveryPowerShell(mac),
"$iface=Wait-ClicdNetworkAdapter",
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
"if ($iface) { Set-NetIPInterface -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -Dhcp Enabled -ErrorAction SilentlyContinue }",
"if ($iface) { Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ResetServerAddresses -ErrorAction SilentlyContinue }",
"Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue",
@@ -1768,11 +1855,23 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
}
networkCommands := []string{}
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) > 0 {
commands = append(commands,
windowsIPv6PowerShell(ipv6s),
)
networkCommands = append(networkCommands, windowsIPv6PowerShell(ipv6s, mac))
}
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) > 0 {
networkCommands = append(networkCommands, windowsIPv4PowerShell(ipv4s, mac))
}
if len(networkCommands) > 0 {
networkScript := strings.Join(append([]string{
"$ErrorActionPreference='Continue'",
"$ProgressPreference='SilentlyContinue'",
"New-Item -ItemType Directory -Force -Path 'C:\\CLICD' | Out-Null",
}, networkCommands...), "\r\n") + "\r\n"
commands = append(commands, windowsPersistentNetworkTaskPowerShell(networkScript))
commands = append(commands, networkCommands...)
}
commands = append(commands,
"New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null",
@@ -1781,19 +1880,58 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string) string {
return strings.Join(commands, "\r\n") + "\r\n"
}
func windowsIPv6PowerShell(ipv6s []string) string {
func windowsPersistentNetworkTaskPowerShell(script string) string {
return strings.Join([]string{
"$clicdNetworkScript=@'",
strings.TrimRight(script, "\r\n"),
"'@",
"Set-Content -Path 'C:\\CLICD\\ApplyNetwork.ps1' -Value $clicdNetworkScript -Encoding UTF8",
"$clicdNetworkAction=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\\CLICD\\ApplyNetwork.ps1'",
"$clicdNetworkTrigger=New-ScheduledTaskTrigger -AtStartup",
"Register-ScheduledTask -TaskName 'CLICD Network Init' -Action $clicdNetworkAction -Trigger $clicdNetworkTrigger -RunLevel Highest -Force -ErrorAction SilentlyContinue | Out-Null",
}, "\r\n")
}
func windowsAdapterDiscoveryPowerShell(mac string) string {
targetMAC := strings.ToUpper(strings.NewReplacer(":", "", "-", "", " ", "").Replace(strings.TrimSpace(mac)))
return strings.Join([]string{
"$clicdTargetMac=" + powerShellSingleQuote(targetMAC),
"function Get-ClicdNetworkAdapter {",
" $adapters=@(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Disabled' })",
" if ($clicdTargetMac) {",
" $matched=$adapters | Where-Object { (($_.MacAddress -replace '[-:]','').ToUpperInvariant()) -eq $clicdTargetMac } | Sort-Object ifIndex | Select-Object -First 1",
" if ($matched) { return $matched }",
" }",
" $up=$adapters | Where-Object { $_.Status -eq 'Up' } | Sort-Object ifIndex | Select-Object -First 1",
" if ($up) { return $up }",
" return $adapters | Sort-Object ifIndex | Select-Object -First 1",
"}",
"function Wait-ClicdNetworkAdapter {",
" param([int]$Retries=90,[int]$DelaySeconds=4)",
" for ($i=0; $i -lt $Retries; $i++) {",
" $adapter=Get-ClicdNetworkAdapter",
" if ($adapter) { return $adapter }",
" Start-Sleep -Seconds $DelaySeconds",
" }",
" return $null",
"}",
}, "\r\n")
}
func windowsIPv6PowerShell(ipv6s []string, mac string) string {
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv6s))
for _, ipv6 := range ipv6s {
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
quoted = append(quoted, powerShellSingleQuote(ipv6))
}
return strings.Join([]string{
windowsAdapterDiscoveryPowerShell(mac),
"if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }",
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
"$iface=$null",
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
"if ($iface) {",
" foreach ($ip in $clicdIPv6) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
@@ -1806,12 +1944,54 @@ func windowsIPv6PowerShell(ipv6s []string) string {
}, "\r\n")
}
func windowsIPv4PowerShell(ipv4s []string, mac string) string {
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv4s))
for _, ipv4 := range ipv4s {
quoted = append(quoted, powerShellSingleQuote(ipv4))
}
return strings.Join([]string{
windowsAdapterDiscoveryPowerShell(mac),
"if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }",
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
"$clicdIPv4=@(" + strings.Join(quoted, ",") + ")",
"if ($iface) {",
" foreach ($ip in $clicdIPv4) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetIPAddress -IPAddress $ip -PrefixLength 32 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
" }",
"}",
}, "\r\n")
}
func normalizeKVMIPv4List(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func powerShellSingleQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, image Image) error {
guestSetup := kvmSSHSetupScript(password, publicKey)
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, ipv4s []string, image Image, sshAuthMode string) error {
disablePubkey := sshAuthMode == "password" || sshAuthMode == "auto_password"
guestSetup := kvmSSHSetupScript(password, disablePubkey, publicKey)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup
}
@@ -1846,29 +2026,40 @@ runcmd:
%s
`, hostname, password, authorizedKeys, setupScript)
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
ipv6Block := ""
// Build static address block (IPv4 + IPv6)
ipv4s = normalizeKVMIPv4List(ipv4s)
addressBlock := ""
addressLines := make([]string, 0, len(ipv4s)+len(ipv6s))
for _, ipv4 := range ipv4s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/32", ipv4))
}
for _, ipv6 := range ipv6s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6))
}
if len(addressLines) > 0 {
addressBlock = fmt.Sprintf("\n addresses:\n%s", strings.Join(addressLines, "\n"))
}
// IPv6 routes (only needed when IPv6 addresses are configured)
ipv6RouteBlock := ""
if len(ipv6s) > 0 {
addressLines := make([]string, 0, len(ipv6s))
for _, ipv6 := range ipv6s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6))
}
ipv6Block = fmt.Sprintf(`
addresses:
%s
ipv6RouteBlock = fmt.Sprintf(`
routes:
- to: default
via: %s
on-link: true
metric: 100`, strings.Join(addressLines, "\n"), ipv6GatewayLinkLocal)
metric: 100`, ipv6GatewayLinkLocal)
}
networkConfig := fmt.Sprintf(`version: 2
ethernets:
nic0:
match:
macaddress: "%s"
dhcp4: true
dhcp6: false%s
`, strings.ToLower(mac), ipv6Block)
dhcp6: false%s%s
`, strings.ToLower(mac), addressBlock, ipv6RouteBlock)
dir := filepath.Dir(seedPath)
userPath := filepath.Join(dir, "user-data")
metaPath := filepath.Join(dir, "meta-data")
@@ -1900,6 +2091,16 @@ func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string
return values
}
func configIPv4AssignmentAddresses(assignments []config.PublicIPv4Assignment) []string {
values := make([]string, 0, len(assignments))
for _, item := range assignments {
if strings.TrimSpace(item.Address) != "" {
values = append(values, strings.TrimSpace(item.Address))
}
}
return values
}
func normalizeKVMIPv6List(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
@@ -1944,7 +2145,7 @@ func isKVMDesktopTemplate(templateID string) bool {
return image != nil && image.Desktop != ""
}
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int, desktop bool) string {
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int, desktop bool) string {
if vcpu < 1 {
vcpu = 1
}
@@ -1952,21 +2153,32 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
ramMB = 512
}
iotune := ""
if ioSpeedMBps > 0 {
bytesPerSecond := int64(ioSpeedMBps) * 1024 * 1024
if ioReadMBps > 0 || ioWriteMBps > 0 {
var parts []string
if ioReadMBps > 0 {
parts = append(parts, fmt.Sprintf(" <read_bytes_sec>%d</read_bytes_sec>", int64(ioReadMBps)*1024*1024))
}
if ioWriteMBps > 0 {
parts = append(parts, fmt.Sprintf(" <write_bytes_sec>%d</write_bytes_sec>", int64(ioWriteMBps)*1024*1024))
}
iotune = fmt.Sprintf(`
<iotune>
<total_bytes_sec>%d</total_bytes_sec>
</iotune>`, bytesPerSecond)
%s
</iotune>`, strings.Join(parts, "\n"))
}
bandwidth := ""
if networkBWMbps > 0 {
averageKiB := networkBWMbps * 128
if networkDownMbps > 0 || networkUpMbps > 0 {
var parts []string
if networkDownMbps > 0 {
parts = append(parts, fmt.Sprintf(" <inbound average='%d'/>", networkDownMbps*128))
}
if networkUpMbps > 0 {
parts = append(parts, fmt.Sprintf(" <outbound average='%d'/>", networkUpMbps*128))
}
bandwidth = fmt.Sprintf(`
<bandwidth>
<inbound average='%d'/>
<outbound average='%d'/>
</bandwidth>`, averageKiB, averageKiB)
%s
</bandwidth>`, strings.Join(parts, "\n"))
}
video := "<video><model type='virtio'/></video>"
input := ""
@@ -2023,7 +2235,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
}
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string {
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string {
if vcpu < 1 {
vcpu = 1
}
@@ -2031,21 +2243,32 @@ func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, un
ramMB = 2048
}
iotune := ""
if ioSpeedMBps > 0 {
bytesPerSecond := int64(ioSpeedMBps) * 1024 * 1024
if ioReadMBps > 0 || ioWriteMBps > 0 {
var parts []string
if ioReadMBps > 0 {
parts = append(parts, fmt.Sprintf(" <read_bytes_sec>%d</read_bytes_sec>", int64(ioReadMBps)*1024*1024))
}
if ioWriteMBps > 0 {
parts = append(parts, fmt.Sprintf(" <write_bytes_sec>%d</write_bytes_sec>", int64(ioWriteMBps)*1024*1024))
}
iotune = fmt.Sprintf(`
<iotune>
<total_bytes_sec>%d</total_bytes_sec>
</iotune>`, bytesPerSecond)
%s
</iotune>`, strings.Join(parts, "\n"))
}
bandwidth := ""
if networkBWMbps > 0 {
averageKiB := networkBWMbps * 128
if networkDownMbps > 0 || networkUpMbps > 0 {
var parts []string
if networkDownMbps > 0 {
parts = append(parts, fmt.Sprintf(" <inbound average='%d'/>", networkDownMbps*128))
}
if networkUpMbps > 0 {
parts = append(parts, fmt.Sprintf(" <outbound average='%d'/>", networkUpMbps*128))
}
bandwidth = fmt.Sprintf(`
<bandwidth>
<inbound average='%d'/>
<outbound average='%d'/>
</bandwidth>`, averageKiB, averageKiB)
%s
</bandwidth>`, strings.Join(parts, "\n"))
}
virtioWinISO := virtioWinISOPath()
unattendDisk := ""
@@ -2186,7 +2409,11 @@ func normalizeKVMManagementPortMapping(c *config.Container) {
}
hostPort := c.SSHPort
if hostPort <= 0 {
hostPort = config.AllocateSSHPort()
allocated, err := config.AllocateSSHPort()
if err != nil {
return
}
hostPort = allocated
c.SSHPort = hostPort
}
desiredPort := 22
@@ -2356,6 +2583,9 @@ func (m *Manager) EnsureSSH(id int) error {
if mapErr := lxc.NewManager().ApplyPortMappings(id); mapErr != nil {
return mapErr
}
if err := lxc.ApplyFirewallRules(id); err != nil {
fmt.Printf("Warning: failed to apply firewall rules: %v\n", err)
}
return nil
}
if lastErr == nil {
@@ -2368,11 +2598,11 @@ func runKVMGuestAgentSSHSetup(name string, password string) error {
if err := qemuGuestPing(name); err != nil {
return err
}
return qemuGuestExec(name, kvmSSHSetupScript(password), 180*time.Second)
return qemuGuestExec(name, kvmSSHSetupScript(password, false), 180*time.Second)
}
func runKVMSSHSetup(client *ssh.Client, password string) error {
return runKVMSSHScript(client, kvmSSHSetupScript(password), "KVM SSH", 150*time.Second)
return runKVMSSHScript(client, kvmSSHSetupScript(password, false), "KVM SSH", 150*time.Second)
}
func runKVMSSHScript(client *ssh.Client, script string, description string, timeout time.Duration) error {
@@ -2401,12 +2631,16 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
}
}
func kvmSSHSetupScript(password string, publicKeys ...string) string {
func kvmSSHSetupScript(password string, disablePubkeyAuth bool, publicKeys ...string) string {
publicKey := ""
if len(publicKeys) > 0 {
publicKey = strings.TrimSpace(publicKeys[0])
}
return `set -u
pubkeyValue := "yes"
if disablePubkeyAuth {
pubkeyValue = "no"
}
script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
SSH_PUBLIC_KEY=` + shellQuote(publicKey) + `
export DEBIAN_FRONTEND=noninteractive
@@ -2435,7 +2669,7 @@ fi
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
PermitRootLogin yes
PubkeyAuthentication yes
PubkeyAuthentication __CLICD_PUBKEY_AUTH__
PasswordAuthentication yes
KbdInteractiveAuthentication yes
ChallengeResponseAuthentication yes
@@ -2443,8 +2677,8 @@ EOF
if [ -f /etc/ssh/sshd_config ]; then
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication __CLICD_PUBKEY_AUTH__\n' >> /etc/ssh/sshd_config
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || true
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
@@ -2459,7 +2693,10 @@ if [ -n "$SSH_PUBLIC_KEY" ]; then
chown -R root:root /root/.ssh 2>/dev/null || true
fi
if command -v chpasswd >/dev/null 2>&1; then
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd 2>/tmp/clicd-chpasswd.log && echo "root password set via chpasswd" || echo "WARNING: chpasswd failed: $(cat /tmp/clicd-chpasswd.log 2>/dev/null)"
elif command -v openssl >/dev/null 2>&1 && command -v usermod >/dev/null 2>&1; then
HASH=$(echo "$ROOT_PASSWORD" | openssl passwd -6 -stdin 2>/dev/null)
[ -n "$HASH" ] && usermod -p "$HASH" root 2>/dev/null && echo "root password set via openssl/usermod" || echo "WARNING: openssl/usermod failed"
fi
ssh-keygen -A >/dev/null 2>&1 || true
if command -v systemctl >/dev/null 2>&1; then
@@ -2485,6 +2722,8 @@ if [ -w /dev/tty1 ]; then
printf '\nCLICD VNC console is ready. Press Enter for login prompt.\n' >/dev/tty1 || true
fi
`
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
return script
}
func kvmDesktopSetupScript(image Image) string {
@@ -3084,6 +3323,11 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
}
ensureKVMIPv6NAT66(assignment.Address, uplink)
}
if c.Status == "running" {
if err := lxc.ApplyFirewallRules(c.ID); err != nil {
fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 setup for %s: %v\n", c.Name, err)
}
}
return nil
}
@@ -3160,7 +3404,7 @@ func ensureKVMIPv6ForwardRules(ipv6 string, bridge string) {
}
for _, rule := range rules {
check := append([]string{"-C"}, rule...)
add := append([]string{"-I"}, append([]string{rule[0], "1"}, rule[1:]...)...)
add := append([]string{"-A"}, rule...)
if exec.Command("ip6tables", check...).Run() != nil {
exec.Command("ip6tables", add...).Run()
}
@@ -3303,13 +3547,14 @@ func (m *Manager) applyGuestIPv6(c *config.Container) error {
}
func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
if err := qemuGuestPing(c.VirshName()); err != nil {
return err
}
script := windowsIPv6PowerShell(c.IPv6AddressStrings())
c.NormalizeNetworkAssignments()
script := windowsIPv6PowerShell(c.IPv6AddressStrings(), c.MACAddress)
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
}
@@ -3670,7 +3915,8 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
}
}
ports := make([]int, 0, count)
for next := 20000; next <= 65535 && len(ports) < count; next++ {
start, end := config.NATPortRange()
for next := start; next <= end && len(ports) < count; next++ {
if !used[next] {
ports = append(ports, next)
}
+6
View File
@@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status {
}
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 {
return configured
}
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
}
@@ -1610,6 +1613,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
}
+220 -52
View File
@@ -226,11 +226,15 @@ type ContainerConfig struct {
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
@@ -247,12 +251,48 @@ type ContainerConfig struct {
ExpiresAt string `json:"expires_at"`
}
func (cfg *ContainerConfig) NormalizeResourceAliases() {
if cfg == nil {
return
}
if cfg.NetworkBWMbps < 0 {
cfg.NetworkBWMbps = 0
}
if cfg.NetworkDownMbps < 0 {
cfg.NetworkDownMbps = 0
}
if cfg.NetworkUpMbps < 0 {
cfg.NetworkUpMbps = 0
}
if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 {
cfg.NetworkDownMbps = cfg.NetworkBWMbps
cfg.NetworkUpMbps = cfg.NetworkBWMbps
}
cfg.NetworkBWMbps = config.LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps)
if cfg.IOSpeedMBps < 0 {
cfg.IOSpeedMBps = 0
}
if cfg.IOReadMBps < 0 {
cfg.IOReadMBps = 0
}
if cfg.IOWriteMBps < 0 {
cfg.IOWriteMBps = 0
}
if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 {
cfg.IOReadMBps = cfg.IOSpeedMBps
cfg.IOWriteMBps = cfg.IOSpeedMBps
}
cfg.IOSpeedMBps = config.LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps)
}
func (cfg ContainerConfig) WantsNAT() bool {
return cfg.AssignNAT == nil || *cfg.AssignNAT
}
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
tmpl := FindTemplate(cfg.TemplateID)
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
@@ -341,16 +381,15 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
sshPort := 0
portMappings := []config.PortMapping{}
if cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
sshPort, err = config.AllocateSSHPort()
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
// Setup default port mappings (SSH only)
portMappings = SetupDefaultPortMappings(sshPort)
defaultHostIP := defaultPortMappingHostIP(publicIPv4s)
if defaultHostIP != "" {
for i := range portMappings {
portMappings[i].HostIP = defaultHostIP
}
}
// NAT4 port mappings should bind to the host IP, not the container's independent public IPv4.
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
extraPorts := cfg.ExtraPorts
@@ -364,7 +403,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
ContainerPort: containerPort,
HostPort: containerPort,
HostIP: defaultHostIP,
HostIP: "",
Protocol: "tcp",
Description: fmt.Sprintf("Port-%d", containerPort),
})
@@ -394,12 +433,16 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
RAMMB: cfg.RAMMB,
DiskGB: cfg.DiskGB,
NetworkBWMbps: cfg.NetworkBWMbps,
NetworkDownMbps: cfg.NetworkDownMbps,
NetworkUpMbps: cfg.NetworkUpMbps,
MonthlyTrafficGB: cfg.MonthlyTrafficGB,
TrafficMode: trafficMode,
TrafficInGB: cfg.TrafficInGB,
TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: trafficResetDate,
IOSpeedMBps: cfg.IOSpeedMBps,
IOReadMBps: cfg.IOReadMBps,
IOWriteMBps: cfg.IOWriteMBps,
Status: "stopped",
IP: "",
PublicIPv4s: publicIPv4s,
@@ -424,7 +467,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
}
if sshAccess.PublicKey != "" {
@@ -512,11 +555,13 @@ IPv6AcceptRA=no
}
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
_ = templateID
// Disable pubkey auth when user chose password-only mode (password or auto_password)
disablePubkey := sshAuthMode == SSHAuthPassword || sshAuthMode == SSHAuthAutoPassword
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false, disablePubkey))
if err != nil {
return err
}
@@ -534,6 +579,7 @@ func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
// applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config.
func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
configFile := filepath.Join(m.LxcPath, lxcName, "config")
data, err := os.ReadFile(configFile)
@@ -602,12 +648,12 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
ramBytes := int64(cfg.RAMMB) * 1024 * 1024
newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.memory.max = %d", ramBytes))
}
if cfg.IOSpeedMBps > 0 {
if cfg.IOReadMBps > 0 || cfg.IOWriteMBps > 0 {
// Note: lxc.cgroup2.io.max is skipped for unprivileged containers because
// LXC's cgfsng_setup_limits cannot resolve host device numbers (e.g. 8:1)
// in the unprivileged namespace context.
// IO limits are instead applied post-start via direct cgroup2 writes.
fmt.Printf("Info: IO limit (%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOSpeedMBps, lxcName)
fmt.Printf("Info: IO limit (read=%d MB/s write=%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOReadMBps, cfg.IOWriteMBps, lxcName)
}
newContent := strings.Join(newLines, "\n")
@@ -617,18 +663,28 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
return nil
}
func (m *Manager) ioLimitLines(lxcName string, mbps int) ([]string, error) {
if mbps <= 0 {
return nil, nil
func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) {
if readMBps < 0 {
readMBps = 0
}
if writeMBps < 0 {
writeMBps = 0
}
devices, err := m.rootfsBlockDevices(lxcName)
if err != nil {
return nil, err
}
ioBytes := mbps * 1024 * 1024
readValue := "max"
if readMBps > 0 {
readValue = strconv.Itoa(readMBps * 1024 * 1024)
}
writeValue := "max"
if writeMBps > 0 {
writeValue = strconv.Itoa(writeMBps * 1024 * 1024)
}
lines := make([]string, 0, len(devices))
for _, device := range devices {
lines = append(lines, fmt.Sprintf("%s rbps=%d wbps=%d", device, ioBytes, ioBytes))
lines = append(lines, fmt.Sprintf("%s rbps=%s wbps=%s", device, readValue, writeValue))
}
return lines, nil
}
@@ -961,12 +1017,99 @@ func findSeccompProfile() (string, error) {
"/etc/lxc/common.seccomp",
} {
if _, err := os.Stat(path); err == nil {
return path, nil
return ensureCVE202643499SeccompProfile(path)
}
}
return "", errors.New("required LXC seccomp profile not found")
}
const clicdSeccompProfileDir = "/var/lib/clicd/security/seccomp"
const clicdCVE202643499SeccompProfile = clicdSeccompProfileDir + "/lxc-cve-2026-43499.profile"
var cve202643499FutexSeccompRules = []string{
"# clicd managed: mitigate CVE-2026-43499 from LXC guests by blocking PI futex operations",
"futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0x7,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0x8,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]",
}
func ensureCVE202643499SeccompProfile(basePath string) (string, error) {
data, err := os.ReadFile(basePath)
if err != nil {
return "", fmt.Errorf("failed to read LXC seccomp profile: %v", err)
}
content := string(data)
if !isLXCVDenylistSeccompProfile(content) {
return "", fmt.Errorf("LXC seccomp profile %s is not a v2 denylist profile; cannot apply CVE-2026-43499 futex mitigation safely", basePath)
}
if err := os.MkdirAll(clicdSeccompProfileDir, 0755); err != nil {
return "", fmt.Errorf("failed to create CLICD seccomp directory: %v", err)
}
hardened := appendMissingSeccompRules(content, cve202643499FutexSeccompRules)
if err := os.WriteFile(clicdCVE202643499SeccompProfile, []byte(hardened), 0644); err != nil {
return "", fmt.Errorf("failed to write CLICD seccomp profile: %v", err)
}
return clicdCVE202643499SeccompProfile, nil
}
func isLXCVDenylistSeccompProfile(content string) bool {
lines := nonCommentSeccompLines(content)
return len(lines) >= 2 && lines[0] == "2" && isSeccompDenylistPolicy(lines[1])
}
func isSeccompDenylistPolicy(line string) bool {
fields := strings.Fields(line)
if len(fields) == 0 {
return false
}
return fields[0] == "denylist" || fields[0] == "blacklist"
}
func appendMissingSeccompRules(content string, rules []string) string {
trimmed := strings.TrimRight(content, "\r\n")
existing := map[string]bool{}
for _, line := range strings.Split(trimmed, "\n") {
line = strings.TrimSpace(stripSeccompLineComment(line))
if line != "" {
existing[line] = true
}
}
var builder strings.Builder
builder.WriteString(trimmed)
for _, rule := range rules {
key := strings.TrimSpace(stripSeccompLineComment(rule))
if key != "" && existing[key] {
continue
}
builder.WriteString("\n")
builder.WriteString(rule)
}
builder.WriteString("\n")
return builder.String()
}
func nonCommentSeccompLines(content string) []string {
lines := make([]string, 0)
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(stripSeccompLineComment(line))
if line == "" {
continue
}
lines = append(lines, line)
}
return lines
}
func stripSeccompLineComment(line string) string {
if idx := strings.Index(line, "#"); idx >= 0 {
return line[:idx]
}
return line
}
func findAppArmorProfile() (string, error) {
data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles")
if err != nil {
@@ -1256,8 +1399,12 @@ func (m *Manager) StartContainer(id int) error {
RAMMB: c.RAMMB,
DiskGB: c.DiskGB,
NetworkBWMbps: c.NetworkBWMbps,
NetworkDownMbps: c.NetworkDownMbps,
NetworkUpMbps: c.NetworkUpMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps,
IOReadMBps: c.IOReadMBps,
IOWriteMBps: c.IOWriteMBps,
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt,
}); err != nil {
@@ -1327,6 +1474,9 @@ func (m *Manager) StartContainer(id int) error {
if err := m.ApplyPortMappings(id); err != nil {
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
}
if err := ApplyFirewallRules(id); err != nil {
fmt.Printf("Warning: failed to apply firewall rules: %v\n", err)
}
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
@@ -1378,6 +1528,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
if c == nil || c.Status != "running" {
return nil
}
config.NormalizeContainerResourceAliases(c)
lxcName := c.LxcName()
// CPU: write cpu.max
@@ -1400,48 +1551,52 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
os.WriteFile(path, []byte(memLine), 0644)
}
// IO speed: write io.max
if c.IOSpeedMBps > 0 {
ioLines, err := m.ioLimitLines(lxcName, c.IOSpeedMBps)
if err != nil {
return err
}
ioLine := strings.Join(ioLines, "\n")
for _, path := range []string{
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName),
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName),
} {
os.WriteFile(path, []byte(ioLine), 0644)
}
// IO speed: write io.max, including max values to clear old per-direction limits.
ioLines, err := m.ioLimitLines(lxcName, c.IOReadMBps, c.IOWriteMBps)
if err != nil {
return err
}
ioLine := strings.Join(ioLines, "\n")
for _, path := range []string{
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName),
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName),
} {
os.WriteFile(path, []byte(ioLine), 0644)
}
// Network bandwidth
if c.NetworkBWMbps > 0 {
m.applyBandwidthLimit(lxcName, c.NetworkBWMbps)
} else {
m.cleanupBandwidthLimit(lxcName)
}
m.applyBandwidthLimit(lxcName, c.NetworkDownMbps, c.NetworkUpMbps)
return nil
}
func (m *Manager) applyBandwidthLimit(lxcName string, mbps int) {
func (m *Manager) applyBandwidthLimit(lxcName string, downMbps int, upMbps int) {
veth := m.getContainerVethByNS(lxcName)
if veth == "" {
fmt.Printf("Warning: could not find veth for %s\n", lxcName)
return
}
rate := fmt.Sprintf("%dmbit", mbps)
burst := fmt.Sprintf("%dkbit", mbps*100)
exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run()
exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run()
exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run()
fmt.Printf("Bandwidth limit: %s = %d Mbps on %s\n", lxcName, mbps, veth)
exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run()
if downMbps > 0 {
rate := fmt.Sprintf("%dmbit", downMbps)
burst := fmt.Sprintf("%dkbit", downMbps*100)
exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run()
exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run()
}
if upMbps > 0 {
rate := fmt.Sprintf("%dmbit", upMbps)
burst := fmt.Sprintf("%dkbit", upMbps*100)
exec.Command("tc", "qdisc", "add", "dev", veth, "handle", "ffff:", "ingress").Run()
exec.Command("tc", "filter", "add", "dev", veth, "parent", "ffff:", "protocol", "all", "u32", "match", "u32", "0", "0", "police", "rate", rate, "burst", burst, "drop", "flowid", ":1").Run()
}
fmt.Printf("Bandwidth limit: %s down=%d Mbps up=%d Mbps on %s\n", lxcName, downMbps, upMbps, veth)
}
func (m *Manager) cleanupBandwidthLimit(lxcName string) {
veth := m.getContainerVethByNS(lxcName)
if veth != "" {
exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run()
exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run()
}
}
@@ -1475,11 +1630,13 @@ func (m *Manager) StopContainer(id int) error {
if status != "running" {
config.UpdateContainerStatus(id, "stopped")
m.CleanPortMappings(id)
CleanFirewallRules(id)
m.cleanupBandwidthLimit(lxcName)
return nil
}
m.CleanPortMappings(id)
CleanFirewallRules(id)
m.cleanupBandwidthLimit(lxcName)
cmd := exec.Command("lxc-stop", "-n", lxcName)
@@ -1758,7 +1915,7 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig()
}
script := sshSetupScript(true)
script := sshSetupScript(true, false) // keep pubkey enabled for runtime ensure
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
@@ -1826,7 +1983,11 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
}
func sshSetupScript(startService bool) string {
func sshSetupScript(startService bool, disablePubkeyAuth bool) string {
pubkeyValue := "yes"
if disablePubkeyAuth {
pubkeyValue = "no"
}
script := `set -u
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
@@ -1945,7 +2106,7 @@ ssh-keygen -A >/dev/null 2>&1 || true
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
PermitRootLogin yes
PubkeyAuthentication yes
PubkeyAuthentication __CLICD_PUBKEY_AUTH__
PasswordAuthentication yes
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
@@ -1953,7 +2114,7 @@ UsePAM no
EOF
set_sshd_option PermitRootLogin yes
set_sshd_option PubkeyAuthentication yes
set_sshd_option PubkeyAuthentication __CLICD_PUBKEY_AUTH__
set_sshd_option PasswordAuthentication yes
set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no
@@ -1981,6 +2142,7 @@ ensure_sshd_runtime_dir
exit 32
}
`
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
if !startService {
return script
}
@@ -2054,7 +2216,7 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
return "", err
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
if err := m.preconfigureSSH(rootfsPath, c.Template, ""); err != nil {
return "", fmt.Errorf("failed to configure SSH: %v", err)
}
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
@@ -2440,6 +2602,8 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
RAMMB: 512,
DiskGB: 10,
NetworkBWMbps: 100,
NetworkDownMbps: 100,
NetworkUpMbps: 100,
MonthlyTrafficGB: 1000,
TrafficMode: "total",
Status: status,
@@ -2583,6 +2747,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
// Clean port mappings temporarily
m.CleanPortMappings(id)
CleanFirewallRules(id)
// Download the new OS into a temporary container, then replace only the
// existing rootfs. The target container directory and config are preserved.
@@ -2602,8 +2767,12 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
RAMMB: c.RAMMB,
DiskGB: c.DiskGB,
NetworkBWMbps: c.NetworkBWMbps,
NetworkDownMbps: c.NetworkDownMbps,
NetworkUpMbps: c.NetworkUpMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps,
IOReadMBps: c.IOReadMBps,
IOWriteMBps: c.IOWriteMBps,
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt,
}
@@ -2626,7 +2795,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
}
}
c.SSHPassword = sshAccess.Password
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
}
if sshAccess.PublicKey != "" {
@@ -2686,9 +2855,8 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
}
}
// Apply bandwidth limit after reinstall
if c.NetworkBWMbps > 0 {
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
}
config.NormalizeContainerResourceAliases(c)
m.applyBandwidthLimit(c.LxcName(), c.NetworkDownMbps, c.NetworkUpMbps)
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
+45
View File
@@ -88,3 +88,48 @@ func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
}
}
func TestIsLXCVDenylistSeccompProfile(t *testing.T) {
tests := []string{`
# base profile
2
denylist
[all]
open_by_handle_at errno 1
`, `
2
blacklist allow
[all]
open_by_handle_at errno 1
`}
for _, profile := range tests {
if !isLXCVDenylistSeccompProfile(profile) {
t.Fatalf("expected v2 denylist profile for\n%s", profile)
}
}
if isLXCVDenylistSeccompProfile("1\nallowlist\n1\n") {
t.Fatal("did not expect v1 allowlist profile")
}
}
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
once := appendMissingSeccompRules(base, cve202643499FutexSeccompRules)
twice := appendMissingSeccompRules(once, cve202643499FutexSeccompRules)
for _, want := range []string{
"futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]",
"futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]",
} {
if !strings.Contains(once, want) {
t.Fatalf("missing seccomp rule %q in\n%s", want, once)
}
if strings.Count(twice, want) != 1 {
t.Fatalf("rule %q duplicated in\n%s", want, twice)
}
}
}
+422 -8
View File
@@ -59,11 +59,54 @@ func (m *Manager) ApplyPortMappings(id int) error {
}
}
// When container has public IPv4, apply full port passthrough DNAT so the
// container owns all ports on its public IP (no NAT management needed).
if len(c.PublicIPv4s) > 0 {
ensureIndependentIPv4Ingress(c, tag)
}
applyIPv4EgressPolicy(c, bridge, subnet, tag)
if err := ApplyFirewallRules(id); err != nil {
return err
}
return nil
}
func ensureIndependentIPv4Ingress(c *config.Container, tag string) {
if c == nil || c.IP == "" || len(c.PublicIPv4s) == 0 {
return
}
for _, assignment := range c.PublicIPv4s {
hostIP := strings.TrimSpace(assignment.Address)
if hostIP == "" {
continue
}
// Full port passthrough: DNAT all TCP+UDP traffic on this public IP to the container.
for _, proto := range []string{"tcp", "udp"} {
args := []string{
"-t", "nat",
"-I", "PREROUTING", "1",
"-d", hostIP,
"-p", proto,
"-j", "DNAT",
"--to-destination", c.IP,
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-all-%s", tag, natRuleIPTag(hostIP), proto),
}
cmd := exec.Command("iptables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply %s passthrough %s->%s: %v, output: %s\n",
proto, hostIP, c.IP, err, string(output))
continue
}
fmt.Printf("IPv4 passthrough (%s): %s -> %s (all ports)\n", proto, hostIP, c.IP)
}
}
}
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
if c == nil || strings.TrimSpace(c.IP) == "" {
return
@@ -221,8 +264,8 @@ func EnsureForwardRules(bridge string) {
break
}
}
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
exec.Command("iptables", insertArgs...).Run()
appendArgs := append([]string{"-A", "FORWARD"}, args...)
exec.Command("iptables", appendArgs...).Run()
}
}
@@ -352,6 +395,10 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
if pm.HostPort <= 0 {
pm.HostPort = pm.ContainerPort
}
if pm.HostIP == "" && !config.NATPortInRange(pm.HostPort) {
start, end := config.NATPortRange()
return pm, fmt.Errorf("host port must be within configured NAT4 range %d-%d", start, end)
}
// Check current container's own mappings
for i, existing := range c.PortMappings {
if i == skipIndex {
@@ -401,16 +448,12 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
}
}
ports := make([]int, 0, count)
next := 20000
for len(ports) < count {
start, end := config.NATPortRange()
for next := start; next <= end && len(ports) < count; next++ {
hostIP := c.PrimaryPublicIPv4()
if !used[hostPortKey(hostIP, next)] && !used[next] {
ports = append(ports, next)
}
next++
if next > 65535 || len(ports) >= count {
break
}
}
return ports
}
@@ -529,3 +572,374 @@ func hostPortKey(hostIP string, port int) int {
}
return port + (sum % 1000000 * 100000)
}
// CleanFirewallRules removes all firewall rules for a container from the FORWARD chain.
func CleanFirewallRules(id int) {
tag := clicdTag(id)
// Remove all rules with the firewall tag prefix
cmd := exec.Command("bash", "-c",
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
cmd.CombinedOutput()
cmd = exec.Command("bash", "-c",
fmt.Sprintf("ip6tables -S FORWARD 2>/dev/null | grep 'clicd-%s-fw-' | sed 's/^-A /-D /' | while read rule; do ip6tables $rule; done", tag))
cmd.CombinedOutput()
// Also remove legacy default policy rules (without specific rule ID)
for _, suffix := range []string{"default-in", "default-out"} {
for _, proto := range []string{"tcp", "udp"} {
exec.Command("iptables", "-D", "FORWARD",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s-%s", tag, suffix, proto),
).CombinedOutput()
}
exec.Command("ip6tables", "-D", "FORWARD",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-%s", tag, suffix),
).CombinedOutput()
}
}
// ApplyFirewallRules applies iptables FORWARD rules for a container's firewall configuration.
func ApplyFirewallRules(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
// Always clean existing firewall rules first
CleanFirewallRules(id)
// If firewall is disabled or no rules, nothing to apply
if !c.FirewallEnabled {
return nil
}
bridge := "lxcbr0"
if c.IsKVM() {
bridge = "virbr0"
}
containerIP := strings.TrimSpace(c.IP)
containerIPv6s := firewallIPv6Addresses(c)
if containerIP == "" && len(containerIPv6s) == 0 {
return nil
}
tag := clicdTag(id)
defaultAction := normalizeFirewallDefaultAction(c.FirewallDefaultAction)
if defaultAction == "DROP" {
if containerIP != "" {
if err := applyDefaultFirewallPolicy(tag, bridge, containerIP); err != nil {
return err
}
}
if err := applyDefaultFirewallIPv6Policy(tag, bridge, containerIPv6s); err != nil {
return err
}
}
for i := len(c.FirewallRules) - 1; i >= 0; i-- {
rule := c.FirewallRules[i]
if !rule.Enabled {
continue
}
if containerIP != "" && firewallRuleAppliesToFamily(rule, true) {
if err := applyOneFirewallRule(tag, bridge, containerIP, rule); err != nil {
return fmt.Errorf("failed to apply firewall rule %s for container %d: %w", rule.ID, id, err)
}
}
if len(containerIPv6s) > 0 && firewallRuleAppliesToFamily(rule, false) {
if err := applyOneFirewallIPv6Rule(tag, bridge, containerIPv6s, rule); err != nil {
return fmt.Errorf("failed to apply IPv6 firewall rule %s for container %d: %w", rule.ID, id, err)
}
}
}
return nil
}
func normalizeFirewallDefaultAction(action string) string {
action = strings.ToUpper(strings.TrimSpace(action))
if action == "ACCEPT" {
return "ACCEPT"
}
return "DROP"
}
func normalizeFirewallNetwork(network string) string {
network = strings.ToLower(strings.TrimSpace(network))
switch network {
case "", "ipv4", "nat4":
return "ipv4"
case "ipv6":
return "ipv6"
case "all", "both":
return "all"
default:
return "ipv4"
}
}
func firewallRuleAppliesToFamily(rule config.FirewallRule, ipv4 bool) bool {
network := normalizeFirewallNetwork(rule.Network)
if network == "ipv4" {
return ipv4
}
if network == "ipv6" {
return !ipv4
}
if rule.SourceIP == "" {
return true
}
addr := firewallIPSpecAddr(rule.SourceIP)
if !addr.IsValid() {
return true
}
if ipv4 {
return addr.Is4()
}
return addr.Is6() && !addr.Is4In6()
}
func firewallIPSpecAddr(value string) netip.Addr {
value = strings.TrimSpace(value)
if value == "" {
return netip.Addr{}
}
if strings.Contains(value, "/") {
prefix, err := netip.ParsePrefix(value)
if err != nil {
return netip.Addr{}
}
return prefix.Addr()
}
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}
}
return addr
}
func firewallIPv6Addresses(c *config.Container) []string {
if c == nil {
return nil
}
c.NormalizeNetworkAssignments()
seen := map[string]bool{}
result := []string{}
for _, assignment := range c.IPv6Addresses {
ip := strings.TrimSpace(assignment.Address)
if ip == "" || seen[ip] {
continue
}
if addr, err := netip.ParseAddr(ip); err == nil && addr.Is6() && !addr.Is4In6() {
seen[ip] = true
result = append(result, ip)
}
}
return result
}
func applyOneFirewallRule(tag, bridge, containerIP string, rule config.FirewallRule) error {
commentTag := fmt.Sprintf("clicd-%s-fw-%s", tag, rule.ID)
// Build base iptables args
args := []string{"-I", "FORWARD", "1"}
// Direction: in = traffic arriving at container (-o bridge -d containerIP)
// out = traffic leaving container (-i bridge -s containerIP)
switch rule.Direction {
case "in":
args = append(args, "-o", bridge, "-d", containerIP+"/32")
case "out":
args = append(args, "-i", bridge, "-s", containerIP+"/32")
default:
return fmt.Errorf("invalid direction: %s", rule.Direction)
}
// Protocol
switch rule.Protocol {
case "tcp", "udp":
args = append(args, "-p", rule.Protocol)
case "icmp":
args = append(args, "-p", "icmp")
case "all":
// no protocol filter
default:
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
}
// Port matching (only for tcp/udp)
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
// For "in" direction, traffic going TO the container uses --dport
// For "out" direction, traffic going FROM the container uses --dport (destination port on remote)
args = append(args, firewallPortArgs(rule.Port)...)
}
// Source IP filter (for "out" direction, this matches the remote source; for "in", it matches the sender)
if rule.SourceIP != "" {
switch rule.Direction {
case "in":
args = append(args, "-s", rule.SourceIP)
case "out":
args = append(args, "-d", rule.SourceIP)
}
}
// Action
action := "DROP"
if rule.Action == "ACCEPT" {
action = "ACCEPT"
}
args = append(args, "-j", action)
// Comment tag for cleanup
args = append(args, "-m", "comment", "--comment", commentTag)
cmd := exec.Command("iptables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("iptables error: %s", string(output))
}
return nil
}
func applyOneFirewallIPv6Rule(tag, bridge string, containerIPs []string, rule config.FirewallRule) error {
for _, containerIP := range containerIPs {
commentTag := fmt.Sprintf("clicd-%s-fw-%s-v6-%s", tag, rule.ID, firewallCommentIPTag(containerIP))
args := []string{"-I", "FORWARD", "1"}
switch rule.Direction {
case "in":
args = append(args, "-o", bridge, "-d", containerIP+"/128")
case "out":
args = append(args, "-i", bridge, "-s", containerIP+"/128")
default:
return fmt.Errorf("invalid direction: %s", rule.Direction)
}
switch rule.Protocol {
case "tcp", "udp":
args = append(args, "-p", rule.Protocol)
case "icmp":
args = append(args, "-p", "ipv6-icmp")
case "all":
default:
return fmt.Errorf("invalid protocol: %s", rule.Protocol)
}
if rule.Port != "" && (rule.Protocol == "tcp" || rule.Protocol == "udp") {
args = append(args, firewallPortArgs(rule.Port)...)
}
if rule.SourceIP != "" {
switch rule.Direction {
case "in":
args = append(args, "-s", rule.SourceIP)
case "out":
args = append(args, "-d", rule.SourceIP)
}
}
action := "DROP"
if rule.Action == "ACCEPT" {
action = "ACCEPT"
}
args = append(args, "-j", action)
args = append(args, "-m", "comment", "--comment", commentTag)
cmd := exec.Command("ip6tables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ip6tables error: %s", string(output))
}
}
return nil
}
func firewallPortArgs(port string) []string {
spec := normalizePortSpec(port)
if strings.Contains(spec, ",") {
return []string{"-m", "multiport", "--dports", spec}
}
return []string{"--dport", spec}
}
// normalizePortSpec converts user port input to iptables-compatible port spec.
// "80,443" -> "80,443", "8000-9000" -> "8000:9000", "80,443,8000-9000" -> "80,443,8000:9000"
func normalizePortSpec(port string) string {
port = strings.TrimSpace(port)
if port == "" {
return ""
}
parts := strings.Split(port, ",")
for i, part := range parts {
part = strings.TrimSpace(part)
if strings.Contains(part, "-") && !strings.Contains(part, ":") {
bounds := strings.SplitN(part, "-", 2)
if len(bounds) == 2 {
part = strings.TrimSpace(bounds[0]) + ":" + strings.TrimSpace(bounds[1])
}
}
parts[i] = part
}
return strings.Join(parts, ",")
}
func applyDefaultFirewallPolicy(tag, bridge, containerIP string) error {
defaults := [][]string{
{
"-I", "FORWARD", "1",
"-o", bridge,
"-d", containerIP + "/32",
"-j", "DROP",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in", tag),
},
{
"-I", "FORWARD", "1",
"-i", bridge,
"-s", containerIP + "/32",
"-j", "DROP",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out", tag),
},
}
for _, args := range defaults {
cmd := exec.Command("iptables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("iptables default firewall error: %s", string(output))
}
}
return nil
}
func applyDefaultFirewallIPv6Policy(tag, bridge string, containerIPs []string) error {
for _, containerIP := range containerIPs {
defaults := [][]string{
{
"-I", "FORWARD", "1",
"-o", bridge,
"-d", containerIP + "/128",
"-j", "DROP",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-in-v6-%s", tag, firewallCommentIPTag(containerIP)),
},
{
"-I", "FORWARD", "1",
"-i", bridge,
"-s", containerIP + "/128",
"-j", "DROP",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-fw-default-out-v6-%s", tag, firewallCommentIPTag(containerIP)),
},
}
for _, args := range defaults {
cmd := exec.Command("ip6tables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ip6tables default firewall error: %s", string(output))
}
}
}
return nil
}
func firewallCommentIPTag(ip string) string {
replacer := strings.NewReplacer(":", "_", ".", "_", "/", "_")
return replacer.Replace(ip)
}
+1
View File
@@ -0,0 +1 @@

+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.13"
Version = "1.1.21"
Repo = "MengMengCode/CLICD"
)
+7
View File
@@ -110,6 +110,13 @@ export default defineConfig({
head: [
['link', { rel: 'icon', href: '/favicon.svg' }],
],
vite: {
esbuild: {
supported: {
destructuring: true,
},
},
},
locales: {
root: {
label: '简体中文',
+194 -10
View File
@@ -53,7 +53,11 @@ Create container example:
"ssh_auth_mode": "auto_password",
"ssh_password": "",
"ssh_public_key": "",
"expires_at": ""
"expires_at": "",
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
@@ -71,6 +75,12 @@ Field notes:
| `ssh_auth_mode` | Linux creation supports `auto_password`, `password`, and `key`; reinstall also supports `keep`. |
| `ssh_password` | Custom password for `password` mode. It must be 8-64 characters, include letters and digits, and contain no whitespace. |
| `ssh_public_key` | One-line SSH public key for `key` mode. |
| `network_down_mbps` | Optional container download/downlink bandwidth limit in Mbps. `0` means unlimited. |
| `network_up_mbps` | Optional container upload/uplink bandwidth limit in Mbps. `0` means unlimited. |
| `io_read_mbps` | Optional disk read limit in MB/s. `0` means unlimited. |
| `io_write_mbps` | Optional disk write limit in MB/s. `0` means unlimited. |
| `network_bw_mbps` | Legacy-compatible field. Sets symmetric downlink/uplink bandwidth; new integrations should prefer the split fields. |
| `io_speed_mbps` | Legacy-compatible field. Sets symmetric read/write I/O limits; new integrations should prefer the split fields. |
Reinstall example:
@@ -85,6 +95,102 @@ Reinstall example:
`keep` is only for reinstall and keeps the current SSH password. Windows KVM images ignore Linux SSH public key fields.
## Resource and Traffic Limits
`PUT /api/v1/containers/{id}/resource-limit` supports partial updates. Fields omitted from the request remain unchanged.
```json
{
"vcpu": 2,
"ram_mb": 1024,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
Legacy `network_bw_mbps` and `io_speed_mbps` are still accepted. They mean symmetric downlink/uplink bandwidth and symmetric read/write I/O limits. New integrations should use the split fields to control download/upload and read/write independently.
`PUT /api/v1/containers/{id}/traffic-limit` request body:
```json
{
"traffic_mode": "total",
"monthly_traffic_gb": 1024,
"traffic_in_gb": 0,
"traffic_out_gb": 0
}
```
| Field | Description |
| --- | --- |
| `traffic_mode` | Traffic limit mode. Common values are `total` for a shared total limit and `split` for separate inbound/outbound limits. |
| `monthly_traffic_gb` | Monthly total traffic quota for `total` mode, in GB. `0` means unlimited. |
| `traffic_in_gb` | Monthly inbound quota for `split` mode, in GB. `0` means unlimited. |
| `traffic_out_gb` | Monthly outbound quota for `split` mode, in GB. `0` means unlimited. |
## Container Firewall
Read container firewall settings with `GET /api/v1/containers/{id}/firewall` and update them with `PUT /api/v1/containers/{id}/firewall`. Updates are applied immediately when the container is running.
Update example:
```json
{
"enabled": true,
"default_action": "DROP",
"rules": [
{
"direction": "in",
"protocol": "tcp",
"action": "ACCEPT",
"network": "ipv4",
"source_ip": "203.0.113.0/24",
"port": "22,80,443",
"description": "allow admin and web"
}
]
}
```
| Field | Description |
| --- | --- |
| `enabled` | Whether the container firewall is enabled. |
| `default_action` | Default action: `ACCEPT` or `DROP`. |
| `rules[].id` | Optional. Omit for new rules and the backend will generate one. |
| `rules[].direction` | Direction: `in` or `out`. |
| `rules[].protocol` | Protocol: `tcp`, `udp`, `icmp`, or `all`. |
| `rules[].action` | Action: `ACCEPT` or `DROP`. |
| `rules[].network` | Network type: `ipv4`, `ipv6`, or `all`. |
| `rules[].source_ip` | Optional source IP, CIDR, or address range. |
| `rules[].port` | Optional. Supported only for `tcp`/`udp`; examples: `22`, `80,443`, or `8000-9000`. |
| `rules[].description` | Optional note. |
## API Key Create and Update
`POST /api/v1/api-keys` and `PATCH /api/v1/api-keys/{id}` use the same field shape. `name` is required when creating a key; updates overwrite the fields you send.
```json
{
"name": "Automation",
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
"scopes": ["dashboard:read", "container:read", "container:power"],
"expires_at": "2026-12-31 23:59:59",
"disabled": false,
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
}
```
| Field | Description |
| --- | --- |
| `name` | API key name. Required when creating a key. |
| `ip_whitelist` | Optional allowed source IPs/CIDRs, comma-separated. Empty means no IP restriction. |
| `scopes` | Optional permission scopes. If omitted, the default read-only scopes are used. `*` grants all permissions. |
| `expires_at` | Optional expiration time. Empty means no expiration. |
| `disabled` | Whether this key is disabled. |
| `container_uuids` | Optional container allowlist that limits the key to specific containers. |
## Python Example
Fetch containers:
@@ -140,6 +246,7 @@ print(resp.json())
| --- | --- | --- |
| GET | `/api/v1/dashboard` | Dashboard statistics |
| GET | `/api/v1/host-info` | Host resources |
| GET | `/api/v1/host-report` | Host inspection report |
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing |
| PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools |
| POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment |
@@ -151,10 +258,11 @@ print(resp.json())
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/containers` | Container list |
| GET | `/api/v1/containers` | Container list (recommended) |
| GET | `/api/v1/containers/list` | Compatible GET form for container list |
| POST | `/api/v1/containers/list` | Compatible POST form for container list |
| POST | `/api/v1/containers` | Create container |
| GET | `/api/v1/containers/{id|uuid|name}` | Container details |
| GET | `/api/v1/containers/{id\|uuid\|name}` | Container details |
| POST | `/api/v1/containers/{id}/start` | Start |
| POST | `/api/v1/containers/{id}/stop` | Stop |
| POST | `/api/v1/containers/{id}/restart` | Restart |
@@ -173,10 +281,12 @@ print(resp.json())
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/containers/{id}/random-port` | Random available port |
| GET | `/api/v1/containers/{id}/random-port` | Random available port; accepts `host_ip` to check a specific host IP |
| POST | `/api/v1/containers/{id}/port-mappings` | Add port mapping |
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping |
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | Delete port mapping |
| GET | `/api/v1/containers/{id}/firewall` | Get container firewall settings |
| PUT | `/api/v1/containers/{id}/firewall` | Update container firewall settings |
| GET | `/api/v1/snapshots` | Snapshot overview |
| GET | `/api/v1/containers/{id}/snapshots` | Container snapshots |
| POST | `/api/v1/containers/{id}/snapshots` | Create snapshot |
@@ -191,6 +301,7 @@ print(resp.json())
| --- | --- | --- |
| GET | `/api/v1/templates` | Template list |
| GET | `/api/v1/images` | Image management list |
| GET | `/api/v1/images/enabled` | Enabled and downloaded images; supports `type=lxc\|kvm` |
| POST | `/api/v1/images/download` | Download image |
| POST | `/api/v1/images/cancel` | Cancel image download |
| DELETE | `/api/v1/images/delete` | Delete image cache |
@@ -203,6 +314,12 @@ print(resp.json())
| PUT | `/api/v1/security/settings` | Update security settings |
| GET | `/api/v1/swap` | Swap information |
| POST | `/api/v1/swap` | Adjust Swap |
| GET | `/api/v1/language` | Current panel language |
| POST/PUT | `/api/v1/language` | Update panel language |
| GET | `/api/v1/ssl` | SSL settings (requires admin permission / `admin:access`) |
| PUT | `/api/v1/ssl` | Update SSL settings (requires admin permission / `admin:access`) |
| GET | `/api/v1/webssh-origins` | WebSSH Origin allowlist (requires admin permission / `admin:access`) |
| PUT | `/api/v1/webssh-origins` | Update WebSSH Origin allowlist (requires admin permission / `admin:access`) |
| POST | `/api/v1/batch-create` | Batch create containers |
| POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall |
| POST | `/api/v1/ssh-ticket` | Create WebSSH ticket |
@@ -255,6 +372,16 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
}
},
"GET /api/v1/host-report": {
"success": true,
"data": {
"generated_at": "2026-06-12 10:00:00",
"summary": { "status": "ok", "warnings": 0 },
"host": { "hostname": "node-1", "kernel": "6.8.0" },
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
}
},
"GET /api/v1/routing": {
"success": true,
"data": {
@@ -331,6 +458,10 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"vcpu": 1,
"ram_mb": 512,
"disk_gb": 10,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80,
"status": "running",
"ip": "10.0.0.10",
"ipv6": "2001:db8:100::1005",
@@ -343,6 +474,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
}
]
},
"GET /api/v1/containers/list": {
"success": true,
"data": [
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
]
},
"POST /api/v1/containers/list": {
"success": true,
"data": [
@@ -410,7 +547,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"success": true,
"data": {
"mode": "total",
"limit_gb": 0,
"limit_gb": 1024,
"in_limit_gb": 0,
"out_limit_gb": 0,
"total_used_bytes": 142082,
@@ -453,7 +590,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
```json
{
"GET /api/v1/containers/{id}/random-port": {
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
"success": true,
"data": { "port": 61320 }
},
@@ -474,6 +611,21 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"success": true,
"data": []
},
"GET /api/v1/containers/{id}/firewall": {
"success": true,
"data": {
"enabled": true,
"default_action": "DROP",
"rules": [
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
]
}
},
"PUT /api/v1/containers/{id}/firewall": {
"success": true,
"message": "Firewall updated",
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
},
"GET /api/v1/snapshots": {
"success": true,
"data": null
@@ -539,6 +691,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
]
},
"GET /api/v1/images/enabled?type=lxc": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
]
},
"POST /api/v1/images/download": {
"success": true,
"message": "Already downloaded"
@@ -585,9 +743,35 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
},
"POST /api/v1/swap": {
"success": true,
"message": "SWAP 已调整为 16384 MB",
"message": "SWAP adjusted to 16384 MB",
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
},
"GET /api/v1/language": {
"success": true,
"data": { "language": "zh" }
},
"PUT /api/v1/language": {
"success": true,
"data": { "language": "en" }
},
"GET /api/v1/ssl": {
"success": true,
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
},
"PUT /api/v1/ssl": {
"success": true,
"message": "SSL settings saved",
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
},
"GET /api/v1/webssh-origins": {
"success": true,
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"PUT /api/v1/webssh-origins": {
"success": true,
"message": "Origin allowlist saved",
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"POST /api/v1/batch-create": {
"success": true,
"data": ["task-12"]
@@ -654,17 +838,17 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
"GET /api/v1/api-keys": {
"success": true,
"data": [
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" }
]
},
"POST /api/v1/api-keys": {
"success": true,
"message": "API key created. Save this key now - it won't be shown again.",
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"PATCH /api/v1/api-keys/{id}": {
"success": true,
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"DELETE /api/v1/api-keys/{id}": {
"success": true,
+193 -9
View File
@@ -53,7 +53,11 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
"ssh_auth_mode": "auto_password",
"ssh_password": "",
"ssh_public_key": "",
"expires_at": ""
"expires_at": "",
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
@@ -71,6 +75,12 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
| `ssh_auth_mode` | Linux 创建支持 `auto_password``password``key`;重装额外支持 `keep`。 |
| `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 |
| `ssh_public_key` | `key` 模式下的一行 SSH 公钥。 |
| `network_down_mbps` | 可选;容器下行/下载带宽限制,单位 Mbps,`0` 表示不限制。 |
| `network_up_mbps` | 可选;容器上行/上传带宽限制,单位 Mbps,`0` 表示不限制。 |
| `io_read_mbps` | 可选;磁盘读取限速,单位 MB/s,`0` 表示不限制。 |
| `io_write_mbps` | 可选;磁盘写入限速,单位 MB/s,`0` 表示不限制。 |
| `network_bw_mbps` | 兼容旧字段;同时设置上下行对称带宽,新接入推荐使用拆分字段。 |
| `io_speed_mbps` | 兼容旧字段;同时设置读写对称 IO 限速,新接入推荐使用拆分字段。 |
重装示例:
@@ -85,6 +95,102 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
`keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。
## 资源限制与流量限制
`PUT /api/v1/containers/{id}/resource-limit` 支持按字段局部更新;未传的字段保持不变。
```json
{
"vcpu": 2,
"ram_mb": 1024,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80
}
```
旧版 `network_bw_mbps``io_speed_mbps` 仍可用,分别表示上下行对称带宽和读写对称 IO 限速。新接入建议使用拆分字段,以便分别控制下载/上传和读取/写入。
`PUT /api/v1/containers/{id}/traffic-limit` 请求体:
```json
{
"traffic_mode": "total",
"monthly_traffic_gb": 1024,
"traffic_in_gb": 0,
"traffic_out_gb": 0
}
```
| 字段 | 说明 |
| --- | --- |
| `traffic_mode` | 流量限制模式;常用 `total` 表示总量限制,`split` 表示入站/出站分别限制。 |
| `monthly_traffic_gb` | `total` 模式下的月总流量额度,单位 GB`0` 表示不限制。 |
| `traffic_in_gb` | `split` 模式下的月入站额度,单位 GB`0` 表示不限制。 |
| `traffic_out_gb` | `split` 模式下的月出站额度,单位 GB`0` 表示不限制。 |
## 容器防火墙
容器防火墙通过 `GET /api/v1/containers/{id}/firewall` 读取,通过 `PUT /api/v1/containers/{id}/firewall` 更新。容器运行中更新时会立即应用规则。
更新示例:
```json
{
"enabled": true,
"default_action": "DROP",
"rules": [
{
"direction": "in",
"protocol": "tcp",
"action": "ACCEPT",
"network": "ipv4",
"source_ip": "203.0.113.0/24",
"port": "22,80,443",
"description": "allow admin and web"
}
]
}
```
| 字段 | 说明 |
| --- | --- |
| `enabled` | 是否启用容器防火墙。 |
| `default_action` | 默认动作:`ACCEPT``DROP`。 |
| `rules[].id` | 可选;新规则可省略,后端会自动生成。 |
| `rules[].direction` | 方向:`in``out`。 |
| `rules[].protocol` | 协议:`tcp``udp``icmp``all`。 |
| `rules[].action` | 动作:`ACCEPT``DROP`。 |
| `rules[].network` | 网络类型:`ipv4``ipv6``all`。 |
| `rules[].source_ip` | 可选;源 IP、CIDR 或地址范围。 |
| `rules[].port` | 可选;仅 `tcp`/`udp` 支持,可写 `22``80,443``8000-9000`。 |
| `rules[].description` | 可选备注。 |
## API Key 创建与更新
`POST /api/v1/api-keys``PATCH /api/v1/api-keys/{id}` 使用相同的字段结构。创建时 `name` 必填;更新时根据需要覆盖字段。
```json
{
"name": "Automation",
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
"scopes": ["dashboard:read", "container:read", "container:power"],
"expires_at": "2026-12-31 23:59:59",
"disabled": false,
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
}
```
| 字段 | 说明 |
| --- | --- |
| `name` | API Key 名称;创建时必填。 |
| `ip_whitelist` | 可选;允许的来源 IP/CIDR,多个值用逗号分隔;空值表示不限制。 |
| `scopes` | 可选;权限范围。省略时使用默认只读范围,传 `*` 表示全部权限。 |
| `expires_at` | 可选;过期时间,空值表示不过期。 |
| `disabled` | 是否禁用该 Key。 |
| `container_uuids` | 可选;限制该 Key 只能访问指定容器。 |
## Python 示例
获取容器列表:
@@ -140,6 +246,7 @@ print(resp.json())
| --- | --- | --- |
| GET | `/api/v1/dashboard` | 控制面板统计 |
| GET | `/api/v1/host-info` | 主机资源 |
| GET | `/api/v1/host-report` | 主机巡检报告 |
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 |
| PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 |
| POST | `/api/v1/routing/ipv4-scan` | 扫描公网 IPv4 段 |
@@ -151,10 +258,11 @@ print(resp.json())
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/containers` | 容器列表 |
| GET | `/api/v1/containers` | 容器列表(推荐) |
| GET | `/api/v1/containers/list` | 容器列表兼容 GET 写法 |
| POST | `/api/v1/containers/list` | 容器列表兼容 POST 写法 |
| POST | `/api/v1/containers` | 创建容器 |
| GET | `/api/v1/containers/{id|uuid|name}` | 容器详情 |
| GET | `/api/v1/containers/{id\|uuid\|name}` | 容器详情 |
| POST | `/api/v1/containers/{id}/start` | 开机 |
| POST | `/api/v1/containers/{id}/stop` | 关机 |
| POST | `/api/v1/containers/{id}/restart` | 重启 |
@@ -173,10 +281,12 @@ print(resp.json())
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口 |
| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口;可传 `host_ip` 查询指定宿主机 IP |
| POST | `/api/v1/containers/{id}/port-mappings` | 添加端口映射 |
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | 更新端口映射 |
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | 删除端口映射 |
| GET | `/api/v1/containers/{id}/firewall` | 获取容器防火墙设置 |
| PUT | `/api/v1/containers/{id}/firewall` | 更新容器防火墙设置 |
| GET | `/api/v1/snapshots` | 快照总览 |
| GET | `/api/v1/containers/{id}/snapshots` | 容器快照 |
| POST | `/api/v1/containers/{id}/snapshots` | 创建快照 |
@@ -191,6 +301,7 @@ print(resp.json())
| --- | --- | --- |
| GET | `/api/v1/templates` | 模板列表 |
| GET | `/api/v1/images` | 镜像管理列表 |
| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` |
| POST | `/api/v1/images/download` | 下载镜像 |
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
@@ -203,6 +314,12 @@ print(resp.json())
| PUT | `/api/v1/security/settings` | 更新安全设置 |
| GET | `/api/v1/swap` | Swap 信息 |
| POST | `/api/v1/swap` | 调整 Swap |
| GET | `/api/v1/language` | 当前面板语言 |
| POST/PUT | `/api/v1/language` | 更新面板语言 |
| GET | `/api/v1/ssl` | SSL 设置(需管理员权限 / `admin:access` |
| PUT | `/api/v1/ssl` | 更新 SSL 设置(需管理员权限 / `admin:access` |
| GET | `/api/v1/webssh-origins` | WebSSH Origin 白名单(需管理员权限 / `admin:access` |
| PUT | `/api/v1/webssh-origins` | 更新 WebSSH Origin 白名单(需管理员权限 / `admin:access` |
| POST | `/api/v1/batch-create` | 批量创建容器 |
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
@@ -255,6 +372,16 @@ print(resp.json())
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
}
},
"GET /api/v1/host-report": {
"success": true,
"data": {
"generated_at": "2026-06-12 10:00:00",
"summary": { "status": "ok", "warnings": 0 },
"host": { "hostname": "node-1", "kernel": "6.8.0" },
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
}
},
"GET /api/v1/routing": {
"success": true,
"data": {
@@ -331,6 +458,10 @@ print(resp.json())
"vcpu": 1,
"ram_mb": 512,
"disk_gb": 10,
"network_down_mbps": 100,
"network_up_mbps": 50,
"io_read_mbps": 120,
"io_write_mbps": 80,
"status": "running",
"ip": "10.0.0.10",
"ipv6": "2001:db8:100::1005",
@@ -343,6 +474,12 @@ print(resp.json())
}
]
},
"GET /api/v1/containers/list": {
"success": true,
"data": [
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
]
},
"POST /api/v1/containers/list": {
"success": true,
"data": [
@@ -410,7 +547,7 @@ print(resp.json())
"success": true,
"data": {
"mode": "total",
"limit_gb": 0,
"limit_gb": 1024,
"in_limit_gb": 0,
"out_limit_gb": 0,
"total_used_bytes": 142082,
@@ -453,7 +590,7 @@ print(resp.json())
```json
{
"GET /api/v1/containers/{id}/random-port": {
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
"success": true,
"data": { "port": 61320 }
},
@@ -474,6 +611,21 @@ print(resp.json())
"success": true,
"data": []
},
"GET /api/v1/containers/{id}/firewall": {
"success": true,
"data": {
"enabled": true,
"default_action": "DROP",
"rules": [
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
]
}
},
"PUT /api/v1/containers/{id}/firewall": {
"success": true,
"message": "Firewall updated",
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
},
"GET /api/v1/snapshots": {
"success": true,
"data": null
@@ -539,6 +691,12 @@ print(resp.json())
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
]
},
"GET /api/v1/images/enabled?type=lxc": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
]
},
"POST /api/v1/images/download": {
"success": true,
"message": "Already downloaded"
@@ -588,6 +746,32 @@ print(resp.json())
"message": "SWAP 已调整为 16384 MB",
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
},
"GET /api/v1/language": {
"success": true,
"data": { "language": "zh" }
},
"PUT /api/v1/language": {
"success": true,
"data": { "language": "en" }
},
"GET /api/v1/ssl": {
"success": true,
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
},
"PUT /api/v1/ssl": {
"success": true,
"message": "SSL settings saved",
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
},
"GET /api/v1/webssh-origins": {
"success": true,
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"PUT /api/v1/webssh-origins": {
"success": true,
"message": "Origin allowlist saved",
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
},
"POST /api/v1/batch-create": {
"success": true,
"data": ["task-12"]
@@ -654,17 +838,17 @@ print(resp.json())
"GET /api/v1/api-keys": {
"success": true,
"data": [
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" }
]
},
"POST /api/v1/api-keys": {
"success": true,
"message": "API key created. Save this key now - it won't be shown again.",
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"PATCH /api/v1/api-keys/{id}": {
"success": true,
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
},
"DELETE /api/v1/api-keys/{id}": {
"success": true,
+107 -107
View File
@@ -369,9 +369,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -386,9 +386,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -403,9 +403,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -420,9 +420,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -437,9 +437,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -454,9 +454,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -471,9 +471,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -488,9 +488,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -505,9 +505,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -522,9 +522,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -539,9 +539,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -556,9 +556,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -573,9 +573,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -590,9 +590,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -607,9 +607,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -624,9 +624,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -641,9 +641,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -658,9 +658,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -675,9 +675,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -692,9 +692,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -709,9 +709,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -726,9 +726,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -743,9 +743,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -760,9 +760,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -777,9 +777,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -794,9 +794,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -1757,9 +1757,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1770,32 +1770,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/estree-walker": {
+2 -1
View File
@@ -11,6 +11,7 @@
"vitepress": "^1.6.4"
},
"overrides": {
"vite": "6.4.2"
"vite": "6.4.2",
"esbuild": "0.28.1"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLICD - LXC Container Manager</title>
<title>CLICD - Container Manager</title>
<script>
(function() {
var theme = localStorage.getItem('clicd_theme');
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "clicd-frontend",
"version": "1.1.1",
"version": "1.1.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clicd-frontend",
"version": "1.1.1",
"version": "1.1.19",
"dependencies": {
"@novnc/novnc": "1.5.0",
"@xterm/addon-fit": "^0.11.0",
@@ -1372,16 +1372,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.13",
"version": "1.1.21",
"type": "module",
"scripts": {
"dev": "vite",
+8 -1
View File
@@ -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`
}
@@ -21,11 +21,15 @@ const defaultForm: CreateContainerRequest = {
ram_mb: 512,
disk_gb: 10,
network_bw_mbps: 0,
network_down_mbps: 0,
network_up_mbps: 0,
monthly_traffic_gb: 0,
traffic_mode: 'total',
traffic_in_gb: 0,
traffic_out_gb: 0,
io_speed_mbps: 0,
io_read_mbps: 0,
io_write_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
assign_nat: true,
@@ -321,7 +325,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
type="checkbox"
checked={!!form.assign_ipv4}
disabled={!ipv4Available}
onChange={(event) => setForm({ ...form, assign_ipv4: event.target.checked, public_ipv4s: event.target.checked ? form.public_ipv4s : [] })}
onChange={(event) => setForm({
...form,
assign_ipv4: event.target.checked,
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [] } : {}),
})}
className="mt-1"
/>
<span className="min-w-0">
@@ -429,6 +438,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 } : {}),
})
}}
className="mt-1"
@@ -492,7 +502,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Field label="磁盘 (GB)">
<NumberInput
value={form.disk_gb}
@@ -503,12 +513,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>
<Field label="IO 速度 (MB/s)">
<NumberInput value={form.io_speed_mbps} min={0} onChange={(value) => setForm({ ...form, io_speed_mbps: value })} />
</Field>
<div className="grid grid-cols-2 gap-3 md:col-span-2">
<Field label="下行带宽 (Mbps)">
<NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
</Field>
<Field label="上行带宽 (Mbps)">
<NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
</Field>
<Field label="读取 IO (MB/s)">
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
</Field>
<Field label="写入 IO (MB/s)">
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
</Field>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -680,9 +698,10 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form)
const wantsNAT = normalized.assign_nat !== false
const wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6
// IPv4 and NAT are mutually exclusive
const wantsNAT = wantsIPv4 ? false : normalized.assign_nat !== false
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return {
@@ -772,5 +791,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'
+135 -33
View File
@@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import { ReactNode, useId } from 'react'
import { RefreshCw } from 'lucide-react'
import { useTheme } from '../contexts/ThemeContext'
@@ -9,17 +9,27 @@ export type ChartPoint = {
value: number
}
export type ResourceChartSeries = {
label: string
points: ChartPoint[]
current?: number
color?: string
}
export type ResourceChartConfig = {
title: string
icon: ReactNode
points: ChartPoint[]
current: number
series?: ResourceChartSeries[]
detail?: string
max?: number
unitLabel?: string
formatValue: (value: number) => string
}
const chartPalette = ['#2563eb', '#16a34a', '#d97706', '#dc2626']
const rangeLabels: Record<StatsRangeKey, string> = {
'30m': '30分钟',
'1h': '1小时',
@@ -77,36 +87,52 @@ export default function ResourceStatsPanel({
<div className="grid grid-cols-1 xl:grid-cols-2">
{charts.map((chart, index) => (
<DetailedChart key={chart.title} chart={chart} className={chartBorderClass(index)} />
<DetailedChart key={chart.title} chart={chart} range={range} className={chartBorderClass(index)} />
))}
</div>
</section>
)
}
function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) {
const values = chart.points.map((point) => point.value)
const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
const peak = values.length > 0 ? Math.max(...values) : 0
function DetailedChart({ chart, range, className }: { chart: ResourceChartConfig; range: StatsRangeKey; className: string }) {
const series = chart.series?.length
? chart.series
: [{ label: chart.title, points: chart.points, current: chart.current }]
const primaryStats = getSeriesStats(series[0], chart.current)
return (
<div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
<span>{chart.title}</span>
</div>
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
</div>
<div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} />
<Stat label="平均" value={chart.formatValue(avg)} />
<Stat label="峰值" value={chart.formatValue(peak)} />
</div>
{series.length > 1 ? (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-right sm:shrink-0">
{series.map((item, index) => (
<SeriesStat
key={item.label}
color={item.color || chartPalette[index % chartPalette.length]}
label={item.label}
stats={getSeriesStats(item, item.current)}
formatValue={chart.formatValue}
/>
))}
</div>
) : (
<div className="grid grid-cols-3 gap-3 text-right sm:shrink-0">
<Stat label="当前" value={chart.formatValue(primaryStats.current)} />
<Stat label="平均" value={chart.formatValue(primaryStats.avg)} />
<Stat label="峰值" value={chart.formatValue(primaryStats.peak)} />
</div>
)}
</div>
<LineAreaChart
points={chart.points}
series={series}
range={range}
max={chart.max}
formatValue={chart.formatValue}
unitLabel={chart.unitLabel}
@@ -115,6 +141,33 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
)
}
function SeriesStat({
color,
label,
stats,
formatValue,
}: {
color: string
label: string
stats: { current: number; avg: number; peak: number }
formatValue: (value: number) => string
}) {
return (
<div className="min-w-[104px]">
<div className="flex items-center justify-end gap-1 text-[10px] text-gray-400 dark:text-gray-500">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span>{label}</span>
</div>
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">
{formatValue(stats.current)}
</div>
<div className="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums whitespace-nowrap">
{formatValue(stats.avg)} / {formatValue(stats.peak)}
</div>
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
@@ -124,19 +177,33 @@ function Stat({ label, value }: { label: string; value: string }) {
)
}
function getSeriesStats(series: ResourceChartSeries, fallbackCurrent = 0) {
const values = series.points
.map((point) => point.value)
.filter((value) => Number.isFinite(value))
const current = Number.isFinite(series.current) ? Number(series.current) : fallbackCurrent
const samples = values.length > 0 ? values : [current]
const avg = samples.reduce((sum, value) => sum + value, 0) / samples.length
const peak = Math.max(current, ...samples, 0)
return { current, avg, peak }
}
function LineAreaChart({
points,
series,
range,
max,
formatValue,
unitLabel,
}: {
points: ChartPoint[]
series: ResourceChartSeries[]
range: StatsRangeKey
max?: number
formatValue: (value: number) => string
unitLabel?: string
}) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const gradientId = `resource-chart-fill-${useId().replace(/:/g, '')}`
const width = 520
const height = 150
@@ -146,21 +213,21 @@ function LineAreaChart({
const bottom = 28
const innerWidth = width - left - right
const innerHeight = height - top - bottom
const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }]
const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1)
const minTs = values[0]?.ts || Date.now()
const maxTs = values[values.length - 1]?.ts || minTs + 1
const span = Math.max(maxTs - minTs, 1)
const coords = values.map((point, index) => {
const x = left + ((point.ts - minTs) / span) * innerWidth
const y = top + innerHeight - (point.value / maxValue) * innerHeight
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
const now = Date.now()
const chartSeries = series.map((item) => {
const validPoints = item.points.filter((point) => Number.isFinite(point.ts) && Number.isFinite(point.value))
return {
...item,
points: validPoints.length > 0
? validPoints
: [{ ts: now, value: Number.isFinite(item.current) ? Number(item.current) : 0 }],
}
})
const fallbackX = left
const fallbackY = top + innerHeight
const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}`
const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}`
const allPoints = chartSeries.flatMap((item) => item.points)
const maxValue = Math.max(max || 0, ...allPoints.map((point) => point.value), 1)
const maxTs = now
const minTs = now - statsRanges[range]
const span = Math.max(maxTs - minTs, 1)
const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1]
@@ -171,11 +238,13 @@ function LineAreaChart({
const lineStroke = isDark ? '#f9fafb' : '#444'
const gradientTop = isDark ? '#f9fafb' : '#555'
const gradientBottom = isDark ? '#374151' : '#555'
const primaryLine = buildLine(chartSeries[0]?.points || [{ ts: now, value: 0 }], minTs, span, left, top, innerWidth, innerHeight, maxValue)
const area = `${left},${top + innerHeight} ${primaryLine} ${left + innerWidth},${top + innerHeight}`
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
</linearGradient>
@@ -214,12 +283,45 @@ function LineAreaChart({
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
<polygon points={area} fill="url(#resource-chart-fill)" />
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
{chartSeries.length === 1 && <polygon points={area} fill={`url(#${gradientId})`} />}
{chartSeries.map((item, index) => (
<polyline
key={item.label || index}
points={buildLine(item.points, minTs, span, left, top, innerWidth, innerHeight, maxValue)}
fill="none"
stroke={item.color || (chartSeries.length === 1 ? lineStroke : chartPalette[index % chartPalette.length])}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
)
}
function buildLine(
points: ChartPoint[],
minTs: number,
span: number,
left: number,
top: number,
innerWidth: number,
innerHeight: number,
maxValue: number,
) {
const coords = points.map((point) => {
const x = left + ((point.ts - minTs) / span) * innerWidth
const y = top + innerHeight - (point.value / maxValue) * innerHeight
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
})
if (coords.length > 1) return coords.join(' ')
const [, yText] = (coords[0] || `${left},${top + innerHeight}`).split(',')
const y = Number(yText)
const safeY = Number.isFinite(y) ? y : top + innerHeight
return `${left},${safeY} ${left + innerWidth},${safeY}`
}
function chartBorderClass(index: number) {
const right = index % 2 === 0 ? 'xl:border-r' : ''
const top = index > 1 ? 'border-t' : ''
+71 -15
View File
@@ -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 {
@@ -176,6 +177,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
['POST', '/api/v1/containers/{id}/port-mappings', '添加端口映射'],
['PUT', '/api/v1/containers/{id}/port-mappings/{index}', '更新端口映射'],
['DELETE', '/api/v1/containers/{id}/port-mappings/{index}', '删除端口映射'],
['GET', '/api/v1/containers/{id}/firewall', '获取防火墙设置'],
['PUT', '/api/v1/containers/{id}/firewall', '更新防火墙设置'],
['GET', '/api/v1/snapshots', '快照总览'],
['GET', '/api/v1/containers/{id}/snapshots', '容器快照'],
['POST', '/api/v1/containers/{id}/snapshots', '创建快照'],
@@ -236,6 +239,7 @@ const emptyForm = (): ApiKeyForm => ({
})
export default function ApiIntegration() {
const { t } = useLanguage()
const [keys, setKeys] = useState<ApiKeyItem[]>([])
const [containers, setContainers] = useState<Container[]>([])
const [loading, setLoading] = useState(true)
@@ -327,7 +331,7 @@ export default function ApiIntegration() {
}
const deleteKey = async (id: string) => {
if (!window.confirm('确定删除这个 API Key 吗?')) return
if (!window.confirm(t('确定删除这个 API Key 吗?'))) return
try {
await api.delete(`/api-keys/${id}`)
setKeys(prev => prev.filter(k => k.id !== id))
@@ -728,11 +732,15 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
ram_mb: 512,
disk_gb: 10,
network_bw_mbps: 0,
network_down_mbps: 100,
network_up_mbps: 20,
monthly_traffic_gb: 0,
traffic_mode: 'total',
traffic_in_gb: 0,
traffic_out_gb: 0,
io_speed_mbps: 0,
io_read_mbps: 80,
io_write_mbps: 30,
extra_ports: [8080],
port_mapping_count: 2,
assign_nat: true,
@@ -763,8 +771,12 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
'PUT /api/v1/containers/{id}/resource-limit': {
vcpu: 1,
ram_mb: 512,
io_speed_mbps: 0,
network_bw_mbps: 0,
network_down_mbps: 100,
network_up_mbps: 20,
io_read_mbps: 80,
io_write_mbps: 30,
network_bw_mbps: 20,
io_speed_mbps: 30,
},
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
@@ -817,6 +829,15 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
limit: 64,
},
'POST /api/v1/security/check': { container_name: 'example-vm' },
'PUT /api/v1/containers/{id}/firewall': {
enabled: true,
default_action: 'DROP',
rules: [
{ id: '', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
{ id: '', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
{ id: '', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
],
},
'PUT /api/v1/security/settings': { auto_shutdown: false },
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
'POST /api/v1/batch-create': {
@@ -893,6 +914,7 @@ const responseSamples: Record<string, unknown> = {
success: true,
data: {
nat4: { used: 62, remaining: '45474', total: '45536' },
nat4_port_range: { start: 20000, end: 65535 },
ipv4: { used: 1, remaining: '3', total: '4' },
ipv6: { used: 31, remaining: 'large', total: 'large' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
@@ -906,6 +928,8 @@ const responseSamples: Record<string, unknown> = {
'PUT /api/v1/routing': {
success: true,
data: {
nat4: { used: 62, remaining: '45474', total: '45536' },
nat4_port_range: { start: 20000, end: 65535 },
ipv4: { used: 1, remaining: '3', total: '4' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
@@ -1025,6 +1049,31 @@ const responseSamples: Record<string, unknown> = {
data: [{ container_port: 8081, host_port: 61320, protocol: 'tcp', description: 'HTTP' }],
},
'DELETE /api/v1/containers/{id}/port-mappings/{index}': { success: true, data: [] },
'GET /api/v1/containers/{id}/firewall': {
success: true,
data: {
enabled: true,
default_action: 'DROP',
rules: [
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
],
},
},
'PUT /api/v1/containers/{id}/firewall': {
success: true,
message: 'Firewall updated',
data: {
enabled: true,
default_action: 'DROP',
rules: [
{ id: 'a1b2c3d4', network: 'ipv4', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv4/NAT4', enabled: true },
{ id: 'e5f6g7h8', network: 'ipv6', direction: 'in', protocol: 'tcp', port: '22', source_ip: '', action: 'ACCEPT', description: 'Allow SSH over IPv6', enabled: true },
{ id: 'i9j0k1l2', network: 'all', direction: 'out', protocol: 'tcp', port: '', source_ip: '', action: 'ACCEPT', description: 'Allow outbound TCP', enabled: true },
],
},
},
'GET /api/v1/snapshots': { success: true, data: null },
'GET /api/v1/containers/{id}/snapshots': {
success: true,
@@ -1062,11 +1111,11 @@ const responseSamples: Record<string, unknown> = {
'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
'POST /api/v1/swap': { success: true, message: 'SWAP 已调整为 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
'POST /api/v1/swap': { success: true, message: 'SWAP adjusted to 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
'POST /api/v1/batch-create': { success: true, data: ['task-12'] },
'POST /api/v1/batch-action': { success: true, data: ['task-13'] },
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60秒有效票据***' } },
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
'POST /api/v1/sub-user/create': {
success: true,
message: 'Sub-user created',
@@ -1131,26 +1180,33 @@ 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;公网 IPv4IPv6 与 NAT 可通过 assign_natassign_ipv4assign_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.')
}
if (key === 'POST /api/v1/containers/{id}/reinstall') {
notes.push('重装支持 ssh_auth_mode=keep|auto_password|password|keykeep 仅用于重装,未传 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 network and SSH authentication fields as POST /api/v1/containers.')
}
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
}
if (key === 'PUT /api/v1/containers/{id}/firewall') {
notes.push('Backward compatible: default_action is optional; if omitted, the existing policy is kept. rule.network is optional; if omitted, it is treated as ipv4. default_action: DROP=deny unmatched traffic, ACCEPT=allow unmatched traffic. network: ipv4=IPv4 NAT/public IPv4, ipv6=IPv6, all=apply to both IPv4 and IPv6. For NAT inbound rules, port is the container internal port, not the host public port.')
}
if (key === 'POST /api/v1/batch-action') {
notes.push('action=reinstall 时可追加 template_idssh_auth_modessh_passwordssh_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:writeverify=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.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(' ')
}
+512 -44
View File
@@ -19,7 +19,6 @@ import {
Plus,
RefreshCw,
Save,
Settings,
Square,
TerminalSquare,
@@ -47,7 +46,9 @@ import {
HostInfo,
TrafficInfo,
getEnabledImages,
getFirewall,
PortMapping,
FirewallRule,
reinstallContainer,
resetSSHPassword,
restartContainer,
@@ -57,6 +58,7 @@ import {
SnapshotSchedule,
Template,
updateContainerExpiry,
updateFirewall,
updateSnapshotQuota,
updateSnapshotSchedule,
restoreContainerSnapshot,
@@ -87,8 +89,12 @@ type MetricPoint = {
ts: number
cpu: number
memory: number
network: number
diskIO: number
network?: number
networkRx?: number
networkTx?: number
diskIO?: number
diskRead?: number
diskWrite?: number
}
type MappingDraft = {
index: number | null
@@ -147,7 +153,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)
@@ -163,6 +169,14 @@ export default function ContainerDetail() {
const [snapshotBusy, setSnapshotBusy] = 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)
const fetchContainer = useCallback(async () => {
if (!containerIdentifier) return
@@ -204,15 +218,21 @@ export default function ContainerDetail() {
const memoryPct = memoryTotalBytes > 0
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
: 0
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
const networkRx = nextUsage.network_rx_bps || 0
const networkTx = nextUsage.network_tx_bps || 0
const diskRead = nextUsage.disk_read_bps || 0
const diskWrite = nextUsage.disk_write_bps || 0
const point: MetricPoint = {
ts: Date.now(),
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
memory: clamp(memoryPct),
network: networkBps,
diskIO: diskIOBps,
network: networkRx + networkTx,
networkRx,
networkTx,
diskIO: diskRead + diskWrite,
diskRead,
diskWrite,
}
setHistory((prev) => {
@@ -385,8 +405,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)
}
@@ -398,8 +420,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()
@@ -410,6 +436,91 @@ export default function ContainerDetail() {
}
}
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
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 {
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) {
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: '',
source_ip: '',
action: 'DROP',
description: '',
enabled: true,
})
setShowFirewallEditor(true)
}
const saveFirewallRule = (rule: FirewallRule) => {
if (rule.id) {
// Update existing
setFirewallRules(firewallRules.map(r => r.id === rule.id ? rule : r))
} else {
// Add new with temporary ID
const newRule = { ...rule, id: `tmp-${Date.now()}` }
setFirewallRules([...firewallRules, newRule])
}
setShowFirewallEditor(false)
setEditingFirewallRule(null)
}
const deleteFirewallRule = (ruleId: string) => {
setFirewallRules(firewallRules.filter(r => r.id !== ruleId))
}
const toggleFirewallRule = (ruleId: string) => {
setFirewallRules(firewallRules.map(r => r.id === ruleId ? { ...r, enabled: !r.enabled } : r))
}
const openReinstall = async () => {
try {
const res = await getEnabledImages(container?.virtualization || 'lxc')
@@ -753,6 +864,7 @@ export default function ContainerDetail() {
}
const isRunning = container.status === 'running'
const isInitializing = container.status === 'initializing'
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const isWindows = container.template?.includes('windows')
const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate)
@@ -770,8 +882,29 @@ export default function ContainerDetail() {
if (ipv6List.length === 0 && container.ipv6) ipv6List.push(container.ipv6)
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const publicEndpoint = container.ssh_port > 0 ? `${publicHost}:${container.ssh_port}` : '-'
const sshCommand = container.ssh_port > 0 ? `ssh -p ${container.ssh_port} root@${publicHost}` : ''
const hasIndependentIPv4 = assignedIPv4List.length > 0
const hasIndependentIPv6 = ipv6List.length > 0
const defaultConnPort = isWindows ? 3389 : 22
let publicEndpoint = '-'
let sshCommand = ''
if (hasIndependentIPv4) {
// Direct connection via independent IPv4 — all ports forwarded
publicEndpoint = `${assignedIPv4List[0]}:${defaultConnPort}`
if (!isWindows) {
sshCommand = `ssh root@${assignedIPv4List[0]}`
}
} else if (hasIndependentIPv6) {
publicEndpoint = `[${ipv6List[0]}]:${defaultConnPort}`
if (!isWindows) {
sshCommand = `ssh root@[${ipv6List[0]}]`
}
} else if (container.ssh_port > 0) {
// NAT port mapping mode
publicEndpoint = `${publicHost}:${container.ssh_port}`
sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
}
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
@@ -784,14 +917,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' : 'IPv4NAT',
})
}
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)}`
: ''
@@ -819,16 +980,24 @@ export default function ContainerDetail() {
icon: <Network className="w-5 h-5" />,
current: networkBps,
points: toChartPoints(filtered, 'network'),
series: [
{ label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' },
{ label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' },
],
formatValue: formatRate,
detail: `${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},累计 ${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)}%`,
},
]
@@ -854,13 +1023,17 @@ export default function ContainerDetail() {
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-black">{container.name}</h1>
<StatusBadge running={isRunning} />
<StatusBadge running={isRunning} initializing={isInitializing} />
</div>
<div className="flex items-center gap-2 flex-wrap mt-2">
<InfoTag color="blue"> {container.template}</InfoTag>
<InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount}` : '未分配'}</InfoTag>
{hasIndependentIPv4 ? (
<InfoTag color="amber"> IPv4 {assignedIPv4List[0]}</InfoTag>
) : (
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount}` : '未分配'}</InfoTag>
)}
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicEndpoint}</InfoTag>
{isPolicyBlocked && <InfoTag color="red"></InfoTag>}
</div>
@@ -903,22 +1076,24 @@ export default function ContainerDetail() {
</ActionButton>
)}
<>
{!hasIndependentIPv4 && hasNATQuota && (
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" />
IPv4 NAT
</ActionButton>
</>
)}
<ActionButton onClick={openFirewall} disabled={isSubUserPolicyBlocked}>
<FirewallIcon className="w-3.5 h-3.5" />
</ActionButton>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
<Camera className="w-3.5 h-3.5" />
</ActionButton>
{!isSubUser && (
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired}>
<RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'}
</ActionButton>
)}
<ActionButton onClick={openReinstall} disabled={!!taskStatus || isExpired || isSubUserPolicyBlocked}>
<RefreshCw className="w-3.5 h-3.5" />
{isExpired ? '已到期' : taskStatus === 'reinstall' ? taskActionLabels['reinstall'] : '重装'}
</ActionButton>
{!isSubUser && (
<ActionButton disabled={!!taskStatus} onClick={() => handleAction('delete')}>
<Trash2 className="w-3.5 h-3.5" />
@@ -942,7 +1117,7 @@ export default function ContainerDetail() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Panel
title="连接信息"
extra={!isSubUser && !isWindows && !isSubUserPolicyBlocked ? (
extra={!isWindows && !isSubUserPolicyBlocked ? (
<button
onClick={openResetPassword}
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-100 hover:text-black"
@@ -1017,8 +1192,8 @@ 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('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
</Panel>
<Panel title="实时状态">
@@ -1418,7 +1593,242 @@ export default function ContainerDetail() {
</Modal>
)}
{showNat && (
{showFirewall && (
<Modal title="防火墙设置" onClose={() => { setShowFirewall(false); setShowFirewallEditor(false); setEditingFirewallRule(null) }} wide extra={
!isSubUser && (
<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>
)
}>
<div className="space-y-5">
{/* Global toggle */}
<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">
{firewallEnabled
? (firewallDefaultAction === 'DROP' ? '已启用,未匹配规则的流量将被拒绝' : '已启用,未匹配规则的流量将被放行')
: '未启用时不接管该容器流量'}
</div>
</div>
<button
onClick={() => setFirewallEnabled(!firewallEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${firewallEnabled ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${firewallEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
</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>
<th className="px-3 py-2 text-left font-medium">/ IP</th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
{!isSubUser && <th className="px-3 py-2 text-right font-medium"></th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{firewallRules.map((rule) => (
<tr key={rule.id} className={!rule.enabled ? 'opacity-50' : ''}>
<td className="px-3 py-2">
<button onClick={() => toggleFirewallRule(rule.id)} className={`inline-flex h-4 w-7 items-center rounded-full transition-colors ${rule.enabled ? 'bg-emerald-500' : 'bg-gray-300'}`}>
<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' ? '入站' : '出站'}
</span>
</td>
<td className="px-3 py-2 font-mono text-xs">{rule.protocol.toUpperCase()}</td>
<td className="px-3 py-2 font-mono text-xs">{rule.port || '全部'}</td>
<td className="px-3 py-2 font-mono text-xs">{rule.source_ip || '任意'}</td>
<td className="px-3 py-2">
<span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${rule.action === 'ACCEPT' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
{rule.action === 'ACCEPT' ? '放行' : '拒绝'}
</span>
</td>
<td className="px-3 py-2 text-xs text-gray-600 max-w-32 truncate">{rule.description || '-'}</td>
{!isSubUser && (
<td className="px-3 py-2 text-right">
<div className="inline-flex items-center gap-1">
<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">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
{firewallRules.length === 0 && (
<tr><td colSpan={isSubUser ? 8 : 9} className="px-3 py-6 text-center text-xs text-gray-400"></td></tr>
)}
</tbody>
</table>
</div>
{/* Save button */}
{!isSubUser && (
<div className="flex justify-end">
<button onClick={saveFirewall} disabled={firewallSaving} className="inline-flex items-center gap-1.5 px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800 disabled:opacity-50">
<Save className="w-3.5 h-3.5" />
{firewallSaving ? '保存中...' : '保存'}
</button>
</div>
)}
</div>
</Modal>
)}
{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>
<option value="out"> (Outbound)</option>
</select>
</Field>
<Field label="协议">
<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={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={(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}>
<option value="ACCEPT"> (ACCEPT)</option>
<option value="DROP"> (DROP)</option>
</select>
</Field>
<Field label="描述">
<input value={editingFirewallRule.description} onChange={(e) => setEditingFirewallRule({ ...editingFirewallRule, description: e.target.value })} placeholder="规则描述" className={inputClass} />
</Field>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => { setShowFirewallEditor(false); setEditingFirewallRule(null) }} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"></button>
<button onClick={() => saveFirewallRule(editingFirewallRule)} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800"></button>
</div>
</div>
</Modal>
)}
{showNat && !hasIndependentIPv4 && (
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
!isSubUser && canAddMapping && (
<button onClick={openAddMapping} 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">
@@ -1610,15 +2020,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"> (Mbps0=)</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"> (Mbps0=)</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/s0=)</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"> (Mbps0=)</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/s0=)</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/s0=)</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>
@@ -1652,7 +2074,23 @@ function RangeSwitch({ value, onChange }: { value: StatsRangeKey; onChange: (val
)
}
function StatusBadge({ running }: { running: boolean }) {
function FirewallIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M979.989543 469.308394H757.450516c4.519899-21.887511 7.247838-45.094992 7.247838-69.798441 0-137.428929-116.773391-270.417958-121.72528-276.001833a21.415521 21.415521 0 0 0-21.887511-6.319858 21.287524 21.287524 0 0 0-15.103663 16.98362l-12.583719 75.438315C571.854663 148.115571 533.535519 69.229333 467.241 5.910748A21.46352 21.46352 0 0 0 441.585573 2.982813a21.295524 21.295524 0 0 0-9.727782 23.935466c15.703649 58.366696-2.815937 152.996581-22.911488 226.978928-5.591875-35.7912-15.615651-66.214521-32.935264-76.414293a21.351523 21.351523 0 0 0-32.167282 18.399589c0 31.359299-15.999643 60.278653-34.519228 93.813904-24.703448 44.759-52.734822 95.525866-52.734822 167.972247 0 4.055909 0.599987 7.727827 0.767983 11.64774H41.346516A21.343523 21.343523 0 0 0 20.010993 490.651917v511.98856a21.343523 21.343523 0 0 0 21.335523 21.335524H979.989543a21.343523 21.343523 0 0 0 21.335524-21.335524v-511.98856A21.343523 21.343523 0 0 0 979.989543 469.308394z m-149.332663 42.663047v127.99714H660.380685c33.879243-29.183348 65.878528-72.702376 85.334093-127.99714h84.942102zM346.699693 310.255948c7.559831-13.599696 14.895667-26.919399 21.167527-40.399098 3.495922 28.543362 5.503877 64.510559 5.071887 100.26176a21.311524 21.311524 0 0 0 17.367612 21.199526 21.255525 21.255525 0 0 0 23.935465-13.351701c3.071931-8.191817 63.918572-169.980202 65.958527-293.241448 78.462247 104.493665 96.429845 228.906885 96.63784 230.354853a21.279525 21.279525 0 0 0 20.823535 18.431588c9.85578-0.255994 19.631561-7.383835 21.335523-17.791602L640.077138 189.29865c32.895265 46.422963 81.958169 129.277111 81.958169 210.219303 0 157.772475-113.837456 240.458627-153.212577 240.458628H455.241268c-19.023575-5.247883-155.940516-47.742933-155.940516-182.347926 0-61.486626 24.111461-105.133651 47.398941-147.372707zM659.996693 682.647627v127.99714H361.339366v-127.99714H659.996693zM190.67118 511.971441h72.750374c15.311658 60.974638 54.910773 101.717727 93.693907 127.99714H190.67118v-127.99714z m-127.99714 0H148.008133v127.99714H62.67404v-127.99714z m0 170.668186h255.99428v127.99714h-255.99428v-127.99714zM148.008133 981.296954H62.67404v-127.99714H148.008133v127.99714z m341.328373 0H190.67118v-127.99714h298.665326v127.99714z m341.320374 0H531.999553v-127.99714h298.657327v127.99714z m127.99714 0h-85.326093v-127.99714h85.326093v127.99714z m0-170.660187h-255.99428v-127.99714h255.99428v127.99714z m0-170.668186h-85.326093v-127.99714h85.326093v127.99714z" />
</svg>
)
}
function StatusBadge({ running, initializing }: { running: boolean; initializing?: boolean }) {
if (initializing) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap bg-amber-50 text-amber-700">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-amber-500 animate-pulse"></span>
</span>
)
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap ${running ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-emerald-500' : 'bg-rose-500'}`}></span>
@@ -2005,11 +2443,12 @@ function TableHead({ children }: { children: ReactNode }) {
return <th className="text-left px-3 py-2 text-xs font-medium text-gray-500">{children}</th>
}
function Field({ label, children }: { label: string; children: ReactNode }) {
function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
return (
<label className="block">
<span className="block text-xs font-medium text-gray-600 mb-1.5">{label}</span>
{children}
{hint && <span className="block text-[11px] text-gray-400 mt-1">{hint}</span>}
</label>
)
}
@@ -2070,8 +2509,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 {
+20 -3
View File
@@ -390,6 +390,7 @@ export default function Containers() {
<tbody className="divide-y divide-gray-100">
{pageContainers.map((container) => {
const isRunning = container.status === 'running'
const isInitializing = container.status === 'initializing'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
@@ -437,7 +438,7 @@ export default function Containers() {
</button>
</td>
<td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
<StatusBadge running={isRunning} initializing={isInitializing} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
@@ -483,7 +484,7 @@ export default function Containers() {
try {
const { default: api } = await import('../services/api')
await api.delete(`/tasks/${task.id}`)
fetchData()
await Promise.all([fetchData(), fetchTasks()])
} catch { /* ignore */ }
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-red-200 text-[11px] text-red-600 hover:bg-red-50 transition-colors whitespace-nowrap"
@@ -581,7 +582,7 @@ type DisplayContainer = Container & {
createTask?: Task
}
function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (policyBlocked) {
return (
@@ -640,6 +641,15 @@ function StatusBadge({ running, task, placeholder, policyBlocked }: { running: b
)
}
if (initializing) {
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
</span>
)
}
return (
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
@@ -694,6 +704,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
ram_mb: cfg.ram_mb,
disk_gb: cfg.disk_gb,
network_bw_mbps: cfg.network_bw_mbps,
network_down_mbps: cfg.network_down_mbps,
network_up_mbps: cfg.network_up_mbps,
monthly_traffic_gb: cfg.monthly_traffic_gb,
traffic_mode: cfg.traffic_mode || 'total',
traffic_in_gb: cfg.traffic_in_gb || 0,
@@ -702,6 +714,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: [],
@@ -714,6 +728,9 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
ssh_password: '',
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: '',
expires_at: cfg.expires_at,
+35 -8
View File
@@ -13,8 +13,12 @@ type HostMetricPoint = {
ts: number
cpu: number
memory: number
network: number
diskIO: number
network?: number
networkRx?: number
networkTx?: number
diskIO?: number
diskRead?: number
diskWrite?: number
}
const hostHistoryKey = 'clicd_host_metric_history_v2'
@@ -58,6 +62,10 @@ export default function Dashboard() {
const filtered = filterHistory(history, range)
const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0
const networkRxBps = host?.network.rx_bps || 0
const networkTxBps = host?.network.tx_bps || 0
const diskReadBps = host?.disk_io.read_bps || 0
const diskWriteBps = host?.disk_io.write_bps || 0
const networkBps = (host?.network.rx_bps || 0) + (host?.network.tx_bps || 0)
const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0)
@@ -85,16 +93,24 @@ export default function Dashboard() {
icon: <Network className="w-5 h-5" />,
current: networkBps,
points: toChartPoints(filtered, 'network'),
series: [
{ label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' },
{ label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' },
],
formatValue: formatRate,
detail: `${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`,
detail: `${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)}`,
},
{
title: '磁盘IO',
icon: <HardDrive className="w-5 h-5" />,
current: diskIOBps,
points: toChartPoints(filtered, 'diskIO'),
series: [
{ label: '读', points: toChartPoints(filtered, 'diskRead'), current: diskReadBps, color: '#d97706' },
{ label: '写', points: toChartPoints(filtered, 'diskWrite'), current: diskWriteBps, color: '#dc2626' },
],
formatValue: formatRate,
detail: `${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`,
detail: `${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)}`,
},
]
@@ -157,12 +173,20 @@ function SummaryCard({
}
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
const networkRx = host.network.rx_bps || 0
const networkTx = host.network.tx_bps || 0
const diskRead = host.disk_io.read_bps || 0
const diskWrite = host.disk_io.write_bps || 0
const point: HostMetricPoint = {
ts: Date.now(),
cpu: clamp(host.cpu.usage_pct),
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0),
diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0),
network: networkRx + networkTx,
networkRx,
networkTx,
diskIO: diskRead + diskWrite,
diskRead,
diskWrite,
}
setHistory((prev) => {
@@ -190,8 +214,11 @@ function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) {
return history.filter((point) => point.ts >= cutoff)
}
function toChartPoints<T extends keyof Omit<HostMetricPoint, 'ts'>>(history: HostMetricPoint[], key: T): ChartPoint[] {
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
function toChartPoints(history: HostMetricPoint[], key: keyof Omit<HostMetricPoint, 'ts'>): ChartPoint[] {
return history.flatMap((point) => {
const value = Number(point[key])
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
})
}
function clamp(value: number) {
+2 -2
View File
@@ -66,7 +66,7 @@ export default function Login() {
<AppIcon className="w-10 h-10" />
</div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'LXC Container Manager'}</p>
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'Container Manager'}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
@@ -128,7 +128,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.13</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.21</p>
</div>
</div>
)
+257 -14
View File
@@ -1,12 +1,16 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Globe2, Network, Pencil, Plus, RefreshCw, Router, Save, Search, Server, Trash2, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import {
getRoutingInfo,
updateRoutingIPv6Prefixes,
updateRoutingIPv4Pool,
updateRoutingPools,
type IPv4Route,
type IPv6Route,
type IPv6PrefixInfo,
type NAT4PortRange,
type NAT4Route,
type PublicIPv4Info,
type RoutingInfo,
@@ -23,7 +27,14 @@ export default function Routing() {
const [ipv4EditMode, setIPv4EditMode] = useState<'pool' | 'address'>('pool')
const [editingIPv4Address, setEditingIPv4Address] = useState('')
const [savingIPv4, setSavingIPv4] = useState(false)
const [ipv4Draft, setIPv4Draft] = useState<PublicIPv4Info[]>([])
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('')
@@ -48,13 +59,16 @@ export default function Routing() {
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Prefixes = routing?.ipv6_prefixes || []
const ipv6Assignments = routing?.ipv6_assignments || []
const nat4Range = routing?.nat4_port_range || { start: 20000, end: 65535 }
const defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0'
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32
const defaultIPv6Interface = ipv6Prefixes[0]?.interface || defaultIPv4Interface
const defaultIPv6Gateway = ipv6Prefixes[0]?.gateway || ''
useEffect(() => {
if (!editingIPv4) {
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip, _id: nextDraftId.current++ })))
}
}, [editingIPv4, publicIPv4s])
@@ -65,14 +79,14 @@ export default function Routing() {
}, [ipv4Assignments])
const startEditIPv4 = () => {
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip, _id: nextDraftId.current++ })))
setIPv4EditMode('pool')
setEditingIPv4Address('')
setEditingIPv4(true)
}
const startEditIPv4Address = (ip: PublicIPv4Info) => {
setIPv4Draft([{ ...ip }])
setIPv4Draft([{ ...ip, _id: nextDraftId.current++ }])
setIPv4EditMode('address')
setEditingIPv4Address(ip.address)
setEditingIPv4(true)
@@ -82,13 +96,14 @@ export default function Routing() {
setEditingIPv4(false)
setIPv4EditMode('pool')
setEditingIPv4Address('')
setIPv4Draft(publicIPv4s.map((ip) => ({ ...ip })))
setIPv4Draft([])
}
const addIPv4Row = () => {
setIPv4Draft((items) => [
...items,
{
_id: nextDraftId.current++,
address: '',
interface: defaultIPv4Interface,
prefix: '',
@@ -108,7 +123,7 @@ export default function Routing() {
setSavingIPv4(true)
try {
const draftItems = ipv4Draft
.map((item) => ({
.map(({ _id, ...item }) => ({
...item,
address: (item.address || '').trim(),
interface: (item.interface || defaultIPv4Interface).trim(),
@@ -137,6 +152,81 @@ export default function Routing() {
}
}
const startEditNAT4 = () => {
setNAT4Draft({ start: nat4Range.start || 20000, end: nat4Range.end || 65535 })
setEditingNAT4(true)
}
const saveNAT4Range = async () => {
const start = Math.round(Number(nat4Draft.start || 0))
const end = Math.round(Number(nat4Draft.end || 0))
if (start < 1 || start > 65535 || end < 1 || end > 65535 || start > end) {
alert(text.nat4RangeInvalid)
return
}
setSavingNAT4(true)
try {
const res = await updateRoutingPools({ nat4_port_range: { start, end } })
setRouting(res.data.data || null)
setEditingNAT4(false)
} catch (err: any) {
alert(err?.response?.data?.message || text.saveNAT4RangeFailed)
} finally {
setSavingNAT4(false)
}
}
const startEditIPv6 = () => {
setIPv6Draft(ipv6Prefixes.map((prefix) => ({ ...prefix, _id: nextDraftId.current++ })))
setEditingIPv6(true)
}
const addIPv6Row = () => {
setIPv6Draft((items) => [
...items,
{
_id: nextDraftId.current++,
prefix: '',
address: '',
prefix_len: 64,
interface: defaultIPv6Interface,
gateway: defaultIPv6Gateway,
source: 'manual',
},
])
}
const updateIPv6Draft = (index: number, patch: Partial<IPv6PrefixInfo>) => {
setIPv6Draft((items) => items.map((item, i) => (i === index ? { ...item, ...patch } : item)))
}
const saveIPv6Prefixes = async () => {
setSavingIPv6(true)
try {
const items = ipv6Draft
.map(({ _id, ...item }) => ({
...item,
prefix: (item.prefix || '').trim(),
address: (item.address || '').trim(),
interface: (item.interface || defaultIPv6Interface).trim(),
gateway: (item.gateway || '').trim(),
prefix_len: Number(item.prefix_len || 0),
}))
.filter((item) => item.prefix || item.address)
if (items.some((item) => !item.interface)) {
alert(text.ipv6InterfaceRequired)
return
}
const res = await updateRoutingIPv6Prefixes(items)
setRouting(res.data.data || null)
setEditingIPv6(false)
} catch (err: any) {
alert(err?.response?.data?.message || text.saveIPv6PrefixesFailed)
} finally {
setSavingIPv6(false)
}
}
const filteredNat4 = useMemo(() => {
const q = nat4Search.toLowerCase().trim()
if (!q) return nat4Mappings
@@ -187,11 +277,45 @@ export default function Routing() {
</div>
<div className="grid gap-4 md:grid-cols-3">
<CapacityCard title={text.nat4Ports} watermark="NAT4" remaining={routing?.nat4.remaining || '0'} total={routing?.nat4.total || '0'} used={routing?.nat4.used || 0} label={text.remainingTotal} usedLabel={text.used} />
<CapacityCard
title={text.nat4Ports}
watermark="NAT4"
remaining={routing?.nat4.remaining || '0'}
total={routing?.nat4.total || '0'}
used={routing?.nat4.used || 0}
label={text.remainingTotal}
usedLabel={text.used}
detail={formatNATRange(nat4Range, language)}
action={
<button onClick={startEditNAT4} className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-black" title={text.editNAT4Range}>
<Pencil className="h-4 w-4" />
</button>
}
/>
<CapacityCard title={text.publicIPv4} watermark="IPv4" remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
<CapacityCard title="IPv6" watermark="IPv6" remaining={formatCapacity(routing?.ipv6.remaining || '0', language)} total={formatCapacity(routing?.ipv6.total || '0', language)} used={routing?.ipv6.used || 0} label={formatDetectedPrefixCount(ipv6Prefixes.length, language)} usedLabel={text.used} />
</div>
{editingNAT4 && (
<RouteModal title={text.editNAT4Range} onClose={() => setEditingNAT4(false)}>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<LabeledNumberInput label={text.rangeStart} value={nat4Draft.start} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, start: value }))} min={1} max={65535} />
<LabeledNumberInput label={text.rangeEnd} value={nat4Draft.end} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, end: value }))} min={1} max={65535} />
</div>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setEditingNAT4(false)} disabled={savingNAT4} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
{text.cancel}
</button>
<button onClick={saveNAT4Range} disabled={savingNAT4} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
{savingNAT4 ? text.saving : text.save}
</button>
</div>
</div>
</RouteModal>
)}
<Panel
title={text.publicIPv4Pool}
subtitle={formatIPv4PoolSubtitle(publicIPv4s.length, ipv4Assignments.length, language)}
@@ -285,7 +409,7 @@ export default function Routing() {
</thead>
<tbody className="divide-y divide-gray-100">
{ipv4Draft.map((item, index) => (
<tr key={`${item.address}-${index}`}>
<tr key={item._id}>
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv4Draft(index, { address: e.target.value })} placeholder={text.ipv4CIDR} className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv4Draft(index, { gateway: e.target.value })} placeholder={defaultIPv4Gateway || text.gateway} className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv4Draft(index, { interface: e.target.value })} placeholder={defaultIPv4Interface} className={smallInputClass} /></td>
@@ -326,8 +450,19 @@ export default function Routing() {
</RouteModal>
)}
{ipv6Prefixes.length > 0 && (
<Panel title={text.detectedIPv6Prefixes} subtitle={formatPrefixCount(ipv6Prefixes.length, language)}>
<Panel
title={text.detectedIPv6Prefixes}
subtitle={formatPrefixCount(ipv6Prefixes.length, language)}
action={
<button onClick={startEditIPv6} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
<Pencil className="h-3.5 w-3.5" />
{text.editPrefixes}
</button>
}
>
{ipv6Prefixes.length === 0 ? (
<EmptyState text={text.noIPv6Prefixes} icon={<Router className="h-7 w-7" />} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
@@ -352,7 +487,58 @@ export default function Routing() {
</tbody>
</table>
</div>
</Panel>
)}
</Panel>
{editingIPv6 && (
<RouteModal title={text.editIPv6Prefixes} onClose={() => setEditingIPv6(false)} wide>
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-sm">
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<tr>
<th className="px-3 py-2 text-left font-medium">{text.prefix}</th>
<th className="px-3 py-2 text-left font-medium">{text.hostAddress}</th>
<th className="px-3 py-2 text-left font-medium">{text.interface}</th>
<th className="px-3 py-2 text-left font-medium">{text.gateway}</th>
<th className="px-3 py-2 text-right font-medium">{text.action}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{ipv6Draft.map((item, index) => (
<tr key={item._id}>
<td className="px-3 py-2"><input value={item.prefix || ''} onChange={(e) => updateIPv6Draft(index, { prefix: e.target.value })} placeholder="2001:db8:100::/64" className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv6Draft(index, { address: e.target.value })} placeholder="2001:db8:100::1" className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv6Draft(index, { interface: e.target.value })} placeholder={defaultIPv6Interface} className={smallInputClass} /></td>
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv6Draft(index, { gateway: e.target.value })} placeholder={text.gateway} className={smallInputClass} /></td>
<td className="px-3 py-2 text-right">
<button onClick={() => setIPv6Draft((items) => items.filter((_, i) => i !== index))} className="inline-flex items-center justify-center rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600">
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
{ipv6Draft.length === 0 && <EmptyRow colSpan={5} text={text.noIPv6Prefixes} />}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<button onClick={addIPv6Row} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
<Plus className="h-3.5 w-3.5" />
{text.addIPv6Prefix}
</button>
<div className="flex items-center gap-2">
<button onClick={() => setEditingIPv6(false)} disabled={savingIPv6} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
{text.cancel}
</button>
<button onClick={saveIPv6Prefixes} disabled={savingIPv6} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
{savingIPv6 ? text.saving : text.save}
</button>
</div>
</div>
</div>
</RouteModal>
)}
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
@@ -525,7 +711,7 @@ function Pagination({ page, totalPages, totalItems, pageSize, onPageChange, lang
)
}
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel }: {
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel, detail, action }: {
title: string
watermark: string
remaining: string
@@ -533,6 +719,8 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
used: number
label: string
usedLabel: string
detail?: string
action?: ReactNode
}) {
return (
<div className="relative overflow-hidden rounded-lg border border-gray-200 bg-white p-4">
@@ -540,20 +728,46 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
{watermark}
</div>
<div className="relative z-10">
<div>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-gray-700">{title}</div>
<div className="mt-2 flex items-end gap-2">
<span className="text-2xl font-semibold text-black">{remaining}</span>
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
</div>
</div>
{action}
</div>
</div>
<div className="relative z-10 mt-3 text-xs text-gray-500">{label}</div>
<div className="relative z-10 mt-1 text-xs text-gray-400">{usedLabel} {used}</div>
{detail && <div className="relative z-10 mt-1 font-mono text-xs text-gray-400">{detail}</div>}
</div>
)
}
function LabeledNumberInput({ label, value, onChange, min, max }: {
label: string
value: number
onChange: (value: number) => void
min: number
max: number
}) {
return (
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500">{label}</span>
<input
type="number"
min={min}
max={max}
value={value || ''}
onChange={(event) => onChange(Number(event.target.value))}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:ring-1 focus:ring-black"
/>
</label>
)
}
function EmptyState({ icon, text }: { icon: ReactNode; text: string }) {
return (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
@@ -630,6 +844,11 @@ const routingText = {
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
refresh: '刷新',
nat4Ports: 'NAT4 端口',
editNAT4Range: '编辑 NAT4 范围',
rangeStart: '起始端口',
rangeEnd: '结束端口',
nat4RangeInvalid: 'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口',
saveNAT4RangeFailed: '保存 NAT4 范围失败',
remainingTotal: '剩余 / 总数',
publicIPv4: '公网 IPv4',
publicIPv4Pool: '公网 IPv4 池',
@@ -659,10 +878,17 @@ const routingText = {
save: '保存',
saving: '保存中...',
detectedIPv6Prefixes: '检测到的 IPv6 前缀',
editPrefixes: '编辑前缀',
editIPv6Prefixes: '编辑 IPv6 前缀',
addIPv6Prefix: '添加 IPv6 前缀',
noIPv6Prefixes: '暂无 IPv6 前缀',
ipv6InterfaceRequired: 'IPv6 网卡不能为空',
saveIPv6PrefixesFailed: '保存 IPv6 前缀失败',
prefix: '前缀',
hostAddress: '宿主地址',
source: '来源',
local: '本机',
manual: '手动',
ipv4NAT: 'IPv4 NAT',
searchNAT: '搜索 NAT...',
noIPv4NATMappings: '暂无 IPv4 NAT 映射',
@@ -689,6 +915,11 @@ const routingText = {
pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments',
refresh: 'Refresh',
nat4Ports: 'NAT4 ports',
editNAT4Range: 'Edit NAT4 range',
rangeStart: 'Start port',
rangeEnd: 'End port',
nat4RangeInvalid: 'NAT4 range must be 1-65535, and start cannot be greater than end',
saveNAT4RangeFailed: 'Save NAT4 range failed',
remainingTotal: 'remaining / total',
publicIPv4: 'Public IPv4',
publicIPv4Pool: 'Public IPv4 pool',
@@ -718,10 +949,17 @@ const routingText = {
save: 'Save',
saving: 'Saving...',
detectedIPv6Prefixes: 'Detected IPv6 prefixes',
editPrefixes: 'Edit prefixes',
editIPv6Prefixes: 'Edit IPv6 prefixes',
addIPv6Prefix: 'Add IPv6 prefix',
noIPv6Prefixes: 'No IPv6 prefixes',
ipv6InterfaceRequired: 'IPv6 interface is required',
saveIPv6PrefixesFailed: 'Save IPv6 prefixes failed',
prefix: 'Prefix',
hostAddress: 'Host address',
source: 'Source',
local: 'local',
manual: 'manual',
ipv4NAT: 'IPv4 NAT',
searchNAT: 'Search NAT...',
noIPv4NATMappings: 'No IPv4 NAT mappings',
@@ -761,6 +999,10 @@ function formatDetectedPrefixCount(count: number, language: Language) {
: `检测到 ${count} 个前缀`
}
function formatNATRange(range: NAT4PortRange, language: Language) {
return language === 'en' ? `range ${range.start}-${range.end}` : `范围 ${range.start}-${range.end}`
}
function formatPrefixCount(count: number, language: Language) {
return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀`
}
@@ -799,6 +1041,7 @@ function formatContainerStatus(status: string, language: Language) {
function formatSource(source: string | undefined, language: Language) {
if (!source || source === 'local') return routingText[language].local
if (source === 'manual') return routingText[language].manual
return source
}
+42 -3
View File
@@ -45,6 +45,18 @@ export interface PortMapping {
description: string
}
export interface FirewallRule {
id: string
network?: 'ipv4' | 'ipv6' | 'all'
direction: 'in' | 'out'
protocol: 'tcp' | 'udp' | 'icmp' | 'all'
port: string
source_ip: string
action: 'ACCEPT' | 'DROP'
description: string
enabled: boolean
}
export interface PublicIPv4Assignment {
address: string
interface?: string
@@ -68,6 +80,8 @@ export interface Container {
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
@@ -76,6 +90,8 @@ 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
public_ipv4s?: PublicIPv4Assignment[]
@@ -88,6 +104,9 @@ export interface Container {
ssh_password: string
port_mappings: PortMapping[]
port_mapping_limit: number
firewall_enabled: boolean
firewall_default_action: 'ACCEPT' | 'DROP'
firewall_rules: FirewallRule[]
snapshot_limit: number
created_at: string
expires_at: string
@@ -123,11 +142,15 @@ export interface CreateContainerRequest {
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
@@ -473,8 +496,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)
@@ -487,6 +514,12 @@ export const updatePortMapping = (id: ContainerIdentifier, index: number, data:
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; default_action: 'ACCEPT' | 'DROP'; rules: FirewallRule[] }>>(`/containers/${id}/firewall`)
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 })
@@ -502,6 +535,11 @@ export interface RouteCapacity {
total: string
}
export interface NAT4PortRange {
start: number
end: number
}
export interface NAT4Route {
container_id: number
container_name: string
@@ -538,6 +576,7 @@ export interface IPv6Route {
export interface RoutingInfo {
nat4: RouteCapacity
nat4_port_range: NAT4PortRange
ipv4: RouteCapacity
ipv6: RouteCapacity
host_public_ipv4?: PublicIPv4Info
@@ -557,7 +596,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[]) =>
+105 -1
View File
@@ -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)',
'下行带宽 (Mbps0=不限制)': 'Download Bandwidth (Mbps, 0=unlimited)',
'上行带宽 (Mbps0=不限制)': 'Upload Bandwidth (Mbps, 0=unlimited)',
'读取 IO (MB/s0=不限制)': 'Read IO (MB/s, 0=unlimited)',
'写入 IO (MB/s0=不限制)': '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',
@@ -578,7 +594,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 +608,7 @@ const exact: Record<string, string> = {
'管理员接口': 'Admin API',
'控制面板统计': 'Dashboard Stats',
'立即安全检查': 'Run Security Check',
'路由配置': 'Routing Configuration',
'返回响应样例': 'Response Example',
'请求参数': 'Request Parameters',
'响应字段': 'Response Fields',
@@ -616,11 +637,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 +674,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 +700,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|keykeep 仅用于重装,未传 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:writeverify=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.',
@@ -788,6 +817,75 @@ const exact: Record<string, string> = {
'50 / 页': '50 / page',
'全局快照列表,共': 'Global snapshot list, total',
'容器分配的子用户列表,共': 'Sub-user list assigned to containers, total',
'防火墙': 'Firewall',
'防火墙设置': 'Firewall Settings',
'独立 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.',
'可配置:IPv4NAT)。 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.',
'可配置:IPv4NAT)、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)',
'IPv4NAT': 'IPv4 (NAT)',
'全部网络': 'All Networks',
'方向': 'Direction',
'网络': 'Network',
'来源/目标 IP': 'Source / Destination IP',
'动作': 'Action',
'入站': 'Inbound',
'出站': 'Outbound',
'任意': 'Any',
'放行': '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',
}
const artifactPatterns: RegExp[] = [
@@ -853,6 +951,7 @@ const replacements: Array<[RegExp, string]> = [
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '],
[/(\d+)\s*个/g, '$1 items'],
[/(\d+)\s*条/g, '$1 records'],
[/1\s*核\b/g, '1 core'],
[/(\d+)\s*核/g, '$1 cores'],
[/(\d+)\s*线程/g, '$1 threads'],
[/已用/g, 'used'],
@@ -873,7 +972,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'],
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 179 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -e
echo "=============================="
echo " Certbot (Snap) Auto Installer"
echo "=============================="
# 检测系统
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VER=$VERSION_ID
else
echo "无法识别系统版本"
exit 1
fi
echo "检测到系统: $OS"
install_snap_debian() {
apt update -y
apt install -y snapd
systemctl enable --now snapd.socket || true
# 修复 snap 路径
ln -sf /var/lib/snapd/snap /snap
# 安装 certbot
snap install --classic certbot
# 软链
ln -sf /snap/bin/certbot /usr/bin/certbot
}
install_snap_rhel() {
# 启用 EPEL(部分系统需要)
if command -v dnf >/dev/null 2>&1; then
dnf install -y epel-release || true
dnf install -y snapd
systemctl enable --now snapd.socket || true
else
yum install -y epel-release || true
yum install -y snapd
systemctl enable --now snapd.socket || true
fi
# snap 经典路径
ln -sf /var/lib/snapd/snap /snap
# 安装 certbot
snap install --classic certbot
# 软链
ln -sf /snap/bin/certbot /usr/bin/certbot
}
case "$OS" in
ubuntu|debian)
install_snap_debian
;;
centos|rhel|almalinux|rocky)
install_snap_rhel
;;
fedora)
dnf install -y snapd
systemctl enable --now snapd.socket || true
ln -sf /var/lib/snapd/snap /snap
snap install --classic certbot
ln -sf /snap/bin/certbot /usr/bin/certbot
;;
*)
echo "不支持的系统: $OS"
exit 1
;;
esac
echo "=============================="
echo "安装完成!验证版本:"
certbot --version || true
echo "=============================="