mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a09aa177 | |||
| 2456b65ce2 | |||
| 292686a19a | |||
| 5ec62ca732 | |||
| a79df0d2dd | |||
| cc8fdbfede | |||
| 702d6975e5 | |||
| 84d98e40c6 | |||
| fd974d95b9 | |||
| cd258fd6ac | |||
| 0c2dd457d4 | |||
| 92e846eecc | |||
| a1d9ce8b1c | |||
| 49d8093f45 | |||
| 98ed716225 | |||
| 78276d303b | |||
| 30d2a4f4da | |||
| a54e03b924 | |||
| 4cdc6e68ba | |||
| 2ed42992ed | |||
| 4dfd7c0885 | |||
| 5ed5b4509d | |||
| 18f297b988 | |||
| d5a236943b | |||
| c54f92f892 | |||
| 86f0d079ab | |||
| 3a65d5d24a |
@@ -68,3 +68,4 @@ linux.txt
|
||||
push-release.ps1
|
||||
deploy.ps1
|
||||
backend/clicd
|
||||
api.md
|
||||
|
||||
+176
-2
@@ -10,6 +10,7 @@ README.md
|
||||
handlers/
|
||||
webssh.php
|
||||
templates/
|
||||
firewall.html
|
||||
info.html
|
||||
nat.html
|
||||
```
|
||||
@@ -93,11 +94,12 @@ Content-Type: application/json
|
||||
|
||||
## 客户区页面
|
||||
|
||||
模块提供两个客户区选项卡:
|
||||
模块提供三个客户区选项卡:
|
||||
|
||||
```text
|
||||
实例信息
|
||||
NAT转发
|
||||
防火墙
|
||||
```
|
||||
|
||||
客户区按钮提供:
|
||||
@@ -197,6 +199,65 @@ DELETE /api/v1/containers/{id}/port-mappings/{index}
|
||||
}
|
||||
```
|
||||
|
||||
## 防火墙
|
||||
|
||||
防火墙是独立客户区页面,支持:
|
||||
|
||||
- 查看防火墙启用状态、默认动作和规则列表
|
||||
- 启用 / 停用防火墙
|
||||
- 设置默认动作:未匹配拒绝或未匹配放行
|
||||
- 添加规则
|
||||
- 编辑规则
|
||||
- 删除规则
|
||||
- 单独启用 / 停用某条规则
|
||||
|
||||
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
|
||||
|
||||
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
|
||||
|
||||
使用的 CLICD API:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{id}/firewall
|
||||
PUT /api/v1/containers/{id}/firewall
|
||||
```
|
||||
|
||||
更新防火墙时必须使用 JSON 请求体,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "ACCEPT",
|
||||
"rules": [
|
||||
{
|
||||
"id": "",
|
||||
"network": "ipv4",
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"port": "22",
|
||||
"source_ip": "",
|
||||
"action": "ACCEPT",
|
||||
"description": "Allow SSH",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
|
||||
| `direction` | 方向,`in` 入站,`out` 出站 |
|
||||
| `protocol` | 协议,`tcp` 或 `udp` |
|
||||
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22`、`80,443`、`8000-9000` |
|
||||
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
|
||||
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
|
||||
| `description` | 规则描述 |
|
||||
| `enabled` | 是否启用该规则 |
|
||||
|
||||
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`。
|
||||
## WebSSH
|
||||
|
||||
WebSSH 按钮会调用:
|
||||
@@ -252,6 +313,8 @@ https://www.example.com
|
||||
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
|
||||
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
|
||||
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
|
||||
| 查询防火墙 | `GET /api/v1/containers/{id}/firewall` |
|
||||
| 更新防火墙 | `PUT /api/v1/containers/{id}/firewall` |
|
||||
| WebSSH | `POST /api/v1/ssh-ticket` |
|
||||
|
||||
## 建议 API 权限
|
||||
@@ -269,6 +332,7 @@ container:password
|
||||
container:traffic
|
||||
container:resize
|
||||
container:port
|
||||
container:firewall
|
||||
task:read
|
||||
ssh-ticket:create
|
||||
```
|
||||
@@ -316,6 +380,22 @@ curl --location --request PUT \
|
||||
--data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}'
|
||||
```
|
||||
|
||||
查询防火墙:
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: clicd_sk_xxxx" \
|
||||
https://0.0.0.0:8999/api/v1/containers/10/firewall
|
||||
```
|
||||
|
||||
更新防火墙:
|
||||
|
||||
```bash
|
||||
curl --location --request PUT \
|
||||
"https://0.0.0.0:8999/api/v1/containers/10/firewall" \
|
||||
--header "X-API-Key: clicd_sk_xxxx" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data-raw '{"enabled":true,"default_action":"ACCEPT","rules":[{"id":"","network":"ipv4","direction":"in","protocol":"tcp","port":"22","source_ip":"","action":"ACCEPT","description":"Allow SSH","enabled":true}]}'
|
||||
```
|
||||
创建 WebSSH 票据:
|
||||
|
||||
```bash
|
||||
@@ -336,6 +416,41 @@ curl --location --request POST \
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 防火墙获取提示“不支持的方法”
|
||||
|
||||
请确认模块版本已经包含防火墙页签修复。客户区防火墙列表应通过模块公开的 `firewallList` 调用,再由模块向 CLICD 发起:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{id}/firewall
|
||||
```
|
||||
|
||||
如果页面或二开代码直接把读取请求改成 `POST /api/v1/containers/{id}/firewall`,CLICD 会返回“不支持的方法”。
|
||||
|
||||
### 防火墙保存后规则为空
|
||||
|
||||
请确认更新接口最终发往 CLICD 的请求体是 JSON,并且包含 `rules` 数组。防火墙关闭时也可以保存规则,`enabled: false` 不应自动清空 `rules`。
|
||||
|
||||
正确请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"default_action": "ACCEPT",
|
||||
"rules": [
|
||||
{
|
||||
"id": "",
|
||||
"network": "ipv4",
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"port": "22",
|
||||
"source_ip": "",
|
||||
"action": "ACCEPT",
|
||||
"description": "Allow SSH",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
### 图表刚打开只有一条横线
|
||||
|
||||
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
|
||||
@@ -344,7 +459,66 @@ CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚
|
||||
|
||||
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
|
||||
|
||||
### WebSSH 打不开或提示不安全 WebSocket
|
||||
### 防火墙
|
||||
|
||||
防火墙是独立客户区页面,支持:
|
||||
|
||||
- 查看防火墙启用状态、默认动作和规则列表
|
||||
- 启用 / 停用防火墙
|
||||
- 设置默认动作:未匹配拒绝或未匹配放行
|
||||
- 添加规则
|
||||
- 编辑规则
|
||||
- 删除规则
|
||||
- 单独启用 / 停用某条规则
|
||||
|
||||
页面会先在前端修改规则列表和开关状态,点击“保存设置”后才统一同步到 CLICD。这样可以避免每次切换开关、修改默认动作或编辑规则时都立即请求后端,减少客户区卡顿。
|
||||
|
||||
注意:防火墙关闭时也可以保存规则;关闭只表示暂时不接管该容器流量,不代表规则必须清空。
|
||||
|
||||
使用的 CLICD API:
|
||||
|
||||
```text
|
||||
GET /api/v1/containers/{id}/firewall
|
||||
PUT /api/v1/containers/{id}/firewall
|
||||
```
|
||||
|
||||
更新防火墙时必须使用 JSON 请求体,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "ACCEPT",
|
||||
"rules": [
|
||||
{
|
||||
"id": "",
|
||||
"network": "ipv4",
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"port": "22",
|
||||
"source_ip": "",
|
||||
"action": "ACCEPT",
|
||||
"description": "Allow SSH",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `network` | 网络范围,常用 `ipv4`,也支持 `ipv6` / `all` |
|
||||
| `direction` | 方向,`in` 入站,`out` 出站 |
|
||||
| `protocol` | 协议,`tcp` 或 `udp` |
|
||||
| `port` | 端口,可填写单端口、逗号分隔端口或端口段,例如 `22`、`80,443`、`8000-9000` |
|
||||
| `source_ip` | 来源 IP / CIDR,留空表示任意来源 |
|
||||
| `action` | 动作,`ACCEPT` 放行,`DROP` 拒绝 |
|
||||
| `description` | 规则描述 |
|
||||
| `enabled` | 是否启用该规则 |
|
||||
|
||||
IPv4 NAT 入站规则的端口按容器内部端口匹配,不是宿主机公网端口。例如公网 `22023 -> 容器 22`,防火墙规则端口应填写 `22`。
|
||||
## WebSSH 打不开或提示不安全 WebSocket
|
||||
|
||||
请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS:
|
||||
|
||||
|
||||
+29
-8
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
use think\Db;
|
||||
|
||||
@@ -40,7 +40,7 @@ function clicd_MetaData()
|
||||
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5 and DeepSeek V4',
|
||||
'APIVersion' => '1.1',
|
||||
'HelpDoc' => 'https://github.com/MengMengCode/CLICD',
|
||||
'version' => '1.0.5',
|
||||
'version' => '1.0.11',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -365,7 +365,10 @@ function clicd_webssh_url($params, $ticket, $containerName)
|
||||
$host = parse_url($baseUrl, PHP_URL_HOST);
|
||||
$port = parse_url($baseUrl, PHP_URL_PORT);
|
||||
$wsBase = $scheme . '://' . $host . ($port ? ':' . $port : '');
|
||||
$wsUrl = $wsBase . '/api/ssh?container=' . rawurlencode((string)$containerName);
|
||||
$wsUrl = $wsBase
|
||||
. '/api/ssh?container=' . rawurlencode((string)$containerName)
|
||||
. '&container_name=' . rawurlencode((string)$containerName)
|
||||
. '&ticket=' . rawurlencode((string)$ticket);
|
||||
|
||||
$siteScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$siteHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
@@ -374,6 +377,7 @@ function clicd_webssh_url($params, $ticket, $containerName)
|
||||
return $handler
|
||||
. '?ws=' . rawurlencode($wsUrl)
|
||||
. '&protocol=' . rawurlencode('clicd-ticket.' . (string)$ticket)
|
||||
. '&ticket=' . rawurlencode((string)$ticket)
|
||||
. '&container=' . rawurlencode((string)$containerName);
|
||||
}
|
||||
|
||||
@@ -626,9 +630,24 @@ function clicd_request_value($key, $default = '')
|
||||
|
||||
function clicd_json_input()
|
||||
{
|
||||
$input = [];
|
||||
if (!empty($_POST) && is_array($_POST)) {
|
||||
$input = $_POST;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode((string)$raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
if (is_array($data)) {
|
||||
return array_merge($input, $data);
|
||||
}
|
||||
|
||||
$form = [];
|
||||
parse_str((string)$raw, $form);
|
||||
if (!empty($form) && is_array($form)) {
|
||||
return array_merge($input, $form);
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
function clicd_param_value($data, $key, $default = '')
|
||||
@@ -1404,7 +1423,13 @@ function clicd_ClientButton($params)
|
||||
|
||||
function clicd_webssh($params)
|
||||
{
|
||||
$container = [];
|
||||
$containerName = clicd_container_name($params);
|
||||
clicd_container_api_id($params, $container);
|
||||
if (!empty($container['name'])) {
|
||||
$containerName = (string)$container['name'];
|
||||
}
|
||||
|
||||
$res = clicd_request($params, '/api/v1/ssh-ticket', ['container_name' => $containerName], 'POST', 30);
|
||||
if (!clicd_success($res)) {
|
||||
return ['status' => 'error', 'msg' => clicd_message($res, 'WebSSH ticket create failed')];
|
||||
@@ -1689,7 +1714,3 @@ function clicd_ClientAreaOutput($params, $key)
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<?php
|
||||
<?php
|
||||
$ws = isset($_GET['ws']) ? (string)$_GET['ws'] : (isset($_GET['amp;ws']) ? (string)$_GET['amp;ws'] : '');
|
||||
$protocol = isset($_GET['protocol']) ? (string)$_GET['protocol'] : (isset($_GET['amp;protocol']) ? (string)$_GET['amp;protocol'] : '');
|
||||
$container = isset($_GET['container']) ? (string)$_GET['container'] : (isset($_GET['amp;container']) ? (string)$_GET['amp;container'] : '');
|
||||
$ticket = isset($_GET['ticket']) ? (string)$_GET['ticket'] : (isset($_GET['amp;ticket']) ? (string)$_GET['amp;ticket'] : '');
|
||||
|
||||
if ($ws === '' || $protocol === '') {
|
||||
if ($protocol === '' && $ticket !== '') {
|
||||
$protocol = 'clicd-ticket.' . $ticket;
|
||||
}
|
||||
|
||||
if ($ws === '') {
|
||||
http_response_code(400);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Missing WebSSH parameters\n";
|
||||
@@ -64,6 +69,7 @@ if ($ws === '' || $protocol === '') {
|
||||
(function(){
|
||||
var wsUrl = <?php echo json_encode($ws, JSON_UNESCAPED_SLASHES); ?>;
|
||||
var protocol = <?php echo json_encode($protocol, JSON_UNESCAPED_SLASHES); ?>;
|
||||
var ticket = <?php echo json_encode($ticket, JSON_UNESCAPED_SLASHES); ?>;
|
||||
var term = document.getElementById('term');
|
||||
var state = document.getElementById('state');
|
||||
var modeSelect = document.getElementById('send-mode');
|
||||
@@ -189,8 +195,17 @@ if ($ws === '' || $protocol === '') {
|
||||
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
|
||||
}
|
||||
|
||||
function websocketProtocolValue(value) {
|
||||
value = String(value || '');
|
||||
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value) ? value : '';
|
||||
}
|
||||
|
||||
try {
|
||||
socket = new WebSocket(wsUrl, protocol);
|
||||
var protocolValue = websocketProtocolValue(protocol);
|
||||
if (!protocolValue && ticket) {
|
||||
append('[WebSSH] 票据已通过 URL 参数传递,当前浏览器不会发送子协议。\n');
|
||||
}
|
||||
socket = protocolValue ? new WebSocket(wsUrl, protocolValue) : new WebSocket(wsUrl);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
} catch (e) {
|
||||
setState('err', '\nWebSocket 创建失败:' + e.message + '\n');
|
||||
|
||||
+1061
-449
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
<style>
|
||||
<style>
|
||||
.clicd-info{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
|
||||
.clicd-info *{box-sizing:border-box}
|
||||
.clicd-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:12px}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<style>
|
||||
<style>
|
||||
.clicd-nat-panel{font-size:14px;color:#1f2937}
|
||||
.clicd-nat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
|
||||
.clicd-nat-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
|
||||
</p>
|
||||
|
||||
<h1 align="center">CLICD <sub><sup>v1.1.18</sup></sub></h1>
|
||||
<h1 align="center">CLICD <sub></sub></h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||
|
||||
@@ -582,9 +582,14 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||
return
|
||||
}
|
||||
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
||||
// Try random ports
|
||||
for tries := 0; tries < 100; tries++ {
|
||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
||||
start, end := config.NATPortRange()
|
||||
capacity := end - start + 1
|
||||
offset := 0
|
||||
if capacity > 0 {
|
||||
offset = int(time.Now().UnixNano() % int64(capacity))
|
||||
}
|
||||
for tries := 0; tries < capacity; tries++ {
|
||||
port := start + ((offset + tries) % capacity)
|
||||
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||
return
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,15 +49,19 @@ type connEntry struct {
|
||||
}
|
||||
|
||||
type trafficStats struct {
|
||||
total int
|
||||
totalSynSent int
|
||||
destCounts map[string]int
|
||||
destPorts map[string]map[int]int
|
||||
portDestCounts map[int]map[string]int
|
||||
portTotalCounts map[int]int
|
||||
udpDestCounts map[int]map[string]int
|
||||
udpTotalCounts map[int]int
|
||||
synSentByDst map[string]int
|
||||
total int
|
||||
totalSynSent int
|
||||
destCounts map[string]int
|
||||
destPorts map[string]map[int]int
|
||||
portDestCounts map[int]map[string]int
|
||||
portTotalCounts map[int]int
|
||||
udpDestCounts map[int]map[string]int
|
||||
udpTotalCounts map[int]int
|
||||
udpDestTotalCounts map[string]int
|
||||
synSentByDst map[string]int
|
||||
tcpSynDestPorts map[string]map[int]int
|
||||
tcpSynPortDestCounts map[int]map[string]int
|
||||
tcpSynPortTotalCounts map[int]int
|
||||
}
|
||||
|
||||
var scanner *SecurityScanner
|
||||
@@ -232,13 +236,17 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
|
||||
|
||||
func newTrafficStats() *trafficStats {
|
||||
return &trafficStats{
|
||||
destCounts: make(map[string]int),
|
||||
destPorts: make(map[string]map[int]int),
|
||||
portDestCounts: make(map[int]map[string]int),
|
||||
portTotalCounts: make(map[int]int),
|
||||
udpDestCounts: make(map[int]map[string]int),
|
||||
udpTotalCounts: make(map[int]int),
|
||||
synSentByDst: make(map[string]int),
|
||||
destCounts: make(map[string]int),
|
||||
destPorts: make(map[string]map[int]int),
|
||||
portDestCounts: make(map[int]map[string]int),
|
||||
portTotalCounts: make(map[int]int),
|
||||
udpDestCounts: make(map[int]map[string]int),
|
||||
udpTotalCounts: make(map[int]int),
|
||||
synSentByDst: make(map[string]int),
|
||||
udpDestTotalCounts: make(map[string]int),
|
||||
tcpSynDestPorts: make(map[string]map[int]int),
|
||||
tcpSynPortDestCounts: make(map[int]map[string]int),
|
||||
tcpSynPortTotalCounts: make(map[int]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,52 +272,64 @@ func (ts *trafficStats) add(conn connEntry) {
|
||||
}
|
||||
ts.udpDestCounts[conn.dstPort][conn.dstIP]++
|
||||
ts.udpTotalCounts[conn.dstPort]++
|
||||
ts.udpDestTotalCounts[conn.dstIP]++
|
||||
}
|
||||
}
|
||||
|
||||
if conn.state == "SYN_SENT" {
|
||||
if conn.proto == "tcp" && conn.state == "SYN_SENT" {
|
||||
ts.totalSynSent++
|
||||
ts.synSentByDst[conn.dstIP]++
|
||||
if conn.dstPort > 0 {
|
||||
if ts.tcpSynDestPorts[conn.dstIP] == nil {
|
||||
ts.tcpSynDestPorts[conn.dstIP] = make(map[int]int)
|
||||
}
|
||||
ts.tcpSynDestPorts[conn.dstIP][conn.dstPort]++
|
||||
if ts.tcpSynPortDestCounts[conn.dstPort] == nil {
|
||||
ts.tcpSynPortDestCounts[conn.dstPort] = make(map[string]int)
|
||||
}
|
||||
ts.tcpSynPortDestCounts[conn.dstPort][conn.dstIP]++
|
||||
ts.tcpSynPortTotalCounts[conn.dstPort]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
|
||||
for dstIP, portCounts := range stats.destPorts {
|
||||
for dstIP, portCounts := range stats.tcpSynDestPorts {
|
||||
uniquePorts := len(portCounts)
|
||||
switch {
|
||||
case uniquePorts >= 20:
|
||||
case uniquePorts >= 25:
|
||||
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
|
||||
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
|
||||
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
|
||||
"")
|
||||
case uniquePorts >= 8:
|
||||
case uniquePorts >= 12:
|
||||
ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0,
|
||||
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
|
||||
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
|
||||
"")
|
||||
}
|
||||
}
|
||||
|
||||
for port, targets := range stats.portDestCounts {
|
||||
for port, targets := range stats.tcpSynPortDestCounts {
|
||||
uniqueTargets := len(targets)
|
||||
if service, ok := bruteForcePorts[port]; ok {
|
||||
if uniqueTargets >= 30 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
|
||||
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
"")
|
||||
} else if uniqueTargets >= 10 {
|
||||
} else if uniqueTargets >= 12 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, "*", port,
|
||||
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
|
||||
"")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if uniqueTargets >= 40 {
|
||||
if uniqueTargets >= 50 {
|
||||
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
|
||||
fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
fmt.Sprintf("横向扫描: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
"")
|
||||
} else if uniqueTargets >= 15 {
|
||||
} else if uniqueTargets >= 20 {
|
||||
ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port,
|
||||
fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
fmt.Sprintf("可疑横向探测: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
|
||||
"")
|
||||
}
|
||||
}
|
||||
@@ -323,13 +343,25 @@ func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats
|
||||
continue
|
||||
}
|
||||
|
||||
if count >= 20 {
|
||||
synCount := 0
|
||||
if ports := stats.tcpSynDestPorts[dstIP]; ports != nil {
|
||||
synCount = ports[port]
|
||||
}
|
||||
if synCount >= 25 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
|
||||
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d", service, port, count),
|
||||
fmt.Sprintf("暴力破解: %s(%d) 当前 TCP 半开连接 %d 条", service, port, synCount),
|
||||
"")
|
||||
} else if count >= 10 {
|
||||
} else if synCount >= 12 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
|
||||
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d", service, port, count),
|
||||
fmt.Sprintf("疑似暴力破解: %s(%d) 当前 TCP 半开连接 %d 条", service, port, synCount),
|
||||
"")
|
||||
} else if count >= 60 {
|
||||
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
|
||||
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
|
||||
"")
|
||||
} else if count >= 30 {
|
||||
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
|
||||
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
|
||||
"")
|
||||
}
|
||||
}
|
||||
@@ -356,30 +388,41 @@ func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) {
|
||||
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
|
||||
targets := len(stats.destCounts)
|
||||
switch {
|
||||
case targets >= 100:
|
||||
case targets >= 120 && stats.total >= 600:
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets),
|
||||
fmt.Sprintf("大规模对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
|
||||
"")
|
||||
case targets >= 35:
|
||||
case targets >= 60 && stats.total >= 300:
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
|
||||
fmt.Sprintf("大量对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
|
||||
"")
|
||||
}
|
||||
|
||||
synTargets := len(stats.synSentByDst)
|
||||
switch {
|
||||
case stats.total >= 500:
|
||||
case stats.totalSynSent >= 250 || (synTargets >= 80 && stats.totalSynSent >= 160):
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total),
|
||||
fmt.Sprintf("大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
|
||||
"")
|
||||
case stats.total >= 200:
|
||||
case stats.totalSynSent >= 100 || (synTargets >= 35 && stats.totalSynSent >= 70):
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("高连接数: 当前 conntrack 出站记录 %d 条", stats.total),
|
||||
fmt.Sprintf("可疑大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
|
||||
"")
|
||||
}
|
||||
|
||||
if stats.totalSynSent >= 100 {
|
||||
udpTargets := len(stats.udpDestTotalCounts)
|
||||
udpTotal := 0
|
||||
for _, count := range stats.udpTotalCounts {
|
||||
udpTotal += count
|
||||
}
|
||||
switch {
|
||||
case udpTargets >= 120 && udpTotal >= 300:
|
||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
||||
fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent),
|
||||
fmt.Sprintf("UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
|
||||
"")
|
||||
case udpTargets >= 50 && udpTotal >= 120:
|
||||
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||
fmt.Sprintf("可疑 UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
|
||||
"")
|
||||
}
|
||||
|
||||
@@ -404,11 +447,18 @@ func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *traffic
|
||||
continue
|
||||
}
|
||||
|
||||
if targets >= 30 || total >= 100 {
|
||||
criticalTargets, criticalTotal := 40, 120
|
||||
highTargets, highTotal := 15, 45
|
||||
if port == 53 {
|
||||
criticalTargets, criticalTotal = 75, 300
|
||||
highTargets, highTotal = 25, 100
|
||||
}
|
||||
|
||||
if targets >= criticalTargets && total >= criticalTotal {
|
||||
ss.addAlert(name, "reflection", "critical", ip, "*", port,
|
||||
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
||||
"")
|
||||
} else if targets >= 10 || total >= 30 {
|
||||
} else if targets >= highTargets && total >= highTotal {
|
||||
ss.addAlert(name, "reflection", "high", ip, "*", port,
|
||||
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
||||
"")
|
||||
@@ -645,6 +695,9 @@ func severityRank(severity string) int {
|
||||
}
|
||||
|
||||
func autoShutdownAlertContainer(containerName, alertType, severity string) {
|
||||
if !config.AppConfig.SecurityAutoShutdown {
|
||||
return
|
||||
}
|
||||
c := config.FindContainerByName(containerName)
|
||||
if c == nil || c.Status != "running" {
|
||||
return
|
||||
@@ -660,6 +713,24 @@ func autoShutdownAlertContainer(containerName, alertType, severity string) {
|
||||
}
|
||||
}
|
||||
|
||||
func clearSecurityPolicyBlocks() int {
|
||||
cleared := 0
|
||||
for i := range config.AppConfig.Containers {
|
||||
c := &config.AppConfig.Containers[i]
|
||||
if !c.PolicyBlocked || !isSecurityPolicyBlockReason(c.PolicyBlockedReason) {
|
||||
continue
|
||||
}
|
||||
config.SetContainerPolicyBlock(c.ID, false, "")
|
||||
config.AddAuditLog("security_policy_unblock", c.Name, "关闭安全告警自动关机后解除策略临时封禁", "system")
|
||||
cleared++
|
||||
}
|
||||
return cleared
|
||||
}
|
||||
|
||||
func isSecurityPolicyBlockReason(reason string) bool {
|
||||
return strings.Contains(reason, "告警触发策略临时封禁")
|
||||
}
|
||||
|
||||
// HandleSecurityAlerts returns all security alerts.
|
||||
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -699,9 +770,17 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
cancelledTasks := 0
|
||||
clearedBlocks := 0
|
||||
if !req.AutoShutdown {
|
||||
cancelledTasks = globalQueue.CancelPendingSecurityStops()
|
||||
clearedBlocks = clearSecurityPolicyBlocks()
|
||||
}
|
||||
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||
"cancelled_tasks": cancelledTasks,
|
||||
"cleared_blocks": clearedBlocks,
|
||||
}})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"clicd/internal/config"
|
||||
)
|
||||
|
||||
func TestDetectReflectionAbuseIgnoresSingleDNSResolver(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
stats := newTrafficStats()
|
||||
for i := 0; i < 180; i++ {
|
||||
stats.add(connEntry{
|
||||
dstIP: "1.1.1.1",
|
||||
dstPort: 53,
|
||||
proto: "udp",
|
||||
state: "UNREPLIED",
|
||||
})
|
||||
}
|
||||
|
||||
ss := newSecurityScanner()
|
||||
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
|
||||
|
||||
if len(ss.alerts) != 0 {
|
||||
t.Fatalf("normal DNS queries to one resolver should not trigger reflection alert: %+v", ss.alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReflectionAbuseFlagsWideDNSFanout(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
stats := newTrafficStats()
|
||||
for i := 0; i < 120; i++ {
|
||||
stats.add(connEntry{
|
||||
dstIP: fmt.Sprintf("203.0.113.%d", i),
|
||||
dstPort: 53,
|
||||
proto: "udp",
|
||||
state: "UNREPLIED",
|
||||
})
|
||||
}
|
||||
|
||||
ss := newSecurityScanner()
|
||||
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
|
||||
|
||||
if len(ss.alerts) != 1 {
|
||||
t.Fatalf("expected one reflection alert, got %+v", ss.alerts)
|
||||
}
|
||||
if got := ss.alerts[0].Type; got != "reflection" {
|
||||
t.Fatalf("expected reflection alert, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPortScansUsesHalfOpenConnections(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
established := newTrafficStats()
|
||||
for port := 8000; port < 8020; port++ {
|
||||
established.add(connEntry{
|
||||
dstIP: "198.51.100.10",
|
||||
dstPort: port,
|
||||
proto: "tcp",
|
||||
state: "ESTABLISHED",
|
||||
})
|
||||
}
|
||||
|
||||
ss := newSecurityScanner()
|
||||
ss.detectPortScans("ct-web", "10.0.0.3", established)
|
||||
if len(ss.alerts) != 0 {
|
||||
t.Fatalf("established multi-port connections should not trigger port scan alert: %+v", ss.alerts)
|
||||
}
|
||||
|
||||
halfOpen := newTrafficStats()
|
||||
for port := 8000; port < 8012; port++ {
|
||||
halfOpen.add(connEntry{
|
||||
dstIP: "198.51.100.10",
|
||||
dstPort: port,
|
||||
proto: "tcp",
|
||||
state: "SYN_SENT",
|
||||
})
|
||||
}
|
||||
|
||||
ss.detectPortScans("ct-web", "10.0.0.3", halfOpen)
|
||||
if len(ss.alerts) != 1 {
|
||||
t.Fatalf("expected one port scan alert, got %+v", ss.alerts)
|
||||
}
|
||||
if got := ss.alerts[0].Type; got != "port_scan" {
|
||||
t.Fatalf("expected port_scan alert, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPendingSecurityStops(t *testing.T) {
|
||||
resetSecurityTestConfig()
|
||||
|
||||
q := &TaskQueue{
|
||||
tasks: map[string]*Task{},
|
||||
}
|
||||
securityTask := &Task{
|
||||
ID: "task-1",
|
||||
Type: TaskStop,
|
||||
ContainerID: 1,
|
||||
Status: "pending",
|
||||
User: "system:security",
|
||||
}
|
||||
userTask := &Task{
|
||||
ID: "task-2",
|
||||
Type: TaskStop,
|
||||
ContainerID: 2,
|
||||
Status: "pending",
|
||||
User: "admin",
|
||||
}
|
||||
runningSecurityTask := &Task{
|
||||
ID: "task-3",
|
||||
Type: TaskStop,
|
||||
ContainerID: 3,
|
||||
Status: "running",
|
||||
User: "system:security",
|
||||
}
|
||||
q.tasks[securityTask.ID] = securityTask
|
||||
q.tasks[userTask.ID] = userTask
|
||||
q.tasks[runningSecurityTask.ID] = runningSecurityTask
|
||||
q.opQueue = []*Task{securityTask, userTask, runningSecurityTask}
|
||||
|
||||
if got := q.CancelPendingSecurityStops(); got != 1 {
|
||||
t.Fatalf("expected one pending security stop to be cancelled, got %d", got)
|
||||
}
|
||||
if _, ok := q.tasks[securityTask.ID]; ok {
|
||||
t.Fatal("pending security stop task was not removed")
|
||||
}
|
||||
if _, ok := q.tasks[userTask.ID]; !ok {
|
||||
t.Fatal("user stop task should not be removed")
|
||||
}
|
||||
if _, ok := q.tasks[runningSecurityTask.ID]; !ok {
|
||||
t.Fatal("running security stop task should be left for worker-side skip")
|
||||
}
|
||||
if len(q.opQueue) != 2 {
|
||||
t.Fatalf("expected op queue to keep two tasks, got %d", len(q.opQueue))
|
||||
}
|
||||
}
|
||||
|
||||
func resetSecurityTestConfig() {
|
||||
config.AppConfig = &config.ClicdConfig{
|
||||
Containers: []config.Container{},
|
||||
AuditLogs: []config.AuditLog{},
|
||||
Tasks: []config.SavedTask{},
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,10 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
||||
}
|
||||
|
||||
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
|
||||
if !config.AppConfig.SecurityAutoShutdown {
|
||||
return "", false
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
@@ -230,6 +234,34 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
|
||||
return taskID, true
|
||||
}
|
||||
|
||||
func (q *TaskQueue) CancelPendingSecurityStops() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
cancelled := 0
|
||||
newOpQueue := make([]*Task, 0, len(q.opQueue))
|
||||
for _, task := range q.opQueue {
|
||||
if isSecurityStopTask(task) && task.Status == "pending" {
|
||||
delete(q.tasks, task.ID)
|
||||
cancelled++
|
||||
continue
|
||||
}
|
||||
newOpQueue = append(newOpQueue, task)
|
||||
}
|
||||
q.opQueue = newOpQueue
|
||||
|
||||
for id, task := range q.tasks {
|
||||
if isSecurityStopTask(task) && task.Status == "pending" {
|
||||
delete(q.tasks, id)
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
if cancelled > 0 {
|
||||
q.persistTasks()
|
||||
}
|
||||
return cancelled
|
||||
}
|
||||
|
||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
||||
// If a restored task already has a same-name container in config, it resumes
|
||||
// initialization instead of creating another ct-{id}.
|
||||
@@ -324,6 +356,7 @@ func (q *TaskQueue) opWorker() {
|
||||
q.mu.Unlock()
|
||||
|
||||
var err error
|
||||
skipped := false
|
||||
err = resolveTaskContainer(task)
|
||||
// Block operations on expired or traffic-exceeded containers (except stop/delete)
|
||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||
@@ -336,27 +369,32 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||
skipped = true
|
||||
}
|
||||
if err == nil {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||
if !skipped {
|
||||
switch task.Type {
|
||||
case TaskStart:
|
||||
err = startByRuntime(task.ContainerID)
|
||||
case TaskStop:
|
||||
err = stopByRuntime(task.ContainerID)
|
||||
case TaskRestart:
|
||||
err = restartByRuntime(task.ContainerID)
|
||||
case TaskDelete:
|
||||
err = destroyByRuntime(task.ContainerID)
|
||||
if err == nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if config.FindContainer(task.ContainerID) != nil {
|
||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
if lxc.HasSSHAuthOptions(task.Config) {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||
} else {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
case TaskReinstall:
|
||||
if lxc.HasSSHAuthOptions(task.Config) {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||
} else {
|
||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +408,9 @@ func (q *TaskQueue) opWorker() {
|
||||
task.Status = "failed"
|
||||
task.Error = err.Error()
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||
} else if skipped {
|
||||
task.Status = "done"
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
||||
} else {
|
||||
task.Status = "done"
|
||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||
@@ -391,6 +432,10 @@ func (q *TaskQueue) opWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
func isSecurityStopTask(task *Task) bool {
|
||||
return task != nil && task.Type == TaskStop && task.User == "system:security"
|
||||
}
|
||||
|
||||
func clearPolicyBlockAfterAdminRecovery(task *Task) {
|
||||
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
|
||||
return
|
||||
@@ -817,6 +862,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
// RestoreTasks restores task queue from config
|
||||
func RestoreTasks() {
|
||||
for _, st := range config.AppConfig.Tasks {
|
||||
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
|
||||
continue
|
||||
}
|
||||
var cfg lxc.ContainerConfig
|
||||
if st.Config != "" {
|
||||
json.Unmarshal([]byte(st.Config), &cfg)
|
||||
|
||||
@@ -372,6 +372,8 @@ type ClicdConfig struct {
|
||||
NextContainerID int `json:"next_container_id"`
|
||||
NextVNCPort int `json:"next_vnc_port"`
|
||||
NextSSHPort int `json:"next_ssh_port"`
|
||||
NATPortStart int `json:"nat_port_start"`
|
||||
NATPortEnd int `json:"nat_port_end"`
|
||||
SetupComplete bool `json:"setup_complete"`
|
||||
SubUsers []SubUser `json:"sub_users"`
|
||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||
@@ -394,6 +396,11 @@ var AppConfig *ClicdConfig
|
||||
|
||||
const DefaultSnapshotLimit = 3
|
||||
|
||||
const (
|
||||
DefaultNATPortStart = 20000
|
||||
DefaultNATPortEnd = 65535
|
||||
)
|
||||
|
||||
func getConfigPath() string {
|
||||
if configPath != "" {
|
||||
return configPath
|
||||
@@ -509,6 +516,8 @@ func InitConfig() (*ClicdConfig, error) {
|
||||
NextContainerID: 1,
|
||||
NextVNCPort: 5900,
|
||||
NextSSHPort: 22000,
|
||||
NATPortStart: DefaultNATPortStart,
|
||||
NATPortEnd: DefaultNATPortEnd,
|
||||
SetupComplete: false,
|
||||
SubUsers: []SubUser{},
|
||||
AuditLogs: []AuditLog{},
|
||||
@@ -552,6 +561,9 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.NextSSHPort = 22000
|
||||
changed = true
|
||||
}
|
||||
if normalizeNATPortRangeDefaults() {
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.NextContainerID == 0 {
|
||||
AppConfig.NextContainerID = 1
|
||||
changed = true
|
||||
@@ -1168,16 +1180,102 @@ func UpdateVNC(containers []Container) {
|
||||
SaveConfig()
|
||||
}
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() int {
|
||||
used := collectAllHostPorts()
|
||||
port := AppConfig.NextSSHPort
|
||||
for used[port] {
|
||||
port++
|
||||
func NormalizeNATPortRange(start, end int) (int, int, error) {
|
||||
if start == 0 && end == 0 {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd, nil
|
||||
}
|
||||
AppConfig.NextSSHPort = port + 1
|
||||
SaveConfig()
|
||||
return port
|
||||
if start == 0 {
|
||||
start = DefaultNATPortStart
|
||||
}
|
||||
if end == 0 {
|
||||
end = DefaultNATPortEnd
|
||||
}
|
||||
if start < 1 || start > 65535 {
|
||||
return 0, 0, fmt.Errorf("NAT port start must be 1-65535")
|
||||
}
|
||||
if end < 1 || end > 65535 {
|
||||
return 0, 0, fmt.Errorf("NAT port end must be 1-65535")
|
||||
}
|
||||
if start > end {
|
||||
return 0, 0, fmt.Errorf("NAT port start cannot be greater than end")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func NATPortRange() (int, int) {
|
||||
if AppConfig == nil {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
|
||||
if err != nil {
|
||||
return DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func NATPortCapacity() int {
|
||||
start, end := NATPortRange()
|
||||
return end - start + 1
|
||||
}
|
||||
|
||||
func NATPortInRange(port int) bool {
|
||||
start, end := NATPortRange()
|
||||
return port >= start && port <= end
|
||||
}
|
||||
|
||||
func SetNATPortRange(start, end int) error {
|
||||
start, end, err := NormalizeNATPortRange(start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
AppConfig.NATPortStart = start
|
||||
AppConfig.NATPortEnd = end
|
||||
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
return SaveConfig()
|
||||
}
|
||||
|
||||
func normalizeNATPortRangeDefaults() bool {
|
||||
if AppConfig == nil {
|
||||
return false
|
||||
}
|
||||
start, end, err := NormalizeNATPortRange(AppConfig.NATPortStart, AppConfig.NATPortEnd)
|
||||
if err != nil {
|
||||
start, end = DefaultNATPortStart, DefaultNATPortEnd
|
||||
}
|
||||
changed := AppConfig.NATPortStart != start || AppConfig.NATPortEnd != end
|
||||
AppConfig.NATPortStart = start
|
||||
AppConfig.NATPortEnd = end
|
||||
if AppConfig.NextSSHPort < start || AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||
func AllocateSSHPort() (int, error) {
|
||||
used := collectAllHostPorts()
|
||||
start, end := NATPortRange()
|
||||
port := AppConfig.NextSSHPort
|
||||
if port < start || port > end {
|
||||
port = start
|
||||
}
|
||||
capacity := end - start + 1
|
||||
for i := 0; i < capacity; i++ {
|
||||
candidate := start + ((port - start + i) % capacity)
|
||||
if used[candidate] {
|
||||
continue
|
||||
}
|
||||
AppConfig.NextSSHPort = candidate + 1
|
||||
if AppConfig.NextSSHPort > end {
|
||||
AppConfig.NextSSHPort = start
|
||||
}
|
||||
SaveConfig()
|
||||
return candidate, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no free NAT4 host port in configured range %d-%d", start, end)
|
||||
}
|
||||
|
||||
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -524,6 +524,8 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
NextContainerID: atoi(meta["next_container_id"]),
|
||||
NextVNCPort: atoi(meta["next_vnc_port"]),
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
NATPortStart: atoi(meta["nat_port_start"]),
|
||||
NATPortEnd: atoi(meta["nat_port_end"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
Language: meta["language"],
|
||||
@@ -651,6 +653,8 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
|
||||
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"nat_port_start": strconv.Itoa(AppConfig.NATPortStart),
|
||||
"nat_port_end": strconv.Itoa(AppConfig.NATPortEnd),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"language": NormalizeLanguage(AppConfig.Language),
|
||||
|
||||
+86
-25
@@ -454,7 +454,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
||||
}
|
||||
winAdminPassword = generateWindowsPassword()
|
||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil {
|
||||
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, mac, ipv6List, ipv4List); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||
@@ -487,7 +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{{
|
||||
@@ -1680,7 +1683,7 @@ func createEmptyDisk(target string, diskGB int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string, ipv4s []string) error {
|
||||
func createWindowsUnattendISO(target, hostname, adminPassword, mac string, ipv6s []string, ipv4s []string) error {
|
||||
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
||||
if tool == "" {
|
||||
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
||||
@@ -1706,13 +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, ipv4s)), 0600); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, mac, ipv6s, ipv4s)), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
@@ -1822,7 +1825,7 @@ exit /b 0
|
||||
`
|
||||
}
|
||||
|
||||
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []string) string {
|
||||
func windowsFirstLogonPowerShell(adminPassword, mac string, ipv6s []string, ipv4s []string) string {
|
||||
commands := []string{
|
||||
"$ErrorActionPreference='Continue'",
|
||||
"$ProgressPreference='SilentlyContinue'",
|
||||
@@ -1832,9 +1835,9 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
|
||||
"net user Administrator " + shellQuoteWindows(adminPassword) + " /active:yes",
|
||||
"Set-LocalUser -Name 'Administrator' -PasswordNeverExpires $true -ErrorAction SilentlyContinue",
|
||||
"Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force",
|
||||
"$iface=$null",
|
||||
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
|
||||
"$iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1",
|
||||
windowsAdapterDiscoveryPowerShell(mac),
|
||||
"$iface=Wait-ClicdNetworkAdapter",
|
||||
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
|
||||
"if ($iface) { Set-NetIPInterface -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -Dhcp Enabled -ErrorAction SilentlyContinue }",
|
||||
"if ($iface) { Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ResetServerAddresses -ErrorAction SilentlyContinue }",
|
||||
"Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue",
|
||||
@@ -1852,17 +1855,23 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
|
||||
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
|
||||
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
|
||||
}
|
||||
networkCommands := []string{}
|
||||
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||
if len(ipv6s) > 0 {
|
||||
commands = append(commands,
|
||||
windowsIPv6PowerShell(ipv6s),
|
||||
)
|
||||
networkCommands = append(networkCommands, windowsIPv6PowerShell(ipv6s, mac))
|
||||
}
|
||||
ipv4s = normalizeKVMIPv4List(ipv4s)
|
||||
if len(ipv4s) > 0 {
|
||||
commands = append(commands,
|
||||
windowsIPv4PowerShell(ipv4s),
|
||||
)
|
||||
networkCommands = append(networkCommands, windowsIPv4PowerShell(ipv4s, mac))
|
||||
}
|
||||
if len(networkCommands) > 0 {
|
||||
networkScript := strings.Join(append([]string{
|
||||
"$ErrorActionPreference='Continue'",
|
||||
"$ProgressPreference='SilentlyContinue'",
|
||||
"New-Item -ItemType Directory -Force -Path 'C:\\CLICD' | Out-Null",
|
||||
}, networkCommands...), "\r\n") + "\r\n"
|
||||
commands = append(commands, windowsPersistentNetworkTaskPowerShell(networkScript))
|
||||
commands = append(commands, networkCommands...)
|
||||
}
|
||||
commands = append(commands,
|
||||
"New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null",
|
||||
@@ -1871,18 +1880,58 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
|
||||
return strings.Join(commands, "\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
func windowsIPv6PowerShell(ipv6s []string) string {
|
||||
func windowsPersistentNetworkTaskPowerShell(script string) string {
|
||||
return strings.Join([]string{
|
||||
"$clicdNetworkScript=@'",
|
||||
strings.TrimRight(script, "\r\n"),
|
||||
"'@",
|
||||
"Set-Content -Path 'C:\\CLICD\\ApplyNetwork.ps1' -Value $clicdNetworkScript -Encoding UTF8",
|
||||
"$clicdNetworkAction=New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\\CLICD\\ApplyNetwork.ps1'",
|
||||
"$clicdNetworkTrigger=New-ScheduledTaskTrigger -AtStartup",
|
||||
"Register-ScheduledTask -TaskName 'CLICD Network Init' -Action $clicdNetworkAction -Trigger $clicdNetworkTrigger -RunLevel Highest -Force -ErrorAction SilentlyContinue | Out-Null",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
func windowsAdapterDiscoveryPowerShell(mac string) string {
|
||||
targetMAC := strings.ToUpper(strings.NewReplacer(":", "", "-", "", " ", "").Replace(strings.TrimSpace(mac)))
|
||||
return strings.Join([]string{
|
||||
"$clicdTargetMac=" + powerShellSingleQuote(targetMAC),
|
||||
"function Get-ClicdNetworkAdapter {",
|
||||
" $adapters=@(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Disabled' })",
|
||||
" if ($clicdTargetMac) {",
|
||||
" $matched=$adapters | Where-Object { (($_.MacAddress -replace '[-:]','').ToUpperInvariant()) -eq $clicdTargetMac } | Sort-Object ifIndex | Select-Object -First 1",
|
||||
" if ($matched) { return $matched }",
|
||||
" }",
|
||||
" $up=$adapters | Where-Object { $_.Status -eq 'Up' } | Sort-Object ifIndex | Select-Object -First 1",
|
||||
" if ($up) { return $up }",
|
||||
" return $adapters | Sort-Object ifIndex | Select-Object -First 1",
|
||||
"}",
|
||||
"function Wait-ClicdNetworkAdapter {",
|
||||
" param([int]$Retries=90,[int]$DelaySeconds=4)",
|
||||
" for ($i=0; $i -lt $Retries; $i++) {",
|
||||
" $adapter=Get-ClicdNetworkAdapter",
|
||||
" if ($adapter) { return $adapter }",
|
||||
" Start-Sleep -Seconds $DelaySeconds",
|
||||
" }",
|
||||
" return $null",
|
||||
"}",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
func windowsIPv6PowerShell(ipv6s []string, mac string) string {
|
||||
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||
if len(ipv6s) == 0 {
|
||||
return ""
|
||||
}
|
||||
quoted := make([]string, 0, len(ipv6s))
|
||||
for _, ipv6 := range ipv6s {
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
|
||||
quoted = append(quoted, powerShellSingleQuote(ipv6))
|
||||
}
|
||||
return strings.Join([]string{
|
||||
windowsAdapterDiscoveryPowerShell(mac),
|
||||
"if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }",
|
||||
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
|
||||
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
|
||||
// Reuse $iface already found by the main script
|
||||
"if ($iface) {",
|
||||
" foreach ($ip in $clicdIPv6) {",
|
||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||
@@ -1895,18 +1944,20 @@ func windowsIPv6PowerShell(ipv6s []string) string {
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
func windowsIPv4PowerShell(ipv4s []string) string {
|
||||
func windowsIPv4PowerShell(ipv4s []string, mac string) string {
|
||||
ipv4s = normalizeKVMIPv4List(ipv4s)
|
||||
if len(ipv4s) == 0 {
|
||||
return ""
|
||||
}
|
||||
quoted := make([]string, 0, len(ipv4s))
|
||||
for _, ipv4 := range ipv4s {
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'")
|
||||
quoted = append(quoted, powerShellSingleQuote(ipv4))
|
||||
}
|
||||
return strings.Join([]string{
|
||||
windowsAdapterDiscoveryPowerShell(mac),
|
||||
"if (-not $iface) { $iface=Wait-ClicdNetworkAdapter }",
|
||||
"if ($iface) { Enable-NetAdapter -Name $iface.Name -Confirm:$false -ErrorAction SilentlyContinue; Start-Sleep -Seconds 2; $iface=Get-ClicdNetworkAdapter }",
|
||||
"$clicdIPv4=@(" + strings.Join(quoted, ",") + ")",
|
||||
// Reuse $iface already found by the main script
|
||||
"if ($iface) {",
|
||||
" foreach ($ip in $clicdIPv4) {",
|
||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||
@@ -1930,6 +1981,10 @@ func normalizeKVMIPv4List(values []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func powerShellSingleQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||
}
|
||||
|
||||
func shellQuoteWindows(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||
}
|
||||
@@ -2354,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
|
||||
@@ -3488,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)
|
||||
}
|
||||
|
||||
@@ -3855,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)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status {
|
||||
}
|
||||
|
||||
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
|
||||
if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 {
|
||||
return configured
|
||||
}
|
||||
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
|
||||
}
|
||||
|
||||
|
||||
@@ -381,7 +381,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||
sshPort := 0
|
||||
portMappings := []config.PortMapping{}
|
||||
if cfg.WantsNAT() {
|
||||
sshPort = config.AllocateSSHPort()
|
||||
sshPort, err = config.AllocateSSHPort()
|
||||
if err != nil {
|
||||
_ = m.cleanupContainerStorage(lxcName)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup default port mappings (SSH only)
|
||||
portMappings = SetupDefaultPortMappings(sshPort)
|
||||
@@ -1013,12 +1017,99 @@ func findSeccompProfile() (string, error) {
|
||||
"/etc/lxc/common.seccomp",
|
||||
} {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, nil
|
||||
return ensureCVE202643499SeccompProfile(path)
|
||||
}
|
||||
}
|
||||
return "", errors.New("required LXC seccomp profile not found")
|
||||
}
|
||||
|
||||
const clicdSeccompProfileDir = "/var/lib/clicd/security/seccomp"
|
||||
const clicdCVE202643499SeccompProfile = clicdSeccompProfileDir + "/lxc-cve-2026-43499.profile"
|
||||
|
||||
var cve202643499FutexSeccompRules = []string{
|
||||
"# clicd managed: mitigate CVE-2026-43499 from LXC guests by blocking PI futex operations",
|
||||
"futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0x7,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0x8,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
"futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||
}
|
||||
|
||||
func ensureCVE202643499SeccompProfile(basePath string) (string, error) {
|
||||
data, err := os.ReadFile(basePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read LXC seccomp profile: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !isLXCVDenylistSeccompProfile(content) {
|
||||
return "", fmt.Errorf("LXC seccomp profile %s is not a v2 denylist profile; cannot apply CVE-2026-43499 futex mitigation safely", basePath)
|
||||
}
|
||||
if err := os.MkdirAll(clicdSeccompProfileDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create CLICD seccomp directory: %v", err)
|
||||
}
|
||||
hardened := appendMissingSeccompRules(content, cve202643499FutexSeccompRules)
|
||||
if err := os.WriteFile(clicdCVE202643499SeccompProfile, []byte(hardened), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write CLICD seccomp profile: %v", err)
|
||||
}
|
||||
return clicdCVE202643499SeccompProfile, nil
|
||||
}
|
||||
|
||||
func isLXCVDenylistSeccompProfile(content string) bool {
|
||||
lines := nonCommentSeccompLines(content)
|
||||
return len(lines) >= 2 && lines[0] == "2" && isSeccompDenylistPolicy(lines[1])
|
||||
}
|
||||
|
||||
func isSeccompDenylistPolicy(line string) bool {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return false
|
||||
}
|
||||
return fields[0] == "denylist" || fields[0] == "blacklist"
|
||||
}
|
||||
|
||||
func appendMissingSeccompRules(content string, rules []string) string {
|
||||
trimmed := strings.TrimRight(content, "\r\n")
|
||||
existing := map[string]bool{}
|
||||
for _, line := range strings.Split(trimmed, "\n") {
|
||||
line = strings.TrimSpace(stripSeccompLineComment(line))
|
||||
if line != "" {
|
||||
existing[line] = true
|
||||
}
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(trimmed)
|
||||
for _, rule := range rules {
|
||||
key := strings.TrimSpace(stripSeccompLineComment(rule))
|
||||
if key != "" && existing[key] {
|
||||
continue
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(rule)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func nonCommentSeccompLines(content string) []string {
|
||||
lines := make([]string, 0)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(stripSeccompLineComment(line))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func stripSeccompLineComment(line string) string {
|
||||
if idx := strings.Index(line, "#"); idx >= 0 {
|
||||
return line[:idx]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func findAppArmorProfile() (string, error) {
|
||||
data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles")
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +395,10 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
||||
if pm.HostPort <= 0 {
|
||||
pm.HostPort = pm.ContainerPort
|
||||
}
|
||||
if pm.HostIP == "" && !config.NATPortInRange(pm.HostPort) {
|
||||
start, end := config.NATPortRange()
|
||||
return pm, fmt.Errorf("host port must be within configured NAT4 range %d-%d", start, end)
|
||||
}
|
||||
// Check current container's own mappings
|
||||
for i, existing := range c.PortMappings {
|
||||
if i == skipIndex {
|
||||
@@ -444,16 +448,12 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, count)
|
||||
next := 20000
|
||||
for len(ports) < count {
|
||||
start, end := config.NATPortRange()
|
||||
for next := start; next <= end && len(ports) < count; next++ {
|
||||
hostIP := c.PrimaryPublicIPv4()
|
||||
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
||||
ports = append(ports, next)
|
||||
}
|
||||
next++
|
||||
if next > 65535 || len(ports) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.19"
|
||||
Version = "1.1.21"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -110,6 +110,13 @@ export default defineConfig({
|
||||
head: [
|
||||
['link', { rel: 'icon', href: '/favicon.svg' }],
|
||||
],
|
||||
vite: {
|
||||
esbuild: {
|
||||
supported: {
|
||||
destructuring: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
locales: {
|
||||
root: {
|
||||
label: '简体中文',
|
||||
|
||||
+194
-10
@@ -53,7 +53,11 @@ Create container example:
|
||||
"ssh_auth_mode": "auto_password",
|
||||
"ssh_password": "",
|
||||
"ssh_public_key": "",
|
||||
"expires_at": ""
|
||||
"expires_at": "",
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
@@ -71,6 +75,12 @@ Field notes:
|
||||
| `ssh_auth_mode` | Linux creation supports `auto_password`, `password`, and `key`; reinstall also supports `keep`. |
|
||||
| `ssh_password` | Custom password for `password` mode. It must be 8-64 characters, include letters and digits, and contain no whitespace. |
|
||||
| `ssh_public_key` | One-line SSH public key for `key` mode. |
|
||||
| `network_down_mbps` | Optional container download/downlink bandwidth limit in Mbps. `0` means unlimited. |
|
||||
| `network_up_mbps` | Optional container upload/uplink bandwidth limit in Mbps. `0` means unlimited. |
|
||||
| `io_read_mbps` | Optional disk read limit in MB/s. `0` means unlimited. |
|
||||
| `io_write_mbps` | Optional disk write limit in MB/s. `0` means unlimited. |
|
||||
| `network_bw_mbps` | Legacy-compatible field. Sets symmetric downlink/uplink bandwidth; new integrations should prefer the split fields. |
|
||||
| `io_speed_mbps` | Legacy-compatible field. Sets symmetric read/write I/O limits; new integrations should prefer the split fields. |
|
||||
|
||||
Reinstall example:
|
||||
|
||||
@@ -85,6 +95,102 @@ Reinstall example:
|
||||
|
||||
`keep` is only for reinstall and keeps the current SSH password. Windows KVM images ignore Linux SSH public key fields.
|
||||
|
||||
## Resource and Traffic Limits
|
||||
|
||||
`PUT /api/v1/containers/{id}/resource-limit` supports partial updates. Fields omitted from the request remain unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"vcpu": 2,
|
||||
"ram_mb": 1024,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
Legacy `network_bw_mbps` and `io_speed_mbps` are still accepted. They mean symmetric downlink/uplink bandwidth and symmetric read/write I/O limits. New integrations should use the split fields to control download/upload and read/write independently.
|
||||
|
||||
`PUT /api/v1/containers/{id}/traffic-limit` request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"traffic_mode": "total",
|
||||
"monthly_traffic_gb": 1024,
|
||||
"traffic_in_gb": 0,
|
||||
"traffic_out_gb": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `traffic_mode` | Traffic limit mode. Common values are `total` for a shared total limit and `split` for separate inbound/outbound limits. |
|
||||
| `monthly_traffic_gb` | Monthly total traffic quota for `total` mode, in GB. `0` means unlimited. |
|
||||
| `traffic_in_gb` | Monthly inbound quota for `split` mode, in GB. `0` means unlimited. |
|
||||
| `traffic_out_gb` | Monthly outbound quota for `split` mode, in GB. `0` means unlimited. |
|
||||
|
||||
## Container Firewall
|
||||
|
||||
Read container firewall settings with `GET /api/v1/containers/{id}/firewall` and update them with `PUT /api/v1/containers/{id}/firewall`. Updates are applied immediately when the container is running.
|
||||
|
||||
Update example:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"action": "ACCEPT",
|
||||
"network": "ipv4",
|
||||
"source_ip": "203.0.113.0/24",
|
||||
"port": "22,80,443",
|
||||
"description": "allow admin and web"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `enabled` | Whether the container firewall is enabled. |
|
||||
| `default_action` | Default action: `ACCEPT` or `DROP`. |
|
||||
| `rules[].id` | Optional. Omit for new rules and the backend will generate one. |
|
||||
| `rules[].direction` | Direction: `in` or `out`. |
|
||||
| `rules[].protocol` | Protocol: `tcp`, `udp`, `icmp`, or `all`. |
|
||||
| `rules[].action` | Action: `ACCEPT` or `DROP`. |
|
||||
| `rules[].network` | Network type: `ipv4`, `ipv6`, or `all`. |
|
||||
| `rules[].source_ip` | Optional source IP, CIDR, or address range. |
|
||||
| `rules[].port` | Optional. Supported only for `tcp`/`udp`; examples: `22`, `80,443`, or `8000-9000`. |
|
||||
| `rules[].description` | Optional note. |
|
||||
|
||||
## API Key Create and Update
|
||||
|
||||
`POST /api/v1/api-keys` and `PATCH /api/v1/api-keys/{id}` use the same field shape. `name` is required when creating a key; updates overwrite the fields you send.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Automation",
|
||||
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
|
||||
"scopes": ["dashboard:read", "container:read", "container:power"],
|
||||
"expires_at": "2026-12-31 23:59:59",
|
||||
"disabled": false,
|
||||
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `name` | API key name. Required when creating a key. |
|
||||
| `ip_whitelist` | Optional allowed source IPs/CIDRs, comma-separated. Empty means no IP restriction. |
|
||||
| `scopes` | Optional permission scopes. If omitted, the default read-only scopes are used. `*` grants all permissions. |
|
||||
| `expires_at` | Optional expiration time. Empty means no expiration. |
|
||||
| `disabled` | Whether this key is disabled. |
|
||||
| `container_uuids` | Optional container allowlist that limits the key to specific containers. |
|
||||
|
||||
## Python Example
|
||||
|
||||
Fetch containers:
|
||||
@@ -140,6 +246,7 @@ print(resp.json())
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard` | Dashboard statistics |
|
||||
| GET | `/api/v1/host-info` | Host resources |
|
||||
| GET | `/api/v1/host-report` | Host inspection report |
|
||||
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing |
|
||||
| PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools |
|
||||
| POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment |
|
||||
@@ -151,10 +258,11 @@ print(resp.json())
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers` | Container list |
|
||||
| GET | `/api/v1/containers` | Container list (recommended) |
|
||||
| GET | `/api/v1/containers/list` | Compatible GET form for container list |
|
||||
| POST | `/api/v1/containers/list` | Compatible POST form for container list |
|
||||
| POST | `/api/v1/containers` | Create container |
|
||||
| GET | `/api/v1/containers/{id|uuid|name}` | Container details |
|
||||
| GET | `/api/v1/containers/{id\|uuid\|name}` | Container details |
|
||||
| POST | `/api/v1/containers/{id}/start` | Start |
|
||||
| POST | `/api/v1/containers/{id}/stop` | Stop |
|
||||
| POST | `/api/v1/containers/{id}/restart` | Restart |
|
||||
@@ -173,10 +281,12 @@ print(resp.json())
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers/{id}/random-port` | Random available port |
|
||||
| GET | `/api/v1/containers/{id}/random-port` | Random available port; accepts `host_ip` to check a specific host IP |
|
||||
| POST | `/api/v1/containers/{id}/port-mappings` | Add port mapping |
|
||||
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping |
|
||||
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | Delete port mapping |
|
||||
| GET | `/api/v1/containers/{id}/firewall` | Get container firewall settings |
|
||||
| PUT | `/api/v1/containers/{id}/firewall` | Update container firewall settings |
|
||||
| GET | `/api/v1/snapshots` | Snapshot overview |
|
||||
| GET | `/api/v1/containers/{id}/snapshots` | Container snapshots |
|
||||
| POST | `/api/v1/containers/{id}/snapshots` | Create snapshot |
|
||||
@@ -191,6 +301,7 @@ print(resp.json())
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/templates` | Template list |
|
||||
| GET | `/api/v1/images` | Image management list |
|
||||
| GET | `/api/v1/images/enabled` | Enabled and downloaded images; supports `type=lxc\|kvm` |
|
||||
| POST | `/api/v1/images/download` | Download image |
|
||||
| POST | `/api/v1/images/cancel` | Cancel image download |
|
||||
| DELETE | `/api/v1/images/delete` | Delete image cache |
|
||||
@@ -203,6 +314,12 @@ print(resp.json())
|
||||
| PUT | `/api/v1/security/settings` | Update security settings |
|
||||
| GET | `/api/v1/swap` | Swap information |
|
||||
| POST | `/api/v1/swap` | Adjust Swap |
|
||||
| GET | `/api/v1/language` | Current panel language |
|
||||
| POST/PUT | `/api/v1/language` | Update panel language |
|
||||
| GET | `/api/v1/ssl` | SSL settings (requires admin permission / `admin:access`) |
|
||||
| PUT | `/api/v1/ssl` | Update SSL settings (requires admin permission / `admin:access`) |
|
||||
| GET | `/api/v1/webssh-origins` | WebSSH Origin allowlist (requires admin permission / `admin:access`) |
|
||||
| PUT | `/api/v1/webssh-origins` | Update WebSSH Origin allowlist (requires admin permission / `admin:access`) |
|
||||
| POST | `/api/v1/batch-create` | Batch create containers |
|
||||
| POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall |
|
||||
| POST | `/api/v1/ssh-ticket` | Create WebSSH ticket |
|
||||
@@ -255,6 +372,16 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/host-report": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"generated_at": "2026-06-12 10:00:00",
|
||||
"summary": { "status": "ok", "warnings": 0 },
|
||||
"host": { "hostname": "node-1", "kernel": "6.8.0" },
|
||||
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
|
||||
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/routing": {
|
||||
"success": true,
|
||||
"data": {
|
||||
@@ -331,6 +458,10 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
"vcpu": 1,
|
||||
"ram_mb": 512,
|
||||
"disk_gb": 10,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80,
|
||||
"status": "running",
|
||||
"ip": "10.0.0.10",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
@@ -343,6 +474,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
}
|
||||
]
|
||||
},
|
||||
"GET /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
@@ -410,7 +547,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
"success": true,
|
||||
"data": {
|
||||
"mode": "total",
|
||||
"limit_gb": 0,
|
||||
"limit_gb": 1024,
|
||||
"in_limit_gb": 0,
|
||||
"out_limit_gb": 0,
|
||||
"total_used_bytes": 142082,
|
||||
@@ -453,7 +590,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/containers/{id}/random-port": {
|
||||
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
|
||||
"success": true,
|
||||
"data": { "port": 61320 }
|
||||
},
|
||||
@@ -474,6 +611,21 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"message": "Firewall updated",
|
||||
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
|
||||
},
|
||||
"GET /api/v1/snapshots": {
|
||||
"success": true,
|
||||
"data": null
|
||||
@@ -539,6 +691,12 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/images/enabled?type=lxc": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/images/download": {
|
||||
"success": true,
|
||||
"message": "Already downloaded"
|
||||
@@ -585,9 +743,35 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
},
|
||||
"POST /api/v1/swap": {
|
||||
"success": true,
|
||||
"message": "SWAP 已调整为 16384 MB",
|
||||
"message": "SWAP adjusted to 16384 MB",
|
||||
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||
},
|
||||
"GET /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "zh" }
|
||||
},
|
||||
"PUT /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "en" }
|
||||
},
|
||||
"GET /api/v1/ssl": {
|
||||
"success": true,
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
|
||||
},
|
||||
"PUT /api/v1/ssl": {
|
||||
"success": true,
|
||||
"message": "SSL settings saved",
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
|
||||
},
|
||||
"GET /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"PUT /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"message": "Origin allowlist saved",
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"POST /api/v1/batch-create": {
|
||||
"success": true,
|
||||
"data": ["task-12"]
|
||||
@@ -654,17 +838,17 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
||||
"GET /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
|
||||
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"message": "API key created. Save this key now - it won't be shown again.",
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"PATCH /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"DELETE /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
|
||||
+193
-9
@@ -53,7 +53,11 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
|
||||
"ssh_auth_mode": "auto_password",
|
||||
"ssh_password": "",
|
||||
"ssh_public_key": "",
|
||||
"expires_at": ""
|
||||
"expires_at": "",
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
@@ -71,6 +75,12 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
|
||||
| `ssh_auth_mode` | Linux 创建支持 `auto_password`、`password`、`key`;重装额外支持 `keep`。 |
|
||||
| `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 |
|
||||
| `ssh_public_key` | `key` 模式下的一行 SSH 公钥。 |
|
||||
| `network_down_mbps` | 可选;容器下行/下载带宽限制,单位 Mbps,`0` 表示不限制。 |
|
||||
| `network_up_mbps` | 可选;容器上行/上传带宽限制,单位 Mbps,`0` 表示不限制。 |
|
||||
| `io_read_mbps` | 可选;磁盘读取限速,单位 MB/s,`0` 表示不限制。 |
|
||||
| `io_write_mbps` | 可选;磁盘写入限速,单位 MB/s,`0` 表示不限制。 |
|
||||
| `network_bw_mbps` | 兼容旧字段;同时设置上下行对称带宽,新接入推荐使用拆分字段。 |
|
||||
| `io_speed_mbps` | 兼容旧字段;同时设置读写对称 IO 限速,新接入推荐使用拆分字段。 |
|
||||
|
||||
重装示例:
|
||||
|
||||
@@ -85,6 +95,102 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
|
||||
|
||||
`keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。
|
||||
|
||||
## 资源限制与流量限制
|
||||
|
||||
`PUT /api/v1/containers/{id}/resource-limit` 支持按字段局部更新;未传的字段保持不变。
|
||||
|
||||
```json
|
||||
{
|
||||
"vcpu": 2,
|
||||
"ram_mb": 1024,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80
|
||||
}
|
||||
```
|
||||
|
||||
旧版 `network_bw_mbps` 和 `io_speed_mbps` 仍可用,分别表示上下行对称带宽和读写对称 IO 限速。新接入建议使用拆分字段,以便分别控制下载/上传和读取/写入。
|
||||
|
||||
`PUT /api/v1/containers/{id}/traffic-limit` 请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"traffic_mode": "total",
|
||||
"monthly_traffic_gb": 1024,
|
||||
"traffic_in_gb": 0,
|
||||
"traffic_out_gb": 0
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `traffic_mode` | 流量限制模式;常用 `total` 表示总量限制,`split` 表示入站/出站分别限制。 |
|
||||
| `monthly_traffic_gb` | `total` 模式下的月总流量额度,单位 GB;`0` 表示不限制。 |
|
||||
| `traffic_in_gb` | `split` 模式下的月入站额度,单位 GB;`0` 表示不限制。 |
|
||||
| `traffic_out_gb` | `split` 模式下的月出站额度,单位 GB;`0` 表示不限制。 |
|
||||
|
||||
## 容器防火墙
|
||||
|
||||
容器防火墙通过 `GET /api/v1/containers/{id}/firewall` 读取,通过 `PUT /api/v1/containers/{id}/firewall` 更新。容器运行中更新时会立即应用规则。
|
||||
|
||||
更新示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{
|
||||
"direction": "in",
|
||||
"protocol": "tcp",
|
||||
"action": "ACCEPT",
|
||||
"network": "ipv4",
|
||||
"source_ip": "203.0.113.0/24",
|
||||
"port": "22,80,443",
|
||||
"description": "allow admin and web"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `enabled` | 是否启用容器防火墙。 |
|
||||
| `default_action` | 默认动作:`ACCEPT` 或 `DROP`。 |
|
||||
| `rules[].id` | 可选;新规则可省略,后端会自动生成。 |
|
||||
| `rules[].direction` | 方向:`in` 或 `out`。 |
|
||||
| `rules[].protocol` | 协议:`tcp`、`udp`、`icmp` 或 `all`。 |
|
||||
| `rules[].action` | 动作:`ACCEPT` 或 `DROP`。 |
|
||||
| `rules[].network` | 网络类型:`ipv4`、`ipv6` 或 `all`。 |
|
||||
| `rules[].source_ip` | 可选;源 IP、CIDR 或地址范围。 |
|
||||
| `rules[].port` | 可选;仅 `tcp`/`udp` 支持,可写 `22`、`80,443` 或 `8000-9000`。 |
|
||||
| `rules[].description` | 可选备注。 |
|
||||
|
||||
## API Key 创建与更新
|
||||
|
||||
`POST /api/v1/api-keys` 和 `PATCH /api/v1/api-keys/{id}` 使用相同的字段结构。创建时 `name` 必填;更新时根据需要覆盖字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Automation",
|
||||
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
|
||||
"scopes": ["dashboard:read", "container:read", "container:power"],
|
||||
"expires_at": "2026-12-31 23:59:59",
|
||||
"disabled": false,
|
||||
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `name` | API Key 名称;创建时必填。 |
|
||||
| `ip_whitelist` | 可选;允许的来源 IP/CIDR,多个值用逗号分隔;空值表示不限制。 |
|
||||
| `scopes` | 可选;权限范围。省略时使用默认只读范围,传 `*` 表示全部权限。 |
|
||||
| `expires_at` | 可选;过期时间,空值表示不过期。 |
|
||||
| `disabled` | 是否禁用该 Key。 |
|
||||
| `container_uuids` | 可选;限制该 Key 只能访问指定容器。 |
|
||||
|
||||
## Python 示例
|
||||
|
||||
获取容器列表:
|
||||
@@ -140,6 +246,7 @@ print(resp.json())
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/dashboard` | 控制面板统计 |
|
||||
| GET | `/api/v1/host-info` | 主机资源 |
|
||||
| GET | `/api/v1/host-report` | 主机巡检报告 |
|
||||
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 |
|
||||
| PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 |
|
||||
| POST | `/api/v1/routing/ipv4-scan` | 扫描公网 IPv4 段 |
|
||||
@@ -151,10 +258,11 @@ print(resp.json())
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers` | 容器列表 |
|
||||
| GET | `/api/v1/containers` | 容器列表(推荐) |
|
||||
| GET | `/api/v1/containers/list` | 容器列表兼容 GET 写法 |
|
||||
| POST | `/api/v1/containers/list` | 容器列表兼容 POST 写法 |
|
||||
| POST | `/api/v1/containers` | 创建容器 |
|
||||
| GET | `/api/v1/containers/{id|uuid|name}` | 容器详情 |
|
||||
| GET | `/api/v1/containers/{id\|uuid\|name}` | 容器详情 |
|
||||
| POST | `/api/v1/containers/{id}/start` | 开机 |
|
||||
| POST | `/api/v1/containers/{id}/stop` | 关机 |
|
||||
| POST | `/api/v1/containers/{id}/restart` | 重启 |
|
||||
@@ -173,10 +281,12 @@ print(resp.json())
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口 |
|
||||
| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口;可传 `host_ip` 查询指定宿主机 IP |
|
||||
| POST | `/api/v1/containers/{id}/port-mappings` | 添加端口映射 |
|
||||
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | 更新端口映射 |
|
||||
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | 删除端口映射 |
|
||||
| GET | `/api/v1/containers/{id}/firewall` | 获取容器防火墙设置 |
|
||||
| PUT | `/api/v1/containers/{id}/firewall` | 更新容器防火墙设置 |
|
||||
| GET | `/api/v1/snapshots` | 快照总览 |
|
||||
| GET | `/api/v1/containers/{id}/snapshots` | 容器快照 |
|
||||
| POST | `/api/v1/containers/{id}/snapshots` | 创建快照 |
|
||||
@@ -191,6 +301,7 @@ print(resp.json())
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/templates` | 模板列表 |
|
||||
| GET | `/api/v1/images` | 镜像管理列表 |
|
||||
| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` |
|
||||
| POST | `/api/v1/images/download` | 下载镜像 |
|
||||
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
|
||||
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
|
||||
@@ -203,6 +314,12 @@ print(resp.json())
|
||||
| PUT | `/api/v1/security/settings` | 更新安全设置 |
|
||||
| GET | `/api/v1/swap` | Swap 信息 |
|
||||
| POST | `/api/v1/swap` | 调整 Swap |
|
||||
| GET | `/api/v1/language` | 当前面板语言 |
|
||||
| POST/PUT | `/api/v1/language` | 更新面板语言 |
|
||||
| GET | `/api/v1/ssl` | SSL 设置(需管理员权限 / `admin:access`) |
|
||||
| PUT | `/api/v1/ssl` | 更新 SSL 设置(需管理员权限 / `admin:access`) |
|
||||
| GET | `/api/v1/webssh-origins` | WebSSH Origin 白名单(需管理员权限 / `admin:access`) |
|
||||
| PUT | `/api/v1/webssh-origins` | 更新 WebSSH Origin 白名单(需管理员权限 / `admin:access`) |
|
||||
| POST | `/api/v1/batch-create` | 批量创建容器 |
|
||||
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
|
||||
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
|
||||
@@ -255,6 +372,16 @@ print(resp.json())
|
||||
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/host-report": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"generated_at": "2026-06-12 10:00:00",
|
||||
"summary": { "status": "ok", "warnings": 0 },
|
||||
"host": { "hostname": "node-1", "kernel": "6.8.0" },
|
||||
"resources": { "cpu_cores": 8, "ram_total_mb": 31825, "disk_total_gb": 1750.49 },
|
||||
"network": { "public_ipv4": "203.0.113.10", "public_ipv6": "2001:db8:100::2" }
|
||||
}
|
||||
},
|
||||
"GET /api/v1/routing": {
|
||||
"success": true,
|
||||
"data": {
|
||||
@@ -331,6 +458,10 @@ print(resp.json())
|
||||
"vcpu": 1,
|
||||
"ram_mb": 512,
|
||||
"disk_gb": 10,
|
||||
"network_down_mbps": 100,
|
||||
"network_up_mbps": 50,
|
||||
"io_read_mbps": 120,
|
||||
"io_write_mbps": 80,
|
||||
"status": "running",
|
||||
"ip": "10.0.0.10",
|
||||
"ipv6": "2001:db8:100::1005",
|
||||
@@ -343,6 +474,12 @@ print(resp.json())
|
||||
}
|
||||
]
|
||||
},
|
||||
"GET /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": 5, "uuid": "00000000-0000-4000-8000-000000000005", "name": "example-vm", "status": "running", "ip": "10.0.0.10" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/containers/list": {
|
||||
"success": true,
|
||||
"data": [
|
||||
@@ -410,7 +547,7 @@ print(resp.json())
|
||||
"success": true,
|
||||
"data": {
|
||||
"mode": "total",
|
||||
"limit_gb": 0,
|
||||
"limit_gb": 1024,
|
||||
"in_limit_gb": 0,
|
||||
"out_limit_gb": 0,
|
||||
"total_used_bytes": 142082,
|
||||
@@ -453,7 +590,7 @@ print(resp.json())
|
||||
|
||||
```json
|
||||
{
|
||||
"GET /api/v1/containers/{id}/random-port": {
|
||||
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
|
||||
"success": true,
|
||||
"data": { "port": 61320 }
|
||||
},
|
||||
@@ -474,6 +611,21 @@ print(resp.json())
|
||||
"success": true,
|
||||
"data": []
|
||||
},
|
||||
"GET /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"default_action": "DROP",
|
||||
"rules": [
|
||||
{ "id": "a1b2c3d4", "direction": "in", "protocol": "tcp", "action": "ACCEPT", "network": "ipv4", "source_ip": "203.0.113.0/24", "port": "22,80,443", "description": "allow admin and web" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"PUT /api/v1/containers/{id}/firewall": {
|
||||
"success": true,
|
||||
"message": "Firewall updated",
|
||||
"data": { "enabled": true, "default_action": "DROP", "rules": [] }
|
||||
},
|
||||
"GET /api/v1/snapshots": {
|
||||
"success": true,
|
||||
"data": null
|
||||
@@ -539,6 +691,12 @@ print(resp.json())
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
|
||||
]
|
||||
},
|
||||
"GET /api/v1/images/enabled?type=lxc": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "variant": "default", "description": "Ubuntu 24.04 LTS", "type": "lxc" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/images/download": {
|
||||
"success": true,
|
||||
"message": "Already downloaded"
|
||||
@@ -588,6 +746,32 @@ print(resp.json())
|
||||
"message": "SWAP 已调整为 16384 MB",
|
||||
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
||||
},
|
||||
"GET /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "zh" }
|
||||
},
|
||||
"PUT /api/v1/language": {
|
||||
"success": true,
|
||||
"data": { "language": "en" }
|
||||
},
|
||||
"GET /api/v1/ssl": {
|
||||
"success": true,
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "detected_host": "panel.example.com", "needs_restart": false }
|
||||
},
|
||||
"PUT /api/v1/ssl": {
|
||||
"success": true,
|
||||
"message": "SSL settings saved",
|
||||
"data": { "enabled": true, "mode": "self-signed", "target": "panel.example.com", "needs_restart": true }
|
||||
},
|
||||
"GET /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"PUT /api/v1/webssh-origins": {
|
||||
"success": true,
|
||||
"message": "Origin allowlist saved",
|
||||
"data": { "origins": ["https://panel.example.com"], "current_origin": "https://panel.example.com" }
|
||||
},
|
||||
"POST /api/v1/batch-create": {
|
||||
"success": true,
|
||||
"data": ["task-12"]
|
||||
@@ -654,17 +838,17 @@ print(resp.json())
|
||||
"GET /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"data": [
|
||||
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
|
||||
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "expires_at": "", "disabled": false, "container_uuids": [], "last_used_ip": "198.51.100.23" }
|
||||
]
|
||||
},
|
||||
"POST /api/v1/api-keys": {
|
||||
"success": true,
|
||||
"message": "API key created. Save this key now - it won't be shown again.",
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "ip_whitelist": "198.51.100.23", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"PATCH /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
|
||||
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "expires_at": "2026-12-31 23:59:59", "disabled": false, "container_uuids": ["00000000-0000-4000-8000-000000000005"] }
|
||||
},
|
||||
"DELETE /api/v1/api-keys/{id}": {
|
||||
"success": true,
|
||||
|
||||
Generated
+107
-107
@@ -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
@@ -11,6 +11,7 @@
|
||||
"vitepress": "^1.6.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "6.4.2"
|
||||
"vite": "6.4.2",
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "clicd-frontend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.19",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.5.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -1372,16 +1372,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.21",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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' : ''
|
||||
|
||||
@@ -914,6 +914,7 @@ const responseSamples: Record<string, unknown> = {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
ipv6: { used: 31, remaining: 'large', total: 'large' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
@@ -927,6 +928,8 @@ const responseSamples: Record<string, unknown> = {
|
||||
'PUT /api/v1/routing': {
|
||||
success: true,
|
||||
data: {
|
||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||
nat4_port_range: { start: 20000, end: 65535 },
|
||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
|
||||
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
|
||||
@@ -1196,7 +1199,7 @@ function endpointNoteFor(key: string) {
|
||||
notes.push('When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.')
|
||||
}
|
||||
if (key === 'PUT /api/v1/routing') {
|
||||
notes.push('Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
|
||||
notes.push('Updating NAT4 port range and public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
|
||||
}
|
||||
if (key === 'POST /api/v1/routing/ipv4-scan') {
|
||||
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||
|
||||
@@ -89,8 +89,12 @@ type MetricPoint = {
|
||||
ts: number
|
||||
cpu: number
|
||||
memory: number
|
||||
network: number
|
||||
diskIO: number
|
||||
network?: number
|
||||
networkRx?: number
|
||||
networkTx?: number
|
||||
diskIO?: number
|
||||
diskRead?: number
|
||||
diskWrite?: number
|
||||
}
|
||||
type MappingDraft = {
|
||||
index: number | null
|
||||
@@ -214,15 +218,21 @@ export default function ContainerDetail() {
|
||||
const memoryPct = memoryTotalBytes > 0
|
||||
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
||||
: 0
|
||||
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
||||
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
|
||||
const networkRx = nextUsage.network_rx_bps || 0
|
||||
const networkTx = nextUsage.network_tx_bps || 0
|
||||
const diskRead = nextUsage.disk_read_bps || 0
|
||||
const diskWrite = nextUsage.disk_write_bps || 0
|
||||
|
||||
const point: MetricPoint = {
|
||||
ts: Date.now(),
|
||||
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
||||
memory: clamp(memoryPct),
|
||||
network: networkBps,
|
||||
diskIO: diskIOBps,
|
||||
network: networkRx + networkTx,
|
||||
networkRx,
|
||||
networkTx,
|
||||
diskIO: diskRead + diskWrite,
|
||||
diskRead,
|
||||
diskWrite,
|
||||
}
|
||||
|
||||
setHistory((prev) => {
|
||||
@@ -907,20 +917,23 @@ export default function ContainerDetail() {
|
||||
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
|
||||
const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0
|
||||
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
|
||||
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
|
||||
const rx = usage?.network_rx_bps || 0
|
||||
const networkRxBps = usage?.network_rx_bps || 0
|
||||
const networkTxBps = usage?.network_tx_bps || 0
|
||||
const networkBps = networkRxBps + networkTxBps
|
||||
const networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps)
|
||||
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
|
||||
const netPct = Math.max(
|
||||
directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000),
|
||||
directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 125000, 125000000),
|
||||
directionUsagePercent(networkRxBps, networkDownLimit, 125000, 125000000),
|
||||
directionUsagePercent(networkTxBps, networkUpLimit, 125000, 125000000),
|
||||
)
|
||||
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
|
||||
const diskReadBps = usage?.disk_read_bps || 0
|
||||
const diskWriteBps = usage?.disk_write_bps || 0
|
||||
const diskIOBps = diskReadBps + diskWriteBps
|
||||
const ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps)
|
||||
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
|
||||
const diskIOPct = Math.max(
|
||||
directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||
directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||
directionUsagePercent(diskReadBps, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||
directionUsagePercent(diskWriteBps, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||
)
|
||||
const mappingCount = container.port_mappings?.length || 0
|
||||
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||
@@ -967,16 +980,24 @@ export default function ContainerDetail() {
|
||||
icon: <Network className="w-5 h-5" />,
|
||||
current: networkBps,
|
||||
points: toChartPoints(filtered, 'network'),
|
||||
series: [
|
||||
{ label: '入', points: toChartPoints(filtered, 'networkRx'), current: networkRxBps, color: '#2563eb' },
|
||||
{ label: '出', points: toChartPoints(filtered, 'networkTx'), current: networkTxBps, color: '#16a34a' },
|
||||
],
|
||||
formatValue: formatRate,
|
||||
detail: `入 ${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`,
|
||||
detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)},限速占用 ${netPct.toFixed(1)}%,累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`,
|
||||
},
|
||||
{
|
||||
title: '磁盘IO',
|
||||
icon: <HardDrive className="w-5 h-5" />,
|
||||
current: diskIOBps,
|
||||
points: toChartPoints(filtered, 'diskIO'),
|
||||
series: [
|
||||
{ label: '读', points: toChartPoints(filtered, 'diskRead'), current: diskReadBps, color: '#d97706' },
|
||||
{ label: '写', points: toChartPoints(filtered, 'diskWrite'), current: diskWriteBps, color: '#dc2626' },
|
||||
],
|
||||
formatValue: formatRate,
|
||||
detail: `读 ${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`,
|
||||
detail: `读 ${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)},限速占用 ${diskIOPct.toFixed(1)}%,累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2514,8 +2535,11 @@ function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLa
|
||||
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
|
||||
}
|
||||
|
||||
function toChartPoints<T extends keyof Omit<MetricPoint, 'ts'>>(history: MetricPoint[], key: T): ChartPoint[] {
|
||||
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
|
||||
function toChartPoints(history: MetricPoint[], key: keyof Omit<MetricPoint, 'ts'>): ChartPoint[] {
|
||||
return history.flatMap((point) => {
|
||||
const value = Number(point[key])
|
||||
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.19</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.21</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||
import {
|
||||
getRoutingInfo,
|
||||
updateRoutingIPv6Prefixes,
|
||||
updateRoutingIPv4Pool,
|
||||
updateRoutingPools,
|
||||
type IPv4Route,
|
||||
type IPv6Route,
|
||||
type IPv6PrefixInfo,
|
||||
type NAT4PortRange,
|
||||
type NAT4Route,
|
||||
type PublicIPv4Info,
|
||||
type RoutingInfo,
|
||||
@@ -25,6 +29,12 @@ export default function Routing() {
|
||||
const [savingIPv4, setSavingIPv4] = useState(false)
|
||||
const [ipv4Draft, setIPv4Draft] = useState<(PublicIPv4Info & { _id: number })[]>([])
|
||||
const nextDraftId = useRef(0)
|
||||
const [editingNAT4, setEditingNAT4] = useState(false)
|
||||
const [savingNAT4, setSavingNAT4] = useState(false)
|
||||
const [nat4Draft, setNAT4Draft] = useState<NAT4PortRange>({ start: 20000, end: 65535 })
|
||||
const [editingIPv6, setEditingIPv6] = useState(false)
|
||||
const [savingIPv6, setSavingIPv6] = useState(false)
|
||||
const [ipv6Draft, setIPv6Draft] = useState<(IPv6PrefixInfo & { _id: number })[]>([])
|
||||
const [nat4Page, setNat4Page] = useState(1)
|
||||
const [ipv6Page, setIPv6Page] = useState(1)
|
||||
const [nat4Search, setNat4Search] = useState('')
|
||||
@@ -49,9 +59,12 @@ export default function Routing() {
|
||||
const nat4Mappings = routing?.nat4_mappings || []
|
||||
const ipv6Prefixes = routing?.ipv6_prefixes || []
|
||||
const ipv6Assignments = routing?.ipv6_assignments || []
|
||||
const nat4Range = routing?.nat4_port_range || { start: 20000, end: 65535 }
|
||||
const defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0'
|
||||
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
|
||||
const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32
|
||||
const defaultIPv6Interface = ipv6Prefixes[0]?.interface || defaultIPv4Interface
|
||||
const defaultIPv6Gateway = ipv6Prefixes[0]?.gateway || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingIPv4) {
|
||||
@@ -139,6 +152,81 @@ export default function Routing() {
|
||||
}
|
||||
}
|
||||
|
||||
const startEditNAT4 = () => {
|
||||
setNAT4Draft({ start: nat4Range.start || 20000, end: nat4Range.end || 65535 })
|
||||
setEditingNAT4(true)
|
||||
}
|
||||
|
||||
const saveNAT4Range = async () => {
|
||||
const start = Math.round(Number(nat4Draft.start || 0))
|
||||
const end = Math.round(Number(nat4Draft.end || 0))
|
||||
if (start < 1 || start > 65535 || end < 1 || end > 65535 || start > end) {
|
||||
alert(text.nat4RangeInvalid)
|
||||
return
|
||||
}
|
||||
setSavingNAT4(true)
|
||||
try {
|
||||
const res = await updateRoutingPools({ nat4_port_range: { start, end } })
|
||||
setRouting(res.data.data || null)
|
||||
setEditingNAT4(false)
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || text.saveNAT4RangeFailed)
|
||||
} finally {
|
||||
setSavingNAT4(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditIPv6 = () => {
|
||||
setIPv6Draft(ipv6Prefixes.map((prefix) => ({ ...prefix, _id: nextDraftId.current++ })))
|
||||
setEditingIPv6(true)
|
||||
}
|
||||
|
||||
const addIPv6Row = () => {
|
||||
setIPv6Draft((items) => [
|
||||
...items,
|
||||
{
|
||||
_id: nextDraftId.current++,
|
||||
prefix: '',
|
||||
address: '',
|
||||
prefix_len: 64,
|
||||
interface: defaultIPv6Interface,
|
||||
gateway: defaultIPv6Gateway,
|
||||
source: 'manual',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const updateIPv6Draft = (index: number, patch: Partial<IPv6PrefixInfo>) => {
|
||||
setIPv6Draft((items) => items.map((item, i) => (i === index ? { ...item, ...patch } : item)))
|
||||
}
|
||||
|
||||
const saveIPv6Prefixes = async () => {
|
||||
setSavingIPv6(true)
|
||||
try {
|
||||
const items = ipv6Draft
|
||||
.map(({ _id, ...item }) => ({
|
||||
...item,
|
||||
prefix: (item.prefix || '').trim(),
|
||||
address: (item.address || '').trim(),
|
||||
interface: (item.interface || defaultIPv6Interface).trim(),
|
||||
gateway: (item.gateway || '').trim(),
|
||||
prefix_len: Number(item.prefix_len || 0),
|
||||
}))
|
||||
.filter((item) => item.prefix || item.address)
|
||||
if (items.some((item) => !item.interface)) {
|
||||
alert(text.ipv6InterfaceRequired)
|
||||
return
|
||||
}
|
||||
const res = await updateRoutingIPv6Prefixes(items)
|
||||
setRouting(res.data.data || null)
|
||||
setEditingIPv6(false)
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.message || text.saveIPv6PrefixesFailed)
|
||||
} finally {
|
||||
setSavingIPv6(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredNat4 = useMemo(() => {
|
||||
const q = nat4Search.toLowerCase().trim()
|
||||
if (!q) return nat4Mappings
|
||||
@@ -189,11 +277,45 @@ export default function Routing() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<CapacityCard title={text.nat4Ports} watermark="NAT4" remaining={routing?.nat4.remaining || '0'} total={routing?.nat4.total || '0'} used={routing?.nat4.used || 0} label={text.remainingTotal} usedLabel={text.used} />
|
||||
<CapacityCard
|
||||
title={text.nat4Ports}
|
||||
watermark="NAT4"
|
||||
remaining={routing?.nat4.remaining || '0'}
|
||||
total={routing?.nat4.total || '0'}
|
||||
used={routing?.nat4.used || 0}
|
||||
label={text.remainingTotal}
|
||||
usedLabel={text.used}
|
||||
detail={formatNATRange(nat4Range, language)}
|
||||
action={
|
||||
<button onClick={startEditNAT4} className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-black" title={text.editNAT4Range}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CapacityCard title={text.publicIPv4} watermark="IPv4" remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
|
||||
<CapacityCard title="IPv6" watermark="IPv6" remaining={formatCapacity(routing?.ipv6.remaining || '0', language)} total={formatCapacity(routing?.ipv6.total || '0', language)} used={routing?.ipv6.used || 0} label={formatDetectedPrefixCount(ipv6Prefixes.length, language)} usedLabel={text.used} />
|
||||
</div>
|
||||
|
||||
{editingNAT4 && (
|
||||
<RouteModal title={text.editNAT4Range} onClose={() => setEditingNAT4(false)}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<LabeledNumberInput label={text.rangeStart} value={nat4Draft.start} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, start: value }))} min={1} max={65535} />
|
||||
<LabeledNumberInput label={text.rangeEnd} value={nat4Draft.end} onChange={(value) => setNAT4Draft((draft) => ({ ...draft, end: value }))} min={1} max={65535} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setEditingNAT4(false)} disabled={savingNAT4} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button onClick={saveNAT4Range} disabled={savingNAT4} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{savingNAT4 ? text.saving : text.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</RouteModal>
|
||||
)}
|
||||
|
||||
<Panel
|
||||
title={text.publicIPv4Pool}
|
||||
subtitle={formatIPv4PoolSubtitle(publicIPv4s.length, ipv4Assignments.length, language)}
|
||||
@@ -328,8 +450,19 @@ export default function Routing() {
|
||||
</RouteModal>
|
||||
)}
|
||||
|
||||
{ipv6Prefixes.length > 0 && (
|
||||
<Panel title={text.detectedIPv6Prefixes} subtitle={formatPrefixCount(ipv6Prefixes.length, language)}>
|
||||
<Panel
|
||||
title={text.detectedIPv6Prefixes}
|
||||
subtitle={formatPrefixCount(ipv6Prefixes.length, language)}
|
||||
action={
|
||||
<button onClick={startEditIPv6} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{text.editPrefixes}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{ipv6Prefixes.length === 0 ? (
|
||||
<EmptyState text={text.noIPv6Prefixes} icon={<Router className="h-7 w-7" />} />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
@@ -354,7 +487,58 @@ export default function Routing() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{editingIPv6 && (
|
||||
<RouteModal title={text.editIPv6Prefixes} onClose={() => setEditingIPv6(false)} wide>
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">{text.prefix}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{text.hostAddress}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{text.interface}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{text.gateway}</th>
|
||||
<th className="px-3 py-2 text-right font-medium">{text.action}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ipv6Draft.map((item, index) => (
|
||||
<tr key={item._id}>
|
||||
<td className="px-3 py-2"><input value={item.prefix || ''} onChange={(e) => updateIPv6Draft(index, { prefix: e.target.value })} placeholder="2001:db8:100::/64" className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv6Draft(index, { address: e.target.value })} placeholder="2001:db8:100::1" className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv6Draft(index, { interface: e.target.value })} placeholder={defaultIPv6Interface} className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv6Draft(index, { gateway: e.target.value })} placeholder={text.gateway} className={smallInputClass} /></td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => setIPv6Draft((items) => items.filter((_, i) => i !== index))} className="inline-flex items-center justify-center rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ipv6Draft.length === 0 && <EmptyRow colSpan={5} text={text.noIPv6Prefixes} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<button onClick={addIPv6Row} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{text.addIPv6Prefix}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setEditingIPv6(false)} disabled={savingIPv6} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button onClick={saveIPv6Prefixes} disabled={savingIPv6} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{savingIPv6 ? text.saving : text.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouteModal>
|
||||
)}
|
||||
|
||||
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
|
||||
@@ -527,7 +711,7 @@ function Pagination({ page, totalPages, totalItems, pageSize, onPageChange, lang
|
||||
)
|
||||
}
|
||||
|
||||
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel }: {
|
||||
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel, detail, action }: {
|
||||
title: string
|
||||
watermark: string
|
||||
remaining: string
|
||||
@@ -535,6 +719,8 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
|
||||
used: number
|
||||
label: string
|
||||
usedLabel: string
|
||||
detail?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-gray-200 bg-white p-4">
|
||||
@@ -542,20 +728,46 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
|
||||
{watermark}
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<span className="text-2xl font-semibold text-black">{remaining}</span>
|
||||
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 mt-3 text-xs text-gray-500">{label}</div>
|
||||
<div className="relative z-10 mt-1 text-xs text-gray-400">{usedLabel} {used}</div>
|
||||
{detail && <div className="relative z-10 mt-1 font-mono text-xs text-gray-400">{detail}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LabeledNumberInput({ label, value, onChange, min, max }: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min: number
|
||||
max: number
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value || ''}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:ring-1 focus:ring-black"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ icon, text }: { icon: ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
@@ -632,6 +844,11 @@ const routingText = {
|
||||
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
|
||||
refresh: '刷新',
|
||||
nat4Ports: 'NAT4 端口',
|
||||
editNAT4Range: '编辑 NAT4 范围',
|
||||
rangeStart: '起始端口',
|
||||
rangeEnd: '结束端口',
|
||||
nat4RangeInvalid: 'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口',
|
||||
saveNAT4RangeFailed: '保存 NAT4 范围失败',
|
||||
remainingTotal: '剩余 / 总数',
|
||||
publicIPv4: '公网 IPv4',
|
||||
publicIPv4Pool: '公网 IPv4 池',
|
||||
@@ -661,10 +878,17 @@ const routingText = {
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
detectedIPv6Prefixes: '检测到的 IPv6 前缀',
|
||||
editPrefixes: '编辑前缀',
|
||||
editIPv6Prefixes: '编辑 IPv6 前缀',
|
||||
addIPv6Prefix: '添加 IPv6 前缀',
|
||||
noIPv6Prefixes: '暂无 IPv6 前缀',
|
||||
ipv6InterfaceRequired: 'IPv6 网卡不能为空',
|
||||
saveIPv6PrefixesFailed: '保存 IPv6 前缀失败',
|
||||
prefix: '前缀',
|
||||
hostAddress: '宿主地址',
|
||||
source: '来源',
|
||||
local: '本机',
|
||||
manual: '手动',
|
||||
ipv4NAT: 'IPv4 NAT',
|
||||
searchNAT: '搜索 NAT...',
|
||||
noIPv4NATMappings: '暂无 IPv4 NAT 映射',
|
||||
@@ -691,6 +915,11 @@ const routingText = {
|
||||
pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments',
|
||||
refresh: 'Refresh',
|
||||
nat4Ports: 'NAT4 ports',
|
||||
editNAT4Range: 'Edit NAT4 range',
|
||||
rangeStart: 'Start port',
|
||||
rangeEnd: 'End port',
|
||||
nat4RangeInvalid: 'NAT4 range must be 1-65535, and start cannot be greater than end',
|
||||
saveNAT4RangeFailed: 'Save NAT4 range failed',
|
||||
remainingTotal: 'remaining / total',
|
||||
publicIPv4: 'Public IPv4',
|
||||
publicIPv4Pool: 'Public IPv4 pool',
|
||||
@@ -720,10 +949,17 @@ const routingText = {
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
detectedIPv6Prefixes: 'Detected IPv6 prefixes',
|
||||
editPrefixes: 'Edit prefixes',
|
||||
editIPv6Prefixes: 'Edit IPv6 prefixes',
|
||||
addIPv6Prefix: 'Add IPv6 prefix',
|
||||
noIPv6Prefixes: 'No IPv6 prefixes',
|
||||
ipv6InterfaceRequired: 'IPv6 interface is required',
|
||||
saveIPv6PrefixesFailed: 'Save IPv6 prefixes failed',
|
||||
prefix: 'Prefix',
|
||||
hostAddress: 'Host address',
|
||||
source: 'Source',
|
||||
local: 'local',
|
||||
manual: 'manual',
|
||||
ipv4NAT: 'IPv4 NAT',
|
||||
searchNAT: 'Search NAT...',
|
||||
noIPv4NATMappings: 'No IPv4 NAT mappings',
|
||||
@@ -763,6 +999,10 @@ function formatDetectedPrefixCount(count: number, language: Language) {
|
||||
: `检测到 ${count} 个前缀`
|
||||
}
|
||||
|
||||
function formatNATRange(range: NAT4PortRange, language: Language) {
|
||||
return language === 'en' ? `range ${range.start}-${range.end}` : `范围 ${range.start}-${range.end}`
|
||||
}
|
||||
|
||||
function formatPrefixCount(count: number, language: Language) {
|
||||
return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀`
|
||||
}
|
||||
@@ -801,6 +1041,7 @@ function formatContainerStatus(status: string, language: Language) {
|
||||
|
||||
function formatSource(source: string | undefined, language: Language) {
|
||||
if (!source || source === 'local') return routingText[language].local
|
||||
if (source === 'manual') return routingText[language].manual
|
||||
return source
|
||||
}
|
||||
|
||||
|
||||
@@ -535,6 +535,11 @@ export interface RouteCapacity {
|
||||
total: string
|
||||
}
|
||||
|
||||
export interface NAT4PortRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface NAT4Route {
|
||||
container_id: number
|
||||
container_name: string
|
||||
@@ -571,6 +576,7 @@ export interface IPv6Route {
|
||||
|
||||
export interface RoutingInfo {
|
||||
nat4: RouteCapacity
|
||||
nat4_port_range: NAT4PortRange
|
||||
ipv4: RouteCapacity
|
||||
ipv6: RouteCapacity
|
||||
host_public_ipv4?: PublicIPv4Info
|
||||
@@ -590,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[]) =>
|
||||
|
||||
@@ -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 "=============================="
|
||||
Reference in New Issue
Block a user