mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d05ca8cc4c | |||
| 48fa14f8a7 | |||
| 5c6d6eafc9 | |||
| 2ecdb5c26f | |||
| ae02241370 | |||
| fdd83977fc | |||
| 58d86b5d08 | |||
| 7307255130 | |||
| 0e3c059236 | |||
| 61137b837d | |||
| 596bf86477 | |||
| 47a09aa177 | |||
| 2456b65ce2 | |||
| 292686a19a | |||
| 9eb7c322cf | |||
| 5ec62ca732 | |||
| a79df0d2dd | |||
| cc8fdbfede | |||
| 702d6975e5 | |||
| 84d98e40c6 | |||
| fd974d95b9 | |||
| cd258fd6ac | |||
| 0c2dd457d4 | |||
| 92e846eecc | |||
| a1d9ce8b1c | |||
| 49d8093f45 | |||
| 98ed716225 | |||
| 78276d303b | |||
| 30d2a4f4da | |||
| a54e03b924 | |||
| 4cdc6e68ba | |||
| 2ed42992ed | |||
| 4dfd7c0885 | |||
| 5ed5b4509d | |||
| 18f297b988 | |||
| d5a236943b | |||
| c54f92f892 | |||
| 86f0d079ab | |||
| 3a65d5d24a |
+42
-10
@@ -14,9 +14,15 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux-amd64:
|
linux:
|
||||||
name: Linux amd64
|
name: Linux ${{ matrix.goarch }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -55,16 +61,21 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build CLICD
|
- name: Build CLICD
|
||||||
|
env:
|
||||||
|
CLICD_GOARCH: ${{ matrix.goarch }}
|
||||||
run: bash build.sh
|
run: bash build.sh
|
||||||
|
|
||||||
- name: Package CLICD
|
- name: Package CLICD
|
||||||
|
env:
|
||||||
|
CLICD_GOARCH: ${{ matrix.goarch }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist package/clicd-linux-amd64
|
asset_dir="clicd-linux-${CLICD_GOARCH}"
|
||||||
cp build/clicd package/clicd-linux-amd64/clicd
|
mkdir -p "dist" "package/${asset_dir}"
|
||||||
cp build/install.sh package/clicd-linux-amd64/install.sh
|
cp build/clicd "package/${asset_dir}/clicd"
|
||||||
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
|
cp build/install.sh "package/${asset_dir}/install.sh"
|
||||||
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
|
chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh"
|
||||||
cp build/clicd dist/clicd-linux-amd64
|
tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}"
|
||||||
|
cp build/clicd "dist/${asset_dir}"
|
||||||
|
|
||||||
- name: Package Mofang module
|
- name: Package Mofang module
|
||||||
run: |
|
run: |
|
||||||
@@ -87,13 +98,34 @@ jobs:
|
|||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: clicd-linux-amd64
|
name: clicd-linux-${{ matrix.goarch }}
|
||||||
path: dist/*
|
path: dist/*
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
release:
|
||||||
|
name: Publish GitHub Release
|
||||||
|
needs: linux
|
||||||
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist-artifacts
|
||||||
|
|
||||||
|
- name: Prepare release assets
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
find dist-artifacts -maxdepth 2 -type f ! -name SHA256SUMS -print -exec cp -f {} dist/ \;
|
||||||
|
sha256sum dist/* > dist/SHA256SUMS
|
||||||
|
|
||||||
|
- name: Publish GitHub Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
|
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
|
||||||
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ backend/internal/server/web/*
|
|||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/build/
|
/build/
|
||||||
|
/dist/
|
||||||
Mofang/*.zip
|
Mofang/*.zip
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
@@ -68,3 +69,6 @@ linux.txt
|
|||||||
push-release.ps1
|
push-release.ps1
|
||||||
deploy.ps1
|
deploy.ps1
|
||||||
backend/clicd
|
backend/clicd
|
||||||
|
api.md
|
||||||
|
deploy-arm.ps1
|
||||||
|
deploy-dhcp.ps1
|
||||||
|
|||||||
+176
-2
@@ -10,6 +10,7 @@ README.md
|
|||||||
handlers/
|
handlers/
|
||||||
webssh.php
|
webssh.php
|
||||||
templates/
|
templates/
|
||||||
|
firewall.html
|
||||||
info.html
|
info.html
|
||||||
nat.html
|
nat.html
|
||||||
```
|
```
|
||||||
@@ -93,11 +94,12 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## 客户区页面
|
## 客户区页面
|
||||||
|
|
||||||
模块提供两个客户区选项卡:
|
模块提供三个客户区选项卡:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
实例信息
|
实例信息
|
||||||
NAT转发
|
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
|
||||||
|
|
||||||
WebSSH 按钮会调用:
|
WebSSH 按钮会调用:
|
||||||
@@ -252,6 +313,8 @@ https://www.example.com
|
|||||||
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
|
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
|
||||||
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
|
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
|
||||||
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
|
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
|
||||||
|
| 查询防火墙 | `GET /api/v1/containers/{id}/firewall` |
|
||||||
|
| 更新防火墙 | `PUT /api/v1/containers/{id}/firewall` |
|
||||||
| WebSSH | `POST /api/v1/ssh-ticket` |
|
| WebSSH | `POST /api/v1/ssh-ticket` |
|
||||||
|
|
||||||
## 建议 API 权限
|
## 建议 API 权限
|
||||||
@@ -269,6 +332,7 @@ container:password
|
|||||||
container:traffic
|
container:traffic
|
||||||
container:resize
|
container:resize
|
||||||
container:port
|
container:port
|
||||||
|
container:firewall
|
||||||
task:read
|
task:read
|
||||||
ssh-ticket:create
|
ssh-ticket:create
|
||||||
```
|
```
|
||||||
@@ -316,6 +380,22 @@ curl --location --request PUT \
|
|||||||
--data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}'
|
--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 票据:
|
创建 WebSSH 票据:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -336,6 +416,41 @@ curl --location --request POST \
|
|||||||
Content-Type: application/json
|
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 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
|
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
|
||||||
@@ -344,7 +459,66 @@ CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚
|
|||||||
|
|
||||||
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
|
旧版本只显示 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:
|
请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS:
|
||||||
|
|
||||||
|
|||||||
+29
-8
@@ -1,4 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use think\Db;
|
use think\Db;
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ function clicd_MetaData()
|
|||||||
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5 and DeepSeek V4',
|
'DisplayName' => 'CLICD 对接模块 by 欢-Huan and ChatGPT 5.5 and DeepSeek V4',
|
||||||
'APIVersion' => '1.1',
|
'APIVersion' => '1.1',
|
||||||
'HelpDoc' => 'https://github.com/MengMengCode/CLICD',
|
'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);
|
$host = parse_url($baseUrl, PHP_URL_HOST);
|
||||||
$port = parse_url($baseUrl, PHP_URL_PORT);
|
$port = parse_url($baseUrl, PHP_URL_PORT);
|
||||||
$wsBase = $scheme . '://' . $host . ($port ? ':' . $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';
|
$siteScheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
$siteHost = $_SERVER['HTTP_HOST'] ?? '';
|
$siteHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
@@ -374,6 +377,7 @@ function clicd_webssh_url($params, $ticket, $containerName)
|
|||||||
return $handler
|
return $handler
|
||||||
. '?ws=' . rawurlencode($wsUrl)
|
. '?ws=' . rawurlencode($wsUrl)
|
||||||
. '&protocol=' . rawurlencode('clicd-ticket.' . (string)$ticket)
|
. '&protocol=' . rawurlencode('clicd-ticket.' . (string)$ticket)
|
||||||
|
. '&ticket=' . rawurlencode((string)$ticket)
|
||||||
. '&container=' . rawurlencode((string)$containerName);
|
. '&container=' . rawurlencode((string)$containerName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,9 +630,24 @@ function clicd_request_value($key, $default = '')
|
|||||||
|
|
||||||
function clicd_json_input()
|
function clicd_json_input()
|
||||||
{
|
{
|
||||||
|
$input = [];
|
||||||
|
if (!empty($_POST) && is_array($_POST)) {
|
||||||
|
$input = $_POST;
|
||||||
|
}
|
||||||
|
|
||||||
$raw = file_get_contents('php://input');
|
$raw = file_get_contents('php://input');
|
||||||
$data = json_decode((string)$raw, true);
|
$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 = '')
|
function clicd_param_value($data, $key, $default = '')
|
||||||
@@ -1404,7 +1423,13 @@ function clicd_ClientButton($params)
|
|||||||
|
|
||||||
function clicd_webssh($params)
|
function clicd_webssh($params)
|
||||||
{
|
{
|
||||||
|
$container = [];
|
||||||
$containerName = clicd_container_name($params);
|
$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);
|
$res = clicd_request($params, '/api/v1/ssh-ticket', ['container_name' => $containerName], 'POST', 30);
|
||||||
if (!clicd_success($res)) {
|
if (!clicd_success($res)) {
|
||||||
return ['status' => 'error', 'msg' => clicd_message($res, 'WebSSH ticket create failed')];
|
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'] : '');
|
$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'] : '');
|
$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'] : '');
|
$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);
|
http_response_code(400);
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
echo "Missing WebSSH parameters\n";
|
echo "Missing WebSSH parameters\n";
|
||||||
@@ -64,6 +69,7 @@ if ($ws === '' || $protocol === '') {
|
|||||||
(function(){
|
(function(){
|
||||||
var wsUrl = <?php echo json_encode($ws, JSON_UNESCAPED_SLASHES); ?>;
|
var wsUrl = <?php echo json_encode($ws, JSON_UNESCAPED_SLASHES); ?>;
|
||||||
var protocol = <?php echo json_encode($protocol, 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 term = document.getElementById('term');
|
||||||
var state = document.getElementById('state');
|
var state = document.getElementById('state');
|
||||||
var modeSelect = document.getElementById('send-mode');
|
var modeSelect = document.getElementById('send-mode');
|
||||||
@@ -189,8 +195,17 @@ if ($ws === '' || $protocol === '') {
|
|||||||
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
|
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function websocketProtocolValue(value) {
|
||||||
|
value = String(value || '');
|
||||||
|
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value) ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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';
|
socket.binaryType = 'arraybuffer';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState('err', '\nWebSocket 创建失败:' + e.message + '\n');
|
setState('err', '\nWebSocket 创建失败:' + e.message + '\n');
|
||||||
|
|||||||
+824
-212
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{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
|
||||||
.clicd-info *{box-sizing:border-box}
|
.clicd-info *{box-sizing:border-box}
|
||||||
.clicd-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:12px}
|
.clicd-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-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-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
|
||||||
.clicd-nat-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}
|
.clicd-nat-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">
|
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">CLICD <sub><sup>v1.1.18</sup></sub></h1>
|
<h1 align="center">CLICD <sub></sub></h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
|
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
|
||||||
|
|||||||
+4
-6
@@ -1,18 +1,16 @@
|
|||||||
module clicd
|
module clicd
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.24.5
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/term v0.37.0
|
golang.org/x/term v0.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/sys v0.38.0
|
golang.org/x/sys v0.45.0
|
||||||
modernc.org/sqlite v1.29.10
|
modernc.org/sqlite v1.29.10
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -18,8 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
|
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w=
|
||||||
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||||
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8=
|
||||||
@@ -27,10 +27,10 @@ golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|||||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
|
||||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContainerMetricPoint struct {
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
CPU float64 `json:"cpu"`
|
||||||
|
Memory float64 `json:"memory"`
|
||||||
|
Network float64 `json:"network"`
|
||||||
|
NetworkRx float64 `json:"network_rx"`
|
||||||
|
NetworkTx float64 `json:"network_tx"`
|
||||||
|
DiskIO float64 `json:"disk_io"`
|
||||||
|
DiskRead float64 `json:"disk_read"`
|
||||||
|
DiskWrite float64 `json:"disk_write"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var containerMetricSamplerOnce sync.Once
|
||||||
|
var containerMetricMu sync.RWMutex
|
||||||
|
var containerMetricHistory = map[string][]ContainerMetricPoint{}
|
||||||
|
var containerMetricInFlight sync.Map
|
||||||
|
|
||||||
|
const (
|
||||||
|
containerMetricSampleInterval = 30 * time.Second
|
||||||
|
containerMetricSampleTimeout = 20 * time.Second
|
||||||
|
containerMetricConcurrency = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
func StartContainerMetricSampler() {
|
||||||
|
containerMetricSamplerOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
sampleAllContainerMetrics()
|
||||||
|
ticker := time.NewTicker(containerMetricSampleInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
sampleAllContainerMetrics()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleAllContainerMetrics() {
|
||||||
|
containers, _ := listByRuntime()
|
||||||
|
sem := make(chan struct{}, containerMetricConcurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, c := range containers {
|
||||||
|
c := c
|
||||||
|
if c.Status != "running" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sem <- struct{}{}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
sampleContainerMetricWithTimeout(c)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
pruneContainerMetricHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleContainerMetricWithTimeout(c config.Container) {
|
||||||
|
key := containerMetricKey(c)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, loaded := containerMetricInFlight.LoadOrStore(key, struct{}{}); loaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
defer containerMetricInFlight.Delete(key)
|
||||||
|
if usage, err := usageByRuntime(c.ID); err == nil {
|
||||||
|
appendContainerMetricPoint(c, usage)
|
||||||
|
}
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(containerMetricSampleTimeout):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendContainerMetricPoint(c config.Container, usage map[string]interface{}) {
|
||||||
|
key := containerMetricKey(c)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
memoryTotal := numberFromUsage(usage, "memory_total_bytes")
|
||||||
|
if memoryTotal <= 0 {
|
||||||
|
memoryTotal = float64(c.RAMMB) * 1024 * 1024
|
||||||
|
}
|
||||||
|
memoryPct := 0.0
|
||||||
|
if memoryTotal > 0 {
|
||||||
|
memoryPct = clampPercent(numberFromUsage(usage, "memory_usage_bytes") / memoryTotal * 100)
|
||||||
|
}
|
||||||
|
vcpu := c.VCPU
|
||||||
|
if vcpu <= 0 {
|
||||||
|
vcpu = 1
|
||||||
|
}
|
||||||
|
cpuPct := clampPercent(numberFromUsage(usage, "cpu_usage_pct") / vcpu)
|
||||||
|
networkRx := positiveNumberFromUsage(usage, "network_rx_bps")
|
||||||
|
networkTx := positiveNumberFromUsage(usage, "network_tx_bps")
|
||||||
|
diskRead := positiveNumberFromUsage(usage, "disk_read_bps")
|
||||||
|
diskWrite := positiveNumberFromUsage(usage, "disk_write_bps")
|
||||||
|
point := ContainerMetricPoint{
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
CPU: cpuPct,
|
||||||
|
Memory: memoryPct,
|
||||||
|
NetworkRx: networkRx,
|
||||||
|
NetworkTx: networkTx,
|
||||||
|
Network: networkRx + networkTx,
|
||||||
|
DiskRead: diskRead,
|
||||||
|
DiskWrite: diskWrite,
|
||||||
|
DiskIO: diskRead + diskWrite,
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
|
||||||
|
|
||||||
|
containerMetricMu.Lock()
|
||||||
|
defer containerMetricMu.Unlock()
|
||||||
|
|
||||||
|
history := containerMetricHistory[key]
|
||||||
|
keepFrom := 0
|
||||||
|
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
|
||||||
|
keepFrom++
|
||||||
|
}
|
||||||
|
if keepFrom > 0 {
|
||||||
|
copy(history, history[keepFrom:])
|
||||||
|
history = history[:len(history)-keepFrom]
|
||||||
|
}
|
||||||
|
containerMetricHistory[key] = append(history, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getContainerMetricHistory(c *config.Container) []ContainerMetricPoint {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := containerMetricKey(*c)
|
||||||
|
containerMetricMu.RLock()
|
||||||
|
defer containerMetricMu.RUnlock()
|
||||||
|
|
||||||
|
history := containerMetricHistory[key]
|
||||||
|
result := make([]ContainerMetricPoint, len(history))
|
||||||
|
copy(result, history)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneContainerMetricHistory() {
|
||||||
|
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
|
||||||
|
valid := map[string]bool{}
|
||||||
|
if config.AppConfig != nil {
|
||||||
|
for _, c := range config.AppConfig.Containers {
|
||||||
|
valid[containerMetricKey(c)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
containerMetricMu.Lock()
|
||||||
|
defer containerMetricMu.Unlock()
|
||||||
|
|
||||||
|
for key, history := range containerMetricHistory {
|
||||||
|
if !valid[key] {
|
||||||
|
delete(containerMetricHistory, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keepFrom := 0
|
||||||
|
for keepFrom < len(history) && history[keepFrom].TS < cutoff {
|
||||||
|
keepFrom++
|
||||||
|
}
|
||||||
|
if keepFrom > 0 {
|
||||||
|
copy(history, history[keepFrom:])
|
||||||
|
containerMetricHistory[key] = history[:len(history)-keepFrom]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerMetricKey(c config.Container) string {
|
||||||
|
if c.UUID != "" {
|
||||||
|
return "uuid:" + c.UUID
|
||||||
|
}
|
||||||
|
if c.ID > 0 {
|
||||||
|
return fmt.Sprintf("id:%d", c.ID)
|
||||||
|
}
|
||||||
|
if c.Name != "" {
|
||||||
|
return "name:" + c.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberFromUsage(usage map[string]interface{}, key string) float64 {
|
||||||
|
value, ok := usage[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case float64:
|
||||||
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case float32:
|
||||||
|
return float64(v)
|
||||||
|
case int:
|
||||||
|
return float64(v)
|
||||||
|
case int64:
|
||||||
|
return float64(v)
|
||||||
|
case int32:
|
||||||
|
return float64(v)
|
||||||
|
case uint:
|
||||||
|
return float64(v)
|
||||||
|
case uint64:
|
||||||
|
return float64(v)
|
||||||
|
case uint32:
|
||||||
|
return float64(v)
|
||||||
|
case json.Number:
|
||||||
|
n, _ := v.Float64()
|
||||||
|
return n
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.ParseFloat(v, 64)
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func positiveNumberFromUsage(usage map[string]interface{}, key string) float64 {
|
||||||
|
value := numberFromUsage(usage, key)
|
||||||
|
if value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -132,6 +132,11 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
getUsage(w, r, id)
|
getUsage(w, r, id)
|
||||||
|
case action == "history" && r.Method == http.MethodGet:
|
||||||
|
if !requireScope(w, r, "container:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getContainerMetricHistory(c)})
|
||||||
case action == "traffic" && r.Method == http.MethodGet:
|
case action == "traffic" && r.Method == http.MethodGet:
|
||||||
if !requireScope(w, r, "container:read") {
|
if !requireScope(w, r, "container:read") {
|
||||||
return
|
return
|
||||||
@@ -240,6 +245,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ids, err := normalizeAllowedImageIDs(cfg.AllowedImageIDs); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
cfg.AllowedImageIDs = ids
|
||||||
|
}
|
||||||
if cfg.VCPU <= 0 {
|
if cfg.VCPU <= 0 {
|
||||||
cfg.VCPU = 1
|
cfg.VCPU = 1
|
||||||
}
|
}
|
||||||
@@ -452,13 +463,12 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
config.NormalizeContainerResourceAliases(c)
|
config.NormalizeContainerResourceAliases(c)
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
|
|
||||||
// Re-apply resource limits to running container
|
// Re-apply persisted/runtime limits. LXC also uses this path to migrate
|
||||||
if c.Status == "running" {
|
// old managed config lines such as lxc.prlimit.nproc.
|
||||||
if err := applyLimitsByRuntime(c); err != nil {
|
if err := applyLimitsByRuntime(c); err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
msg := "Resource limits updated"
|
msg := "Resource limits updated"
|
||||||
if c.IsKVM() && c.Status == "running" {
|
if c.IsKVM() && c.Status == "running" {
|
||||||
@@ -582,9 +592,14 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
|
||||||
// Try random ports
|
start, end := config.NATPortRange()
|
||||||
for tries := 0; tries < 100; tries++ {
|
capacity := end - start + 1
|
||||||
port := 10000 + (int(time.Now().UnixNano()) % 55535)
|
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") {
|
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
|
||||||
return
|
return
|
||||||
@@ -651,6 +666,18 @@ func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleHostHistory returns host resource samples collected by the server.
|
||||||
|
func HandleHostHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireScope(w, r, "host:read") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostMetricHistory()})
|
||||||
|
}
|
||||||
|
|
||||||
func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && lxc.IsExpired(*c) {
|
if c != nil && lxc.IsExpired(*c) {
|
||||||
|
|||||||
+482
-19
@@ -5,6 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -27,6 +29,7 @@ type HostInfo struct {
|
|||||||
Network NetworkInfo `json:"network"`
|
Network NetworkInfo `json:"network"`
|
||||||
DiskIO DiskIOInfo `json:"disk_io"`
|
DiskIO DiskIOInfo `json:"disk_io"`
|
||||||
Load LoadInfo `json:"load"`
|
Load LoadInfo `json:"load"`
|
||||||
|
Runtime HostRuntimeProbe `json:"runtime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HostProbeReport struct {
|
type HostProbeReport struct {
|
||||||
@@ -215,10 +218,34 @@ type DiskIOInfo struct {
|
|||||||
WriteBps float64 `json:"write_bps"`
|
WriteBps float64 `json:"write_bps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HostMetricPoint struct {
|
||||||
|
TS int64 `json:"ts"`
|
||||||
|
CPU float64 `json:"cpu"`
|
||||||
|
Memory float64 `json:"memory"`
|
||||||
|
Network float64 `json:"network"`
|
||||||
|
NetworkRx float64 `json:"network_rx"`
|
||||||
|
NetworkTx float64 `json:"network_tx"`
|
||||||
|
DiskIO float64 `json:"disk_io"`
|
||||||
|
DiskRead float64 `json:"disk_read"`
|
||||||
|
DiskWrite float64 `json:"disk_write"`
|
||||||
|
DiskUsagePct float64 `json:"disk_usage_pct"`
|
||||||
|
}
|
||||||
|
|
||||||
var hostCPUMu sync.Mutex
|
var hostCPUMu sync.Mutex
|
||||||
var lastHostCPU cpuTimes
|
var lastHostCPU cpuTimes
|
||||||
var hostIOMu sync.Mutex
|
var hostIOMu sync.Mutex
|
||||||
var lastHostIO hostIOSample
|
var lastHostIO hostIOSample
|
||||||
|
var hostMetricSamplerOnce sync.Once
|
||||||
|
var hostMetricMu sync.RWMutex
|
||||||
|
var hostMetricHistory []HostMetricPoint
|
||||||
|
var egressIPv4Mu sync.Mutex
|
||||||
|
var cachedEgressIPv4 lxc.PublicIPInfo
|
||||||
|
var cachedEgressIPv4At time.Time
|
||||||
|
|
||||||
|
const (
|
||||||
|
hostMetricSampleInterval = 30 * time.Second
|
||||||
|
hostMetricRetention = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
type cpuTimes struct {
|
type cpuTimes struct {
|
||||||
Total uint64
|
Total uint64
|
||||||
@@ -234,6 +261,10 @@ type hostIOSample struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getHostInfo() HostInfo {
|
func getHostInfo() HostInfo {
|
||||||
|
return getHostInfoWithNetworkDetails(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHostInfoWithNetworkDetails(includeDetails bool) HostInfo {
|
||||||
info := HostInfo{
|
info := HostInfo{
|
||||||
CPU: CpuInfo{Cores: runtime.NumCPU()},
|
CPU: CpuInfo{Cores: runtime.NumCPU()},
|
||||||
}
|
}
|
||||||
@@ -241,11 +272,107 @@ func getHostInfo() HostInfo {
|
|||||||
info.RAM = getMemoryInfo()
|
info.RAM = getMemoryInfo()
|
||||||
info.Disk = getDiskInfo()
|
info.Disk = getDiskInfo()
|
||||||
info.CPU.Usage = getCPUUsage()
|
info.CPU.Usage = getCPUUsage()
|
||||||
info.Network, info.DiskIO = getHostRates()
|
info.Network, info.DiskIO = getHostRates(includeDetails)
|
||||||
info.Load = getLoadInfo()
|
info.Load = getLoadInfo()
|
||||||
|
info.Runtime = detectRuntimeProbeQuick()
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectRuntimeProbeQuick() HostRuntimeProbe {
|
||||||
|
devKVM := fileExists("/dev/kvm")
|
||||||
|
nested, detail := detectNestedVirtualization()
|
||||||
|
lxcOK := commandExists("lxc-create")
|
||||||
|
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
|
||||||
|
kvmOK := kvmSupportedArch && devKVM && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
|
||||||
|
probe := HostRuntimeProbe{
|
||||||
|
LXCAvailable: lxcOK,
|
||||||
|
KVMAvailable: kvmOK,
|
||||||
|
DevKVM: devKVM,
|
||||||
|
NestedVirtualization: nested,
|
||||||
|
NestedDetail: detail,
|
||||||
|
SupportMode: "unsupported",
|
||||||
|
}
|
||||||
|
if probe.KVMAvailable {
|
||||||
|
probe.SupportMode = "kvm_lxc"
|
||||||
|
} else if probe.LXCAvailable {
|
||||||
|
probe.SupportMode = "lxc_only"
|
||||||
|
}
|
||||||
|
return probe
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartHostMetricSampler() {
|
||||||
|
hostMetricSamplerOnce.Do(func() {
|
||||||
|
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(hostMetricSampleInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
appendHostMetricPoint(getHostInfoWithNetworkDetails(false))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendHostMetricPoint(info HostInfo) {
|
||||||
|
memoryPct := 0.0
|
||||||
|
if info.RAM.TotalMB > 0 {
|
||||||
|
memoryPct = clampPercent(float64(info.RAM.UsedMB) / float64(info.RAM.TotalMB) * 100)
|
||||||
|
}
|
||||||
|
diskUsagePct := 0.0
|
||||||
|
if info.Disk.TotalGB > 0 {
|
||||||
|
diskUsagePct = clampPercent(info.Disk.UsedGB / info.Disk.TotalGB * 100)
|
||||||
|
}
|
||||||
|
point := HostMetricPoint{
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
CPU: clampPercent(info.CPU.Usage),
|
||||||
|
Memory: memoryPct,
|
||||||
|
NetworkRx: info.Network.RXBps,
|
||||||
|
NetworkTx: info.Network.TXBps,
|
||||||
|
Network: info.Network.RXBps + info.Network.TXBps,
|
||||||
|
DiskRead: info.DiskIO.ReadBps,
|
||||||
|
DiskWrite: info.DiskIO.WriteBps,
|
||||||
|
DiskIO: info.DiskIO.ReadBps + info.DiskIO.WriteBps,
|
||||||
|
DiskUsagePct: diskUsagePct,
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-hostMetricRetention).UnixMilli()
|
||||||
|
|
||||||
|
hostMetricMu.Lock()
|
||||||
|
defer hostMetricMu.Unlock()
|
||||||
|
|
||||||
|
keepFrom := 0
|
||||||
|
for keepFrom < len(hostMetricHistory) && hostMetricHistory[keepFrom].TS < cutoff {
|
||||||
|
keepFrom++
|
||||||
|
}
|
||||||
|
if keepFrom > 0 {
|
||||||
|
copy(hostMetricHistory, hostMetricHistory[keepFrom:])
|
||||||
|
hostMetricHistory = hostMetricHistory[:len(hostMetricHistory)-keepFrom]
|
||||||
|
}
|
||||||
|
hostMetricHistory = append(hostMetricHistory, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHostMetricHistory() []HostMetricPoint {
|
||||||
|
hostMetricMu.RLock()
|
||||||
|
defer hostMetricMu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]HostMetricPoint, len(hostMetricHistory))
|
||||||
|
copy(result, hostMetricHistory)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampPercent(value float64) float64 {
|
||||||
|
if value < 0 || !isFiniteFloat(value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFiniteFloat(value float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func getMemoryInfo() MemoryInfo {
|
func getMemoryInfo() MemoryInfo {
|
||||||
f, err := os.Open("/proc/meminfo")
|
f, err := os.Open("/proc/meminfo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -391,13 +518,14 @@ func parseSizeGBf(s string) (float64, error) {
|
|||||||
return val, err
|
return val, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHostRates() (NetworkInfo, DiskIOInfo) {
|
func getHostRates(includeDetails bool) (NetworkInfo, DiskIOInfo) {
|
||||||
rx, tx := readHostNetworkBytes()
|
rx, tx := readHostNetworkBytes()
|
||||||
readBytes, writeBytes := readHostDiskBytes()
|
readBytes, writeBytes := readHostDiskBytes()
|
||||||
now := unixNano()
|
now := unixNano()
|
||||||
|
|
||||||
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
||||||
publicIPv4 := lxc.DetectPublicIPv4()
|
if includeDetails {
|
||||||
|
publicIPv4 := detectDisplayPublicIPv4()
|
||||||
network.PublicIPv4 = publicIPv4.Address
|
network.PublicIPv4 = publicIPv4.Address
|
||||||
network.PublicIPv4Interface = publicIPv4.Interface
|
network.PublicIPv4Interface = publicIPv4.Interface
|
||||||
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||||
@@ -406,6 +534,7 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
|
|||||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||||
}
|
}
|
||||||
|
}
|
||||||
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
||||||
|
|
||||||
hostIOMu.Lock()
|
hostIOMu.Lock()
|
||||||
@@ -437,23 +566,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readHostNetworkBytes() (uint64, uint64) {
|
func readHostNetworkBytes() (uint64, uint64) {
|
||||||
entries, err := os.ReadDir("/sys/class/net")
|
ifaces := detectHostTrafficInterfaces()
|
||||||
if err != nil {
|
if len(ifaces) == 0 {
|
||||||
return 0, 0
|
ifaces = fallbackHostTrafficInterfaces()
|
||||||
}
|
}
|
||||||
|
|
||||||
var rx, tx uint64
|
var rx, tx uint64
|
||||||
for _, entry := range entries {
|
for _, name := range ifaces {
|
||||||
name := entry.Name()
|
|
||||||
if name == "lo" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
|
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
|
||||||
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
|
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
|
||||||
}
|
}
|
||||||
return rx, tx
|
return rx, tx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectHostTrafficInterfaces() []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, 2)
|
||||||
|
add := func(name string) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if !isHostTrafficInterface(name) || seen[name] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
result = append(result, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if iface, _ := detectDefaultIPv4Route(); iface != "" {
|
||||||
|
add(iface)
|
||||||
|
}
|
||||||
|
if iface, _ := detectDefaultIPv6Route(); iface != "" {
|
||||||
|
add(iface)
|
||||||
|
}
|
||||||
|
if pub := lxc.DetectPublicIPv4(); pub.Interface != "" {
|
||||||
|
add(pub.Interface)
|
||||||
|
}
|
||||||
|
for _, prefix := range lxc.DetectHostPublicIPv6Prefixes() {
|
||||||
|
add(prefix.Interface)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackHostTrafficInterfaces() []string {
|
||||||
|
entries, err := os.ReadDir("/sys/class/net")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if !isHostTrafficInterface(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state := strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "operstate")))
|
||||||
|
if state == "down" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, name)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHostTrafficInterface(name string) bool {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || name == "lo" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isContainerLikeInterfaceName(name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func readHostDiskBytes() (uint64, uint64) {
|
func readHostDiskBytes() (uint64, uint64) {
|
||||||
f, err := os.Open("/proc/diskstats")
|
f, err := os.Open("/proc/diskstats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -574,6 +759,8 @@ func trimOSReleaseValue(value string) string {
|
|||||||
|
|
||||||
func detectHostCPUProbe() HostCPUProbe {
|
func detectHostCPUProbe() HostCPUProbe {
|
||||||
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
|
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
|
||||||
|
armImplementer := ""
|
||||||
|
armPart := ""
|
||||||
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
seenFlags := map[string]bool{}
|
seenFlags := map[string]bool{}
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
@@ -582,19 +769,28 @@ func detectHostCPUProbe() HostCPUProbe {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := strings.TrimSpace(fields[0])
|
key := strings.TrimSpace(fields[0])
|
||||||
|
keyLower := strings.ToLower(key)
|
||||||
value := strings.TrimSpace(fields[1])
|
value := strings.TrimSpace(fields[1])
|
||||||
switch key {
|
switch keyLower {
|
||||||
case "model name", "Hardware", "Processor":
|
case "model name", "hardware", "processor":
|
||||||
if probe.Model == "" {
|
if probe.Model == "" && meaningfulCPUModel(value) {
|
||||||
probe.Model = value
|
probe.Model = value
|
||||||
}
|
}
|
||||||
case "cpu cores":
|
case "cpu cores":
|
||||||
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
|
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
|
||||||
probe.Cores = cores
|
probe.Cores = cores
|
||||||
}
|
}
|
||||||
case "flags", "Features":
|
case "cpu implementer":
|
||||||
|
if armImplementer == "" {
|
||||||
|
armImplementer = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
case "cpu part":
|
||||||
|
if armPart == "" {
|
||||||
|
armPart = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
case "flags", "features":
|
||||||
for _, flag := range strings.Fields(value) {
|
for _, flag := range strings.Fields(value) {
|
||||||
if flag == "vmx" || flag == "svm" {
|
if flag == "vmx" || flag == "svm" || flag == "virt" {
|
||||||
probe.Virtualization = true
|
probe.Virtualization = true
|
||||||
probe.VirtualizationKey = flag
|
probe.VirtualizationKey = flag
|
||||||
}
|
}
|
||||||
@@ -607,12 +803,132 @@ func detectHostCPUProbe() HostCPUProbe {
|
|||||||
}
|
}
|
||||||
sort.Strings(probe.Flags)
|
sort.Strings(probe.Flags)
|
||||||
}
|
}
|
||||||
|
enrichCPUProbeFromLscpu(&probe, &armImplementer, &armPart)
|
||||||
|
if probe.Model == "" {
|
||||||
|
probe.Model = armCPUModelName(armImplementer, armPart)
|
||||||
|
}
|
||||||
|
if probe.Model == "" && runtime.GOARCH == "arm64" {
|
||||||
|
probe.Model = "ARM64 CPU"
|
||||||
|
}
|
||||||
if probe.Model == "" {
|
if probe.Model == "" {
|
||||||
probe.Model = "Unknown"
|
probe.Model = "Unknown"
|
||||||
}
|
}
|
||||||
return probe
|
return probe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func meaningfulCPUModel(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(value); err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
return lower != "unknown" && lower != "not specified"
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichCPUProbeFromLscpu(probe *HostCPUProbe, armImplementer *string, armPart *string) {
|
||||||
|
out := runCommandOutput(2*time.Second, "lscpu")
|
||||||
|
if out == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
fields := strings.SplitN(line, ":", 2)
|
||||||
|
if len(fields) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(fields[0]))
|
||||||
|
value := strings.TrimSpace(fields[1])
|
||||||
|
switch key {
|
||||||
|
case "model name":
|
||||||
|
if probe.Model == "" && meaningfulCPUModel(value) {
|
||||||
|
probe.Model = value
|
||||||
|
}
|
||||||
|
case "cpu(s)":
|
||||||
|
if threads, err := strconv.Atoi(value); err == nil && threads > probe.Threads {
|
||||||
|
probe.Threads = threads
|
||||||
|
}
|
||||||
|
case "core(s) per socket":
|
||||||
|
if cores, err := strconv.Atoi(value); err == nil && cores > 0 {
|
||||||
|
probe.Cores = cores
|
||||||
|
}
|
||||||
|
case "socket(s)":
|
||||||
|
if sockets, err := strconv.Atoi(value); err == nil && sockets > 1 && probe.Cores > 0 {
|
||||||
|
probe.Cores *= sockets
|
||||||
|
}
|
||||||
|
case "virtualization":
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
if value != "" && lower != "none" && lower != "n/a" {
|
||||||
|
probe.Virtualization = true
|
||||||
|
probe.VirtualizationKey = value
|
||||||
|
}
|
||||||
|
case "flags":
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, flag := range probe.Flags {
|
||||||
|
seen[flag] = true
|
||||||
|
}
|
||||||
|
for _, flag := range strings.Fields(value) {
|
||||||
|
if flag == "vmx" || flag == "svm" || flag == "virt" {
|
||||||
|
probe.Virtualization = true
|
||||||
|
probe.VirtualizationKey = flag
|
||||||
|
}
|
||||||
|
if !seen[flag] {
|
||||||
|
probe.Flags = append(probe.Flags, flag)
|
||||||
|
seen[flag] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(probe.Flags)
|
||||||
|
case "cpu implementer":
|
||||||
|
if *armImplementer == "" {
|
||||||
|
*armImplementer = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
case "cpu part":
|
||||||
|
if *armPart == "" {
|
||||||
|
*armPart = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func armCPUModelName(implementer, part string) string {
|
||||||
|
implementer = normalizeHexID(implementer)
|
||||||
|
part = normalizeHexID(part)
|
||||||
|
if implementer == "" || part == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
armParts := map[string]string{
|
||||||
|
"0x41:0xd03": "ARM Cortex-A53",
|
||||||
|
"0x41:0xd05": "ARM Cortex-A55",
|
||||||
|
"0x41:0xd07": "ARM Cortex-A57",
|
||||||
|
"0x41:0xd08": "ARM Cortex-A72",
|
||||||
|
"0x41:0xd09": "ARM Cortex-A73",
|
||||||
|
"0x41:0xd0a": "ARM Cortex-A75",
|
||||||
|
"0x41:0xd0b": "ARM Cortex-A76",
|
||||||
|
"0x41:0xd0c": "ARM Neoverse N1",
|
||||||
|
"0x41:0xd0d": "ARM Cortex-A77",
|
||||||
|
"0x41:0xd40": "ARM Neoverse V1",
|
||||||
|
"0x41:0xd41": "ARM Cortex-A78",
|
||||||
|
"0x41:0xd49": "ARM Neoverse N2",
|
||||||
|
"0x41:0xd4f": "ARM Neoverse V2",
|
||||||
|
}
|
||||||
|
if model := armParts[implementer+":"+part]; model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return strings.ToUpper(strings.TrimPrefix(implementer, "0x")) + " ARM CPU part " + part
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHexID(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "0x") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "0x" + value
|
||||||
|
}
|
||||||
|
|
||||||
func detectMemoryModules() []HostMemoryModule {
|
func detectMemoryModules() []HostMemoryModule {
|
||||||
if !commandExists("dmidecode") {
|
if !commandExists("dmidecode") {
|
||||||
return nil
|
return nil
|
||||||
@@ -724,7 +1040,7 @@ func isVirtualBlockDevice(name, model, vendor string) bool {
|
|||||||
}
|
}
|
||||||
for _, token := range []string{
|
for _, token := range []string{
|
||||||
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
|
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
|
||||||
"amazon elastic block store", "google persistentdisk", "microsoft",
|
"amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume",
|
||||||
} {
|
} {
|
||||||
if strings.Contains(lower, token) {
|
if strings.Contains(lower, token) {
|
||||||
return true
|
return true
|
||||||
@@ -1153,9 +1469,126 @@ func detectAllPublicIPv4() []string {
|
|||||||
result = append(result, value)
|
result = append(result, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if egress := detectEgressPublicIPv4(); egress.Address != "" {
|
||||||
|
if !seen[egress.Address] {
|
||||||
|
seen[egress.Address] = true
|
||||||
|
result = append(result, egress.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectDisplayPublicIPv4() lxc.PublicIPInfo {
|
||||||
|
if pub := lxc.DetectPublicIPv4(); pub.Address != "" {
|
||||||
|
return pub
|
||||||
|
}
|
||||||
|
return detectEgressPublicIPv4()
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectEgressPublicIPv4() lxc.PublicIPInfo {
|
||||||
|
egressIPv4Mu.Lock()
|
||||||
|
defer egressIPv4Mu.Unlock()
|
||||||
|
|
||||||
|
if cachedEgressIPv4.Address != "" && time.Since(cachedEgressIPv4At) < 5*time.Minute {
|
||||||
|
return cachedEgressIPv4
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 1200 * time.Millisecond}
|
||||||
|
for _, endpoint := range []string{
|
||||||
|
"https://api.ipify.org",
|
||||||
|
"https://ifconfig.me/ip",
|
||||||
|
"https://icanhazip.com",
|
||||||
|
} {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 128))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
cancel()
|
||||||
|
if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
address := strings.TrimSpace(string(body))
|
||||||
|
ip := net.ParseIP(address)
|
||||||
|
if !isPublicIPv4(ip) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface, gateway := detectDefaultIPv4Route()
|
||||||
|
cachedEgressIPv4 = lxc.PublicIPInfo{
|
||||||
|
Address: ip.String(),
|
||||||
|
Interface: iface,
|
||||||
|
Prefix: ip.String() + "/32",
|
||||||
|
PrefixLen: 32,
|
||||||
|
SubnetMask: "255.255.255.255",
|
||||||
|
Gateway: gateway,
|
||||||
|
IsTunnel: isTunnelLikeInterfaceName(iface),
|
||||||
|
Source: "egress",
|
||||||
|
}
|
||||||
|
cachedEgressIPv4At = time.Now()
|
||||||
|
return cachedEgressIPv4
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedEgressIPv4 = lxc.PublicIPInfo{}
|
||||||
|
cachedEgressIPv4At = time.Now()
|
||||||
|
return cachedEgressIPv4
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDefaultIPv4Route() (string, string) {
|
||||||
|
out := runCommandOutput(2*time.Second, "ip", "-4", "route", "show", "default")
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := ""
|
||||||
|
gateway := ""
|
||||||
|
for i, field := range fields {
|
||||||
|
if field == "dev" && i+1 < len(fields) {
|
||||||
|
iface = fields[i+1]
|
||||||
|
}
|
||||||
|
if field == "via" && i+1 < len(fields) {
|
||||||
|
gateway = fields[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if iface != "" || gateway != "" {
|
||||||
|
return iface, gateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDefaultIPv6Route() (string, string) {
|
||||||
|
out := runCommandOutput(2*time.Second, "ip", "-6", "route", "show", "default")
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
iface := ""
|
||||||
|
gateway := ""
|
||||||
|
for i, field := range fields {
|
||||||
|
if field == "dev" && i+1 < len(fields) {
|
||||||
|
iface = fields[i+1]
|
||||||
|
}
|
||||||
|
if field == "via" && i+1 < len(fields) {
|
||||||
|
gateway = fields[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if iface != "" || gateway != "" {
|
||||||
|
return iface, gateway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
|
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
|
||||||
result := make([]HostIPProbe, 0)
|
result := make([]HostIPProbe, 0)
|
||||||
for _, nic := range nics {
|
for _, nic := range nics {
|
||||||
@@ -1301,6 +1734,16 @@ func isContainerLikeInterfaceName(iface string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTunnelLikeInterfaceName(iface string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(iface))
|
||||||
|
for _, prefix := range []string{"tun", "tap", "wg", "gre", "gretap", "sit", "ip6tnl", "he-", "zt", "tailscale"} {
|
||||||
|
if lower == prefix || strings.HasPrefix(lower, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
|
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
|
||||||
result := make([]HostIPProbe, 0)
|
result := make([]HostIPProbe, 0)
|
||||||
for _, nic := range nics {
|
for _, nic := range nics {
|
||||||
@@ -1368,6 +1811,8 @@ func detectGPUVendor(value string) string {
|
|||||||
return "NVIDIA"
|
return "NVIDIA"
|
||||||
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
|
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
|
||||||
return "AMD"
|
return "AMD"
|
||||||
|
case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"):
|
||||||
|
return "Virtio"
|
||||||
default:
|
default:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
@@ -1375,6 +1820,9 @@ func detectGPUVendor(value string) string {
|
|||||||
|
|
||||||
func detectGPUType(value string) string {
|
func detectGPUType(value string) string {
|
||||||
lower := strings.ToLower(value)
|
lower := strings.ToLower(value)
|
||||||
|
if strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu") {
|
||||||
|
return "virtual"
|
||||||
|
}
|
||||||
if strings.Contains(lower, "intel") {
|
if strings.Contains(lower, "intel") {
|
||||||
return "integrated"
|
return "integrated"
|
||||||
}
|
}
|
||||||
@@ -1394,9 +1842,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
|
|||||||
devKVM := fileExists("/dev/kvm")
|
devKVM := fileExists("/dev/kvm")
|
||||||
nested, detail := detectNestedVirtualization()
|
nested, detail := detectNestedVirtualization()
|
||||||
lxcOK := envCheckOK(env, "lxc-create")
|
lxcOK := envCheckOK(env, "lxc-create")
|
||||||
|
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
|
||||||
probe := HostRuntimeProbe{
|
probe := HostRuntimeProbe{
|
||||||
LXCAvailable: lxcOK,
|
LXCAvailable: lxcOK,
|
||||||
KVMAvailable: devKVM && envCheckOK(env, "virsh"),
|
KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
|
||||||
DevKVM: devKVM,
|
DevKVM: devKVM,
|
||||||
NestedVirtualization: nested,
|
NestedVirtualization: nested,
|
||||||
NestedDetail: detail,
|
NestedDetail: detail,
|
||||||
@@ -1446,6 +1895,7 @@ func detectSystemProbe() HostSystemProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func detectHostEnvironment() []HostEnvCheck {
|
func detectHostEnvironment() []HostEnvCheck {
|
||||||
|
qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "")
|
||||||
checks := []HostEnvCheck{
|
checks := []HostEnvCheck{
|
||||||
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
|
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
|
||||||
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
|
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
|
||||||
@@ -1454,7 +1904,7 @@ func detectHostEnvironment() []HostEnvCheck {
|
|||||||
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
|
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
|
||||||
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
|
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
|
||||||
commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
|
commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
|
||||||
commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""),
|
qemuCheck,
|
||||||
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
|
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
|
||||||
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
|
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
|
||||||
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
|
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
|
||||||
@@ -1467,6 +1917,19 @@ func detectHostEnvironment() []HostEnvCheck {
|
|||||||
return checks
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func kvmQEMUCheckKey() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return "qemu-system-aarch64"
|
||||||
|
default:
|
||||||
|
return "qemu-system-x86_64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvmQEMUCommand() string {
|
||||||
|
return kvmQEMUCheckKey()
|
||||||
|
}
|
||||||
|
|
||||||
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
|
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
|
||||||
ok := commandExists(cmd)
|
ok := commandExists(cmd)
|
||||||
detail := "missing"
|
detail := "missing"
|
||||||
|
|||||||
@@ -41,3 +41,37 @@ func TestCertbotVersionAtLeast54(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestARMCPUModelName(t *testing.T) {
|
||||||
|
if got := armCPUModelName("0x41", "0xd0c"); got != "ARM Neoverse N1" {
|
||||||
|
t.Fatalf("armCPUModelName() = %q, want ARM Neoverse N1", got)
|
||||||
|
}
|
||||||
|
if got := armCPUModelName("41", "d0c"); got != "ARM Neoverse N1" {
|
||||||
|
t.Fatalf("armCPUModelName() without hex prefix = %q, want ARM Neoverse N1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeaningfulCPUModel(t *testing.T) {
|
||||||
|
if meaningfulCPUModel("0") {
|
||||||
|
t.Fatal("numeric ARM processor index should not be treated as a CPU model")
|
||||||
|
}
|
||||||
|
if !meaningfulCPUModel("Neoverse-N1") {
|
||||||
|
t.Fatal("expected Neoverse-N1 to be treated as a CPU model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostTrafficInterfaceFilter(t *testing.T) {
|
||||||
|
accepted := []string{"eth0", "ens3", "enp0s6", "bond0", "wg0"}
|
||||||
|
for _, name := range accepted {
|
||||||
|
if !isHostTrafficInterface(name) {
|
||||||
|
t.Fatalf("expected %s to be accepted as a host traffic interface", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected := []string{"", "lo", "docker0", "br-3024b78640ee", "lxcbr0", "virbr0", "vethaaa9e44", "cni0"}
|
||||||
|
for _, name := range rejected {
|
||||||
|
if isHostTrafficInterface(name) {
|
||||||
|
t.Fatalf("expected %s to be rejected as an internal/container interface", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -227,9 +228,14 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
cleanupOldImageDownloadErrors()
|
cleanupOldImageDownloadErrors()
|
||||||
|
kvmAvailable := hostKVMAvailable()
|
||||||
|
|
||||||
templates := lxc.GetTemplates()
|
templates := lxc.GetTemplates()
|
||||||
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
|
kvmImages := []kvm.Image{}
|
||||||
|
if kvmAvailable {
|
||||||
|
kvmImages = kvm.GetImages()
|
||||||
|
}
|
||||||
|
images := make([]ImageInfo, 0, len(templates)+len(kvmImages))
|
||||||
for _, t := range templates {
|
for _, t := range templates {
|
||||||
dl := imageDownloadInfo(t.ID)
|
dl := imageDownloadInfo(t.ID)
|
||||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||||
@@ -252,7 +258,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, t := range kvm.GetImages() {
|
for _, t := range kvmImages {
|
||||||
dl := imageDownloadInfo(t.ID)
|
dl := imageDownloadInfo(t.ID)
|
||||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||||
manualPath := ""
|
manualPath := ""
|
||||||
@@ -309,6 +315,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !hostKVMAvailable() {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||||
ensureImageEnabled(image.ID)
|
ensureImageEnabled(image.ID)
|
||||||
clearImageDownload(image.ID)
|
clearImageDownload(image.ID)
|
||||||
@@ -531,11 +541,36 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
|
var subUser *config.SubUser
|
||||||
|
var targetContainer *config.Container
|
||||||
|
currentImageIDs := map[string]bool{}
|
||||||
|
if isSubUserRequest(r) {
|
||||||
|
subUser = subUserFromRequest(r)
|
||||||
|
if identifier := r.URL.Query().Get("container"); identifier != "" {
|
||||||
|
targetContainer = containerByIdentifier(identifier)
|
||||||
|
if targetContainer == nil || !isContainerAllowedForRequest(r, identifier) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentImageIDs[targetContainer.Template] = true
|
||||||
|
} else {
|
||||||
|
for _, id := range subUserCurrentImageIDs(subUser) {
|
||||||
|
currentImageIDs[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := make([]map[string]string, 0)
|
result := make([]map[string]string, 0)
|
||||||
if runtime == config.VirtualizationKVM {
|
if runtime == config.VirtualizationKVM {
|
||||||
|
if !hostKVMAvailable() {
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, t := range kvm.GetImages() {
|
for _, t := range kvm.GetImages() {
|
||||||
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
|
||||||
result = append(result, map[string]string{
|
result = append(result, map[string]string{
|
||||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||||
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
|
"description": t.Description, "type": config.VirtualizationKVM, "desktop": t.Desktop,
|
||||||
@@ -544,7 +579,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, t := range lxc.GetTemplates() {
|
for _, t := range lxc.GetTemplates() {
|
||||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
if subUser != nil && !isImageAllowedForSubUser(subUser, targetContainer, t.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if downloaded := isImageDownloaded(t.Distro, t.Release, t.Arch); downloaded && (enabledSet[t.ID] || currentImageIDs[t.ID]) {
|
||||||
result = append(result, map[string]string{
|
result = append(result, map[string]string{
|
||||||
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||||
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||||
@@ -560,9 +598,48 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
|
|||||||
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
|
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func imageTemplateExists(templateID string) bool {
|
||||||
|
return lxc.FindTemplate(templateID) != nil || kvm.FindImage(templateID) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageDownloadedForRuntime(templateID string, runtime string) bool {
|
||||||
|
runtime = runtimeFromRequest(runtime)
|
||||||
|
if runtime == config.VirtualizationKVM {
|
||||||
|
if !hostKVMAvailable() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
image := kvm.FindImage(templateID)
|
||||||
|
if image == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
|
||||||
|
return downloaded
|
||||||
|
}
|
||||||
|
tmpl := lxc.FindTemplate(templateID)
|
||||||
|
if tmpl == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTemplateAvailableForRequest(r *http.Request, c *config.Container, templateID string, runtime string) bool {
|
||||||
|
if isSubUserRequest(r) {
|
||||||
|
if !isTemplateAllowedForRequest(r, c, templateID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c != nil && c.Template == templateID {
|
||||||
|
return isImageDownloadedForRuntime(templateID, runtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isImageEnabledAndDownloaded(templateID, runtime)
|
||||||
|
}
|
||||||
|
|
||||||
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||||
runtime = runtimeFromRequest(runtime)
|
runtime = runtimeFromRequest(runtime)
|
||||||
if runtime == config.VirtualizationKVM {
|
if runtime == config.VirtualizationKVM {
|
||||||
|
if !hostKVMAvailable() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
image := kvm.FindImage(templateID)
|
image := kvm.FindImage(templateID)
|
||||||
if image == nil {
|
if image == nil {
|
||||||
return false
|
return false
|
||||||
@@ -579,6 +656,13 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
|||||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hostKVMAvailable() bool {
|
||||||
|
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fileExists("/dev/kvm") && commandExists("virsh") && commandExists(kvmQEMUCheckKey())
|
||||||
|
}
|
||||||
|
|
||||||
func ensureImageEnabled(id string) {
|
func ensureImageEnabled(id string) {
|
||||||
// If the enabled list is empty, all templates are currently enabled by default.
|
// If the enabled list is empty, all templates are currently enabled by default.
|
||||||
// We must populate the list with all template IDs first so that explicit toggles stick.
|
// We must populate the list with all template IDs first so that explicit toggles stick.
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ type routeCapacity struct {
|
|||||||
Total string `json:"total"`
|
Total string `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nat4PortRange struct {
|
||||||
|
Start int `json:"start"`
|
||||||
|
End int `json:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
type nat4Route struct {
|
type nat4Route struct {
|
||||||
ContainerID int `json:"container_id"`
|
ContainerID int `json:"container_id"`
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -41,6 +46,19 @@ type ipv4Route struct {
|
|||||||
Gateway string `json:"gateway,omitempty"`
|
Gateway string `json:"gateway,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type lanDHCPRoute struct {
|
||||||
|
ContainerID int `json:"container_id"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
LXCName string `json:"lxc_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
PrefixLen int `json:"prefix_len,omitempty"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
MACAddress string `json:"mac_address,omitempty"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
type ipv6Route struct {
|
type ipv6Route struct {
|
||||||
ContainerID int `json:"container_id"`
|
ContainerID int `json:"container_id"`
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
@@ -53,11 +71,14 @@ type ipv6Route struct {
|
|||||||
|
|
||||||
type routingResponse struct {
|
type routingResponse struct {
|
||||||
NAT4 routeCapacity `json:"nat4"`
|
NAT4 routeCapacity `json:"nat4"`
|
||||||
|
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||||
IPv4 routeCapacity `json:"ipv4"`
|
IPv4 routeCapacity `json:"ipv4"`
|
||||||
|
LANDHCP routeCapacity `json:"lan_dhcp"`
|
||||||
IPv6 routeCapacity `json:"ipv6"`
|
IPv6 routeCapacity `json:"ipv6"`
|
||||||
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
|
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
|
||||||
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
|
||||||
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
|
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
|
||||||
|
LANDHCPAssignments []lanDHCPRoute `json:"lan_dhcp_assignments"`
|
||||||
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
NAT4Mappings []nat4Route `json:"nat4_mappings"`
|
||||||
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
|
||||||
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
|
||||||
@@ -67,6 +88,7 @@ type routingPoolsRequest struct {
|
|||||||
Addresses *[]string `json:"addresses"`
|
Addresses *[]string `json:"addresses"`
|
||||||
Items *[]config.PublicIPv4Assignment `json:"items"`
|
Items *[]config.PublicIPv4Assignment `json:"items"`
|
||||||
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
|
||||||
|
NAT4PortRange *nat4PortRange `json:"nat4_port_range"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type publicIPv4ScanRequest struct {
|
type publicIPv4ScanRequest struct {
|
||||||
@@ -118,15 +140,15 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
nat4Mappings := make([]nat4Route, 0)
|
nat4Mappings := make([]nat4Route, 0)
|
||||||
usedPorts := map[int]bool{}
|
usedPorts := map[int]bool{}
|
||||||
ipv4Assignments := make([]ipv4Route, 0)
|
ipv4Assignments := make([]ipv4Route, 0)
|
||||||
|
lanDHCPAssignments := make([]lanDHCPRoute, 0)
|
||||||
ipv6Assignments := make([]ipv6Route, 0)
|
ipv6Assignments := make([]ipv6Route, 0)
|
||||||
|
|
||||||
const nat4StartPort = 20000
|
nat4StartPort, nat4EndPort := config.NATPortRange()
|
||||||
const nat4EndPort = 65535
|
|
||||||
|
|
||||||
for i := range config.AppConfig.Containers {
|
for i := range config.AppConfig.Containers {
|
||||||
c := &config.AppConfig.Containers[i]
|
c := &config.AppConfig.Containers[i]
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
|
if config.NATPortInRange(pm.HostPort) {
|
||||||
usedPorts[pm.HostPort] = true
|
usedPorts[pm.HostPort] = true
|
||||||
}
|
}
|
||||||
nat4Mappings = append(nat4Mappings, nat4Route{
|
nat4Mappings = append(nat4Mappings, nat4Route{
|
||||||
@@ -158,6 +180,20 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
c.NormalizeNetworkAssignments()
|
c.NormalizeNetworkAssignments()
|
||||||
|
if c.UsesLANIPv4() {
|
||||||
|
lanDHCPAssignments = append(lanDHCPAssignments, lanDHCPRoute{
|
||||||
|
ContainerID: c.ID,
|
||||||
|
ContainerName: c.Name,
|
||||||
|
LXCName: c.LxcName(),
|
||||||
|
Status: c.Status,
|
||||||
|
Address: c.IP,
|
||||||
|
Interface: c.LANInterface,
|
||||||
|
PrefixLen: c.LANIPv4PrefixLen,
|
||||||
|
Gateway: c.LANIPv4Gateway,
|
||||||
|
MACAddress: c.MACAddress,
|
||||||
|
Mode: c.LANIPv4Mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
for _, ip := range c.IPv6Addresses {
|
for _, ip := range c.IPv6Addresses {
|
||||||
if ip.Address == "" {
|
if ip.Address == "" {
|
||||||
continue
|
continue
|
||||||
@@ -185,11 +221,17 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
|
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
|
||||||
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
|
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
|
||||||
})
|
})
|
||||||
|
sort.SliceStable(lanDHCPAssignments, func(i, j int) bool {
|
||||||
|
if lanDHCPAssignments[i].Interface == lanDHCPAssignments[j].Interface {
|
||||||
|
return lanDHCPAssignments[i].ContainerName < lanDHCPAssignments[j].ContainerName
|
||||||
|
}
|
||||||
|
return lanDHCPAssignments[i].Interface < lanDHCPAssignments[j].Interface
|
||||||
|
})
|
||||||
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
|
||||||
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalNAT4Ports = nat4EndPort - nat4StartPort + 1
|
totalNAT4Ports := config.NATPortCapacity()
|
||||||
nat4Used := len(usedPorts)
|
nat4Used := len(usedPorts)
|
||||||
nat4Remaining := totalNAT4Ports - nat4Used
|
nat4Remaining := totalNAT4Ports - nat4Used
|
||||||
if nat4Remaining < 0 {
|
if nat4Remaining < 0 {
|
||||||
@@ -216,11 +258,20 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
Remaining: strconv.Itoa(nat4Remaining),
|
Remaining: strconv.Itoa(nat4Remaining),
|
||||||
Total: strconv.Itoa(totalNAT4Ports),
|
Total: strconv.Itoa(totalNAT4Ports),
|
||||||
},
|
},
|
||||||
|
NAT4PortRange: nat4PortRange{
|
||||||
|
Start: nat4StartPort,
|
||||||
|
End: nat4EndPort,
|
||||||
|
},
|
||||||
IPv4: routeCapacity{
|
IPv4: routeCapacity{
|
||||||
Used: ipv4Used,
|
Used: ipv4Used,
|
||||||
Remaining: strconv.Itoa(ipv4Remaining),
|
Remaining: strconv.Itoa(ipv4Remaining),
|
||||||
Total: strconv.Itoa(ipv4Total),
|
Total: strconv.Itoa(ipv4Total),
|
||||||
},
|
},
|
||||||
|
LANDHCP: routeCapacity{
|
||||||
|
Used: len(lanDHCPAssignments),
|
||||||
|
Remaining: "DHCP",
|
||||||
|
Total: "DHCP",
|
||||||
|
},
|
||||||
IPv6: routeCapacity{
|
IPv6: routeCapacity{
|
||||||
Used: len(ipv6Assignments),
|
Used: len(ipv6Assignments),
|
||||||
Remaining: ipv6Remaining,
|
Remaining: ipv6Remaining,
|
||||||
@@ -229,6 +280,7 @@ func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
HostPublicIPv4: hostPublicIPv4,
|
HostPublicIPv4: hostPublicIPv4,
|
||||||
PublicIPv4Addresses: publicIPv4s,
|
PublicIPv4Addresses: publicIPv4s,
|
||||||
IPv4Assignments: ipv4Assignments,
|
IPv4Assignments: ipv4Assignments,
|
||||||
|
LANDHCPAssignments: lanDHCPAssignments,
|
||||||
NAT4Mappings: nat4Mappings,
|
NAT4Mappings: nat4Mappings,
|
||||||
IPv6Assignments: ipv6Assignments,
|
IPv6Assignments: ipv6Assignments,
|
||||||
IPv6Prefixes: prefixes,
|
IPv6Prefixes: prefixes,
|
||||||
@@ -246,6 +298,19 @@ func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if req.Items != nil || req.Addresses != nil {
|
||||||
items := []config.PublicIPv4Assignment{}
|
items := []config.PublicIPv4Assignment{}
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func runtimeFromRequest(value string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
|
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
|
||||||
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
|
return cfg.WantsNAT() || cfg.WantsLANIPv4() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func runtimeFromTemplateID(templateID string) string {
|
func runtimeFromTemplateID(templateID string) string {
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ type trafficStats struct {
|
|||||||
portTotalCounts map[int]int
|
portTotalCounts map[int]int
|
||||||
udpDestCounts map[int]map[string]int
|
udpDestCounts map[int]map[string]int
|
||||||
udpTotalCounts map[int]int
|
udpTotalCounts map[int]int
|
||||||
|
udpDestTotalCounts map[string]int
|
||||||
synSentByDst 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
|
var scanner *SecurityScanner
|
||||||
@@ -239,6 +243,10 @@ func newTrafficStats() *trafficStats {
|
|||||||
udpDestCounts: make(map[int]map[string]int),
|
udpDestCounts: make(map[int]map[string]int),
|
||||||
udpTotalCounts: make(map[int]int),
|
udpTotalCounts: make(map[int]int),
|
||||||
synSentByDst: make(map[string]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.udpDestCounts[conn.dstPort][conn.dstIP]++
|
||||||
ts.udpTotalCounts[conn.dstPort]++
|
ts.udpTotalCounts[conn.dstPort]++
|
||||||
|
ts.udpDestTotalCounts[conn.dstIP]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn.state == "SYN_SENT" {
|
if conn.proto == "tcp" && conn.state == "SYN_SENT" {
|
||||||
ts.totalSynSent++
|
ts.totalSynSent++
|
||||||
ts.synSentByDst[conn.dstIP]++
|
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) {
|
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
|
||||||
for dstIP, portCounts := range stats.destPorts {
|
for dstIP, portCounts := range stats.tcpSynDestPorts {
|
||||||
uniquePorts := len(portCounts)
|
uniquePorts := len(portCounts)
|
||||||
switch {
|
switch {
|
||||||
case uniquePorts >= 20:
|
case uniquePorts >= 25:
|
||||||
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
|
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,
|
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)
|
uniqueTargets := len(targets)
|
||||||
if service, ok := bruteForcePorts[port]; ok {
|
if service, ok := bruteForcePorts[port]; ok {
|
||||||
if uniqueTargets >= 30 {
|
if uniqueTargets >= 30 {
|
||||||
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
|
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,
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if uniqueTargets >= 40 {
|
if uniqueTargets >= 50 {
|
||||||
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
|
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,
|
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
|
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,
|
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,
|
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) {
|
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
|
||||||
targets := len(stats.destCounts)
|
targets := len(stats.destCounts)
|
||||||
switch {
|
switch {
|
||||||
case targets >= 100:
|
case targets >= 120 && stats.total >= 600:
|
||||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
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,
|
ss.addAlert(name, "ddos", "high", ip, "*", 0,
|
||||||
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
|
fmt.Sprintf("大量对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
|
||||||
"")
|
"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
synTargets := len(stats.synSentByDst)
|
||||||
switch {
|
switch {
|
||||||
case stats.total >= 500:
|
case stats.totalSynSent >= 250 || (synTargets >= 80 && stats.totalSynSent >= 160):
|
||||||
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
|
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,
|
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,
|
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
|
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,
|
ss.addAlert(name, "reflection", "critical", ip, "*", port,
|
||||||
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
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,
|
ss.addAlert(name, "reflection", "high", ip, "*", port,
|
||||||
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
|
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) {
|
func autoShutdownAlertContainer(containerName, alertType, severity string) {
|
||||||
|
if !config.AppConfig.SecurityAutoShutdown {
|
||||||
|
return
|
||||||
|
}
|
||||||
c := config.FindContainerByName(containerName)
|
c := config.FindContainerByName(containerName)
|
||||||
if c == nil || c.Status != "running" {
|
if c == nil || c.Status != "running" {
|
||||||
return
|
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.
|
// HandleSecurityAlerts returns all security alerts.
|
||||||
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
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()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
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, "")
|
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{
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
|
||||||
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
|
||||||
|
"cancelled_tasks": cancelledTasks,
|
||||||
|
"cleared_blocks": clearedBlocks,
|
||||||
}})
|
}})
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
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{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ type subUserResponse struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
|
CurrentImageIDs []string `json:"current_image_ids,omitempty"`
|
||||||
AccessCode string `json:"access_code"`
|
AccessCode string `json:"access_code"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -38,6 +41,9 @@ func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
|||||||
Password: password,
|
Password: password,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
|
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||||
|
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||||
AccessCode: su.AccessCode,
|
AccessCode: su.AccessCode,
|
||||||
CreatedAt: su.CreatedAt,
|
CreatedAt: su.CreatedAt,
|
||||||
}
|
}
|
||||||
@@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||||
|
if !su.ImageLimitConfigured && len(su.AllowedImageIDs) == 0 {
|
||||||
|
su.AllowedImageIDs = effectiveContainerAllowedImageIDs(c)
|
||||||
|
su.ImageLimitConfigured = true
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -120,6 +130,8 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
PassHash: string(hash),
|
PassHash: string(hash),
|
||||||
ContainerNames: []string{containerName},
|
ContainerNames: []string{containerName},
|
||||||
ContainerUUIDs: []string{c.UUID},
|
ContainerUUIDs: []string{c.UUID},
|
||||||
|
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
|
||||||
|
ImageLimitConfigured: true,
|
||||||
AccessCode: accessCode,
|
AccessCode: accessCode,
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
@@ -306,6 +318,155 @@ func requestAllowedContainers(r *http.Request) (subUserAccess, bool) {
|
|||||||
return subUserAllowedContainers(r)
|
return subUserAllowedContainers(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func subUserFromRequest(r *http.Request) *config.SubUser {
|
||||||
|
username := ""
|
||||||
|
if ctx, ok := authContextFromRequest(r); ok && ctx.Type == authTypeSubUser {
|
||||||
|
username = ctx.Username
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
if claims, ok := claimsFromRequest(r); ok {
|
||||||
|
username, _ = claims["sub_user"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range config.AppConfig.SubUsers {
|
||||||
|
if config.AppConfig.SubUsers[i].Username == username {
|
||||||
|
return &config.AppConfig.SubUsers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAllowedImageIDs(ids []string) ([]string, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !imageTemplateExists(id) {
|
||||||
|
return nil, fmt.Errorf("unknown image template: %s", id)
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTemplateAllowedForRequest(r *http.Request, c *config.Container, templateID string) bool {
|
||||||
|
if !isSubUserRequest(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isImageAllowedForSubUser(subUserFromRequest(r), c, templateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageAllowedForSubUser(su *config.SubUser, c *config.Container, templateID string) bool {
|
||||||
|
if su == nil || strings.TrimSpace(templateID) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, id := range effectiveSubUserAllowedImageIDs(su) {
|
||||||
|
if id == templateID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveContainerAllowedImageIDs(c *config.Container) []string {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.ImageLimitConfigured || len(c.AllowedImageIDs) > 0 {
|
||||||
|
return cleanImageIDList(c.AllowedImageIDs)
|
||||||
|
}
|
||||||
|
if c.Template != "" {
|
||||||
|
return []string{c.Template}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveSubUserAllowedImageIDs(su *config.SubUser) []string {
|
||||||
|
if su == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if su.ImageLimitConfigured || len(su.AllowedImageIDs) > 0 {
|
||||||
|
return cleanImageIDList(su.AllowedImageIDs)
|
||||||
|
}
|
||||||
|
result := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range subUserAssignedContainers(su) {
|
||||||
|
for _, id := range effectiveContainerAllowedImageIDs(c) {
|
||||||
|
if id != "" && !seen[id] {
|
||||||
|
seen[id] = true
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanImageIDList(ids []string) []string {
|
||||||
|
result := make([]string, 0, len(ids))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func subUserCurrentImageIDs(su *config.SubUser) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := []string{}
|
||||||
|
for _, c := range subUserAssignedContainers(su) {
|
||||||
|
if c.Template != "" && !seen[c.Template] {
|
||||||
|
seen[c.Template] = true
|
||||||
|
result = append(result, c.Template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func subUserAssignedContainers(su *config.SubUser) []*config.Container {
|
||||||
|
if su == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := []*config.Container{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, uuid := range su.ContainerUUIDs {
|
||||||
|
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||||
|
key := c.UUID
|
||||||
|
if key == "" {
|
||||||
|
key = c.Name
|
||||||
|
}
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
result = append(result, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range su.ContainerNames {
|
||||||
|
if c := config.FindContainerByName(name); c != nil {
|
||||||
|
key := c.UUID
|
||||||
|
if key == "" {
|
||||||
|
key = c.Name
|
||||||
|
}
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
result = append(result, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func isAccessRestrictedRequest(r *http.Request) bool {
|
func isAccessRestrictedRequest(r *http.Request) bool {
|
||||||
_, restricted := requestAllowedContainers(r)
|
_, restricted := requestAllowedContainers(r)
|
||||||
return restricted
|
return restricted
|
||||||
@@ -485,7 +646,7 @@ func isSubUserBlockedAction(action string, method string) bool {
|
|||||||
return method != http.MethodGet
|
return method != http.MethodGet
|
||||||
}
|
}
|
||||||
switch action {
|
switch action {
|
||||||
case "usage", "traffic":
|
case "usage", "traffic", "history":
|
||||||
return method != http.MethodGet
|
return method != http.MethodGet
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -504,7 +665,7 @@ func isSubUserContainerActionAllowed(action string, method string) bool {
|
|||||||
return method == http.MethodGet
|
return method == http.MethodGet
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case action == "usage" || action == "traffic" || action == "random-port":
|
case action == "usage" || action == "traffic" || action == "history" || action == "random-port":
|
||||||
return method == http.MethodGet
|
return method == http.MethodGet
|
||||||
case action == "snapshots":
|
case action == "snapshots":
|
||||||
return method == http.MethodGet || method == http.MethodPost
|
return method == http.MethodGet || method == http.MethodPost
|
||||||
@@ -584,6 +745,9 @@ type SubUserListItem struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids"`
|
ContainerUUIDs []string `json:"container_uuids"`
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids"`
|
||||||
|
ImageLimitConfigured bool `json:"image_limit_configured"`
|
||||||
|
CurrentImageIDs []string `json:"current_image_ids"`
|
||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
ContainerUUID string `json:"container_uuid"`
|
ContainerUUID string `json:"container_uuid"`
|
||||||
AccessCode string `json:"access_code"`
|
AccessCode string `json:"access_code"`
|
||||||
@@ -611,6 +775,9 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
|||||||
Username: su.Username,
|
Username: su.Username,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
|
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||||
|
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||||
AccessCode: su.AccessCode,
|
AccessCode: su.AccessCode,
|
||||||
Password: su.Password,
|
Password: su.Password,
|
||||||
CreatedAt: su.CreatedAt,
|
CreatedAt: su.CreatedAt,
|
||||||
@@ -711,6 +878,28 @@ func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
logs := filterSubUserLoginLogs(target.Username)
|
logs := filterSubUserLoginLogs(target.Username)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|
||||||
|
case action == "images" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "subuser:update") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := normalizeAllowedImageIDs(req.AllowedImageIDs)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.AllowedImageIDs = ids
|
||||||
|
target.ImageLimitConfigured = true
|
||||||
|
target.TokenVersion++
|
||||||
|
config.SaveConfig()
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: newSubUserResponse(*target, target.Password)})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,10 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
|
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
|
||||||
|
if !config.AppConfig.SecurityAutoShutdown {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
@@ -230,6 +234,34 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
|
|||||||
return taskID, true
|
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.
|
// 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
|
// If a restored task already has a same-name container in config, it resumes
|
||||||
// initialization instead of creating another ct-{id}.
|
// initialization instead of creating another ct-{id}.
|
||||||
@@ -324,6 +356,7 @@ func (q *TaskQueue) opWorker() {
|
|||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
skipped := false
|
||||||
err = resolveTaskContainer(task)
|
err = resolveTaskContainer(task)
|
||||||
// Block operations on expired or traffic-exceeded containers (except stop/delete)
|
// Block operations on expired or traffic-exceeded containers (except stop/delete)
|
||||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||||
@@ -336,7 +369,11 @@ func (q *TaskQueue) opWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||||
|
skipped = true
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
if !skipped {
|
||||||
switch task.Type {
|
switch task.Type {
|
||||||
case TaskStart:
|
case TaskStart:
|
||||||
err = startByRuntime(task.ContainerID)
|
err = startByRuntime(task.ContainerID)
|
||||||
@@ -360,6 +397,7 @@ func (q *TaskQueue) opWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
auditUser := task.User
|
auditUser := task.User
|
||||||
@@ -370,6 +408,9 @@ func (q *TaskQueue) opWorker() {
|
|||||||
task.Status = "failed"
|
task.Status = "failed"
|
||||||
task.Error = err.Error()
|
task.Error = err.Error()
|
||||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, 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 {
|
} else {
|
||||||
task.Status = "done"
|
task.Status = "done"
|
||||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
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) {
|
func clearPolicyBlockAfterAdminRecovery(task *Task) {
|
||||||
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
|
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
|
||||||
return
|
return
|
||||||
@@ -515,7 +560,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
if c := config.FindContainer(id); c != nil {
|
if c := config.FindContainer(id); c != nil {
|
||||||
runtime = c.Runtime()
|
runtime = c.Runtime()
|
||||||
}
|
}
|
||||||
if !isImageEnabledAndDownloaded(templateID, runtime) {
|
if !isTemplateAllowedForRequest(r, c, templateID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not allowed for this user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isTemplateAvailableForRequest(r, c, templateID, runtime) {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -601,6 +650,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
req.Containers[i].NormalizeResourceAliases()
|
req.Containers[i].NormalizeResourceAliases()
|
||||||
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
|
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
|
||||||
|
if req.Containers[i].WantsLANIPv4() && req.Containers[i].Virtualization != config.VirtualizationLXC {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": LAN IPv4 is only supported for LXC containers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.Containers[i].RAMMB < 128 {
|
if req.Containers[i].RAMMB < 128 {
|
||||||
req.Containers[i].RAMMB = 512
|
req.Containers[i].RAMMB = 512
|
||||||
}
|
}
|
||||||
@@ -611,6 +664,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ids, err := normalizeAllowedImageIDs(req.Containers[i].AllowedImageIDs); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
req.Containers[i].AllowedImageIDs = ids
|
||||||
|
}
|
||||||
if req.Containers[i].PortMappingCount < 0 {
|
if req.Containers[i].PortMappingCount < 0 {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
|
||||||
return
|
return
|
||||||
@@ -732,6 +791,10 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if taskType == TaskReinstall && !isTemplateAllowedForRequest(r, c, req.TemplateID) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: c.Name + ": template is not allowed for this user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if taskConfig != nil {
|
if taskConfig != nil {
|
||||||
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
|
||||||
@@ -817,6 +880,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
// RestoreTasks restores task queue from config
|
// RestoreTasks restores task queue from config
|
||||||
func RestoreTasks() {
|
func RestoreTasks() {
|
||||||
for _, st := range config.AppConfig.Tasks {
|
for _, st := range config.AppConfig.Tasks {
|
||||||
|
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var cfg lxc.ContainerConfig
|
var cfg lxc.ContainerConfig
|
||||||
if st.Config != "" {
|
if st.Config != "" {
|
||||||
json.Unmarshal([]byte(st.Config), &cfg)
|
json.Unmarshal([]byte(st.Config), &cfg)
|
||||||
|
|||||||
+28
-12
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -127,7 +128,9 @@ var cliTranslations = map[string]string{
|
|||||||
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
|
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
|
||||||
"最新版本": "Latest version",
|
"最新版本": "Latest version",
|
||||||
"发布页面": "Release page",
|
"发布页面": "Release page",
|
||||||
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.",
|
"当前架构不支持自动升级": "Automatic upgrade is not supported on the current architecture",
|
||||||
|
"最新 Release 没有找到": "The latest release does not contain",
|
||||||
|
"无法自动升级。": "automatic upgrade is unavailable.",
|
||||||
"当前已经是最新版本。": "The current version is already the latest.",
|
"当前已经是最新版本。": "The current version is already the latest.",
|
||||||
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
||||||
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
||||||
@@ -557,11 +560,16 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
|||||||
if repo == "" {
|
if repo == "" {
|
||||||
repo = version.Repo
|
repo = version.Repo
|
||||||
}
|
}
|
||||||
|
assetName, err := releaseArchiveAssetName(runtime.GOARCH)
|
||||||
|
if err != nil {
|
||||||
|
cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
|
||||||
|
return
|
||||||
|
}
|
||||||
current := version.Current()
|
current := version.Current()
|
||||||
cliPrintf("当前版本: %s\n", current)
|
cliPrintf("当前版本: %s\n", current)
|
||||||
cliPrintf("检查仓库: https://github.com/%s\n", repo)
|
cliPrintf("检查仓库: https://github.com/%s\n", repo)
|
||||||
|
|
||||||
release, err := fetchLatestRelease(repo)
|
release, err := fetchLatestRelease(repo, assetName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
|
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
|
||||||
return
|
return
|
||||||
@@ -576,9 +584,9 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
|||||||
cliPrintf("发布页面: %s\n", release.HTMLURL)
|
cliPrintf("发布页面: %s\n", release.HTMLURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
|
assetURL := findReleaseAsset(release, assetName)
|
||||||
if assetURL == "" {
|
if assetURL == "" {
|
||||||
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
|
cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +605,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
|
if err := upgradeFromReleaseAsset(assetURL, latest, assetName); err != nil {
|
||||||
cliPrintf("升级失败: %v\n", err)
|
cliPrintf("升级失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -605,7 +613,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
|||||||
cliPrintln("原有数据已保留,Web 服务已重启。")
|
cliPrintln("原有数据已保留,Web 服务已重启。")
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchLatestRelease(repo string) (*githubRelease, error) {
|
func fetchLatestRelease(repo, assetName string) (*githubRelease, error) {
|
||||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -617,7 +625,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
|||||||
client := &http.Client{Timeout: 20 * time.Second}
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
|
if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
|
||||||
return fallback, nil
|
return fallback, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -627,7 +635,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
|
if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
|
||||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||||
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
|
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
|
||||||
} else {
|
} else {
|
||||||
@@ -645,7 +653,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
|||||||
return &release, nil
|
return &release, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
|
func fetchLatestReleaseFallback(repo, assetName string) (*githubRelease, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -667,7 +675,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
|
|||||||
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
|
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetName = "clicd-linux-amd64.tar.gz"
|
|
||||||
return &githubRelease{
|
return &githubRelease{
|
||||||
TagName: tag,
|
TagName: tag,
|
||||||
Name: tag,
|
Name: tag,
|
||||||
@@ -708,6 +715,15 @@ func setGitHubRequestHeaders(req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func releaseArchiveAssetName(goarch string) (string, error) {
|
||||||
|
switch goarch {
|
||||||
|
case "amd64", "arm64":
|
||||||
|
return fmt.Sprintf("clicd-linux-%s.tar.gz", goarch), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported architecture: %s", goarch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func findReleaseAsset(release *githubRelease, name string) string {
|
func findReleaseAsset(release *githubRelease, name string) string {
|
||||||
for _, asset := range release.Assets {
|
for _, asset := range release.Assets {
|
||||||
if asset.Name == name && asset.BrowserDownloadURL != "" {
|
if asset.Name == name && asset.BrowserDownloadURL != "" {
|
||||||
@@ -717,14 +733,14 @@ func findReleaseAsset(release *githubRelease, name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func upgradeFromReleaseAsset(assetURL, latest string) error {
|
func upgradeFromReleaseAsset(assetURL, latest, assetName string) error {
|
||||||
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
|
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
|
archivePath := filepath.Join(tmpDir, assetName)
|
||||||
cliPrintln("正在下载升级包...")
|
cliPrintln("正在下载升级包...")
|
||||||
if err := downloadFile(assetURL, archivePath); err != nil {
|
if err := downloadFile(assetURL, archivePath); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -19,6 +19,26 @@ func TestSafeReleaseBackupComponent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseArchiveAssetName(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"amd64": "clicd-linux-amd64.tar.gz",
|
||||||
|
"arm64": "clicd-linux-arm64.tar.gz",
|
||||||
|
}
|
||||||
|
for goarch, want := range tests {
|
||||||
|
got, err := releaseArchiveAssetName(goarch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("releaseArchiveAssetName(%q) error = %v", goarch, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("releaseArchiveAssetName(%q) = %q, want %q", goarch, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := releaseArchiveAssetName("386"); err == nil {
|
||||||
|
t.Fatal("releaseArchiveAssetName(386) error = nil, want unsupported architecture")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
|
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
|
||||||
unsafeNames := []string{
|
unsafeNames := []string{
|
||||||
"../clicd",
|
"../clicd",
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ type Container struct {
|
|||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
|
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||||
|
LANInterface string `json:"lan_interface,omitempty"`
|
||||||
|
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||||
|
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||||
|
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||||
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
|
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
|
||||||
IPv6 string `json:"ipv6"`
|
IPv6 string `json:"ipv6"`
|
||||||
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
IPv6PrefixLen int `json:"ipv6_prefix_len"`
|
||||||
@@ -143,6 +148,8 @@ type Container struct {
|
|||||||
FirewallEnabled bool `json:"firewall_enabled"`
|
FirewallEnabled bool `json:"firewall_enabled"`
|
||||||
FirewallDefaultAction string `json:"firewall_default_action"`
|
FirewallDefaultAction string `json:"firewall_default_action"`
|
||||||
FirewallRules []FirewallRule `json:"firewall_rules"`
|
FirewallRules []FirewallRule `json:"firewall_rules"`
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ExpiresAt string `json:"expires_at"`
|
||||||
@@ -160,6 +167,9 @@ type Container struct {
|
|||||||
const (
|
const (
|
||||||
VirtualizationLXC = "lxc"
|
VirtualizationLXC = "lxc"
|
||||||
VirtualizationKVM = "kvm"
|
VirtualizationKVM = "kvm"
|
||||||
|
|
||||||
|
LANIPv4ModeDHCP = "dhcp"
|
||||||
|
LANIPv4ModeStatic = "static"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NormalizeVirtualization(value string) string {
|
func NormalizeVirtualization(value string) string {
|
||||||
@@ -179,8 +189,56 @@ func (c *Container) IsKVM() bool {
|
|||||||
return c.Runtime() == VirtualizationKVM
|
return c.Runtime() == VirtualizationKVM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Container) UsesLANDHCP() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeDHCP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) UsesLANStaticIPv4() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(c.LANIPv4Mode), LANIPv4ModeStatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) UsesLANIPv4() bool {
|
||||||
|
return c.UsesLANDHCP() || c.UsesLANStaticIPv4()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Container) NormalizeNetworkAssignments() bool {
|
func (c *Container) NormalizeNetworkAssignments() bool {
|
||||||
changed := false
|
changed := false
|
||||||
|
lanMode := strings.ToLower(strings.TrimSpace(c.LANIPv4Mode))
|
||||||
|
if lanMode != "" && lanMode != LANIPv4ModeDHCP && lanMode != LANIPv4ModeStatic {
|
||||||
|
lanMode = ""
|
||||||
|
}
|
||||||
|
if c.LANIPv4Mode != lanMode {
|
||||||
|
c.LANIPv4Mode = lanMode
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
lanInterface := strings.TrimSpace(c.LANInterface)
|
||||||
|
if c.LANInterface != lanInterface {
|
||||||
|
c.LANInterface = lanInterface
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
lanAddress := strings.TrimSpace(c.LANIPv4Address)
|
||||||
|
if c.LANIPv4Address != lanAddress {
|
||||||
|
c.LANIPv4Address = lanAddress
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
lanGateway := strings.TrimSpace(c.LANIPv4Gateway)
|
||||||
|
if c.LANIPv4Gateway != lanGateway {
|
||||||
|
c.LANIPv4Gateway = lanGateway
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if c.LANIPv4Mode == LANIPv4ModeDHCP {
|
||||||
|
if c.LANIPv4Address != "" {
|
||||||
|
c.LANIPv4Address = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else if c.LANIPv4Mode != LANIPv4ModeStatic {
|
||||||
|
if c.LANIPv4Address != "" || c.LANIPv4PrefixLen != 0 || c.LANIPv4Gateway != "" {
|
||||||
|
c.LANIPv4Address = ""
|
||||||
|
c.LANIPv4PrefixLen = 0
|
||||||
|
c.LANIPv4Gateway = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
seenIPv4 := map[string]bool{}
|
seenIPv4 := map[string]bool{}
|
||||||
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
|
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
|
||||||
for _, item := range c.PublicIPv4s {
|
for _, item := range c.PublicIPv4s {
|
||||||
@@ -325,6 +383,8 @@ type SubUser struct {
|
|||||||
PassHash string `json:"pass_hash"`
|
PassHash string `json:"pass_hash"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
Token string `json:"-"`
|
Token string `json:"-"`
|
||||||
AccessCode string `json:"access_code"`
|
AccessCode string `json:"access_code"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
@@ -372,6 +432,8 @@ type ClicdConfig struct {
|
|||||||
NextContainerID int `json:"next_container_id"`
|
NextContainerID int `json:"next_container_id"`
|
||||||
NextVNCPort int `json:"next_vnc_port"`
|
NextVNCPort int `json:"next_vnc_port"`
|
||||||
NextSSHPort int `json:"next_ssh_port"`
|
NextSSHPort int `json:"next_ssh_port"`
|
||||||
|
NATPortStart int `json:"nat_port_start"`
|
||||||
|
NATPortEnd int `json:"nat_port_end"`
|
||||||
SetupComplete bool `json:"setup_complete"`
|
SetupComplete bool `json:"setup_complete"`
|
||||||
SubUsers []SubUser `json:"sub_users"`
|
SubUsers []SubUser `json:"sub_users"`
|
||||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||||
@@ -394,6 +456,11 @@ var AppConfig *ClicdConfig
|
|||||||
|
|
||||||
const DefaultSnapshotLimit = 3
|
const DefaultSnapshotLimit = 3
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultNATPortStart = 20000
|
||||||
|
DefaultNATPortEnd = 65535
|
||||||
|
)
|
||||||
|
|
||||||
func getConfigPath() string {
|
func getConfigPath() string {
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
@@ -509,6 +576,8 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
NextContainerID: 1,
|
NextContainerID: 1,
|
||||||
NextVNCPort: 5900,
|
NextVNCPort: 5900,
|
||||||
NextSSHPort: 22000,
|
NextSSHPort: 22000,
|
||||||
|
NATPortStart: DefaultNATPortStart,
|
||||||
|
NATPortEnd: DefaultNATPortEnd,
|
||||||
SetupComplete: false,
|
SetupComplete: false,
|
||||||
SubUsers: []SubUser{},
|
SubUsers: []SubUser{},
|
||||||
AuditLogs: []AuditLog{},
|
AuditLogs: []AuditLog{},
|
||||||
@@ -552,6 +621,9 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.NextSSHPort = 22000
|
AppConfig.NextSSHPort = 22000
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if normalizeNATPortRangeDefaults() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.NextContainerID == 0 {
|
if AppConfig.NextContainerID == 0 {
|
||||||
AppConfig.NextContainerID = 1
|
AppConfig.NextContainerID = 1
|
||||||
changed = true
|
changed = true
|
||||||
@@ -1168,16 +1240,102 @@ func UpdateVNC(containers []Container) {
|
|||||||
SaveConfig()
|
SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
func NormalizeNATPortRange(start, end int) (int, int, error) {
|
||||||
func AllocateSSHPort() int {
|
if start == 0 && end == 0 {
|
||||||
used := collectAllHostPorts()
|
return DefaultNATPortStart, DefaultNATPortEnd, nil
|
||||||
port := AppConfig.NextSSHPort
|
}
|
||||||
for used[port] {
|
if start == 0 {
|
||||||
port++
|
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
|
||||||
}
|
}
|
||||||
AppConfig.NextSSHPort = port + 1
|
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
return port
|
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)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,14 @@ type savedTaskConfig struct {
|
|||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
|
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||||
|
LANInterface string `json:"lan_interface,omitempty"`
|
||||||
|
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||||
|
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||||
|
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
@@ -203,6 +210,11 @@ func ensureSchema() error {
|
|||||||
io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT,
|
status TEXT,
|
||||||
ip TEXT,
|
ip TEXT,
|
||||||
|
lan_ipv4_mode TEXT,
|
||||||
|
lan_interface TEXT,
|
||||||
|
lan_ipv4_address TEXT,
|
||||||
|
lan_ipv4_prefix_len INTEGER,
|
||||||
|
lan_ipv4_gateway TEXT,
|
||||||
ipv6 TEXT,
|
ipv6 TEXT,
|
||||||
ipv6_prefix_len INTEGER,
|
ipv6_prefix_len INTEGER,
|
||||||
ipv6_interface TEXT,
|
ipv6_interface TEXT,
|
||||||
@@ -222,7 +234,9 @@ func ensureSchema() error {
|
|||||||
snapshot_schedule_created_by TEXT,
|
snapshot_schedule_created_by TEXT,
|
||||||
policy_blocked INTEGER,
|
policy_blocked INTEGER,
|
||||||
policy_blocked_reason TEXT,
|
policy_blocked_reason TEXT,
|
||||||
policy_blocked_at TEXT
|
policy_blocked_at TEXT,
|
||||||
|
allowed_image_ids TEXT,
|
||||||
|
image_limit_configured INTEGER NOT NULL DEFAULT 0
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS port_mappings (
|
`CREATE TABLE IF NOT EXISTS port_mappings (
|
||||||
container_id INTEGER NOT NULL,
|
container_id INTEGER NOT NULL,
|
||||||
@@ -258,7 +272,9 @@ func ensureSchema() error {
|
|||||||
pass_hash TEXT,
|
pass_hash TEXT,
|
||||||
access_code TEXT,
|
access_code TEXT,
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
token_version INTEGER
|
token_version INTEGER,
|
||||||
|
allowed_image_ids TEXT,
|
||||||
|
image_limit_configured INTEGER NOT NULL DEFAULT 0
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
|
`CREATE TABLE IF NOT EXISTS sub_user_container_names (
|
||||||
sub_user_id TEXT NOT NULL,
|
sub_user_id TEXT NOT NULL,
|
||||||
@@ -338,6 +354,11 @@ func ensureSchema() error {
|
|||||||
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||||
cfg_port_mapping_count INTEGER,
|
cfg_port_mapping_count INTEGER,
|
||||||
cfg_assign_nat INTEGER,
|
cfg_assign_nat INTEGER,
|
||||||
|
cfg_lan_ipv4_mode TEXT,
|
||||||
|
cfg_lan_interface TEXT,
|
||||||
|
cfg_lan_ipv4_address TEXT,
|
||||||
|
cfg_lan_ipv4_prefix_len INTEGER,
|
||||||
|
cfg_lan_ipv4_gateway TEXT,
|
||||||
cfg_snapshot_limit INTEGER,
|
cfg_snapshot_limit INTEGER,
|
||||||
cfg_assign_ipv4 INTEGER,
|
cfg_assign_ipv4 INTEGER,
|
||||||
cfg_ipv4_count INTEGER,
|
cfg_ipv4_count INTEGER,
|
||||||
@@ -348,6 +369,8 @@ func ensureSchema() error {
|
|||||||
cfg_ssh_auth_mode TEXT,
|
cfg_ssh_auth_mode TEXT,
|
||||||
cfg_ssh_password TEXT,
|
cfg_ssh_password TEXT,
|
||||||
cfg_ssh_public_key TEXT,
|
cfg_ssh_public_key TEXT,
|
||||||
|
cfg_allowed_image_ids TEXT,
|
||||||
|
cfg_image_limit_configured INTEGER NOT NULL DEFAULT 0,
|
||||||
cfg_expires_at TEXT
|
cfg_expires_at TEXT
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
`CREATE TABLE IF NOT EXISTS task_extra_ports (
|
||||||
@@ -410,14 +433,23 @@ func ensureSchemaMigrations() error {
|
|||||||
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
{"tasks", "cfg_ipv4_count", "INTEGER"},
|
||||||
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
{"tasks", "cfg_public_ipv4s", "TEXT"},
|
||||||
{"tasks", "cfg_assign_nat", "INTEGER"},
|
{"tasks", "cfg_assign_nat", "INTEGER"},
|
||||||
|
{"tasks", "cfg_lan_ipv4_mode", "TEXT"},
|
||||||
|
{"tasks", "cfg_lan_interface", "TEXT"},
|
||||||
|
{"tasks", "cfg_lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
||||||
|
{"tasks", "cfg_lan_ipv4_prefix_len", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"tasks", "cfg_lan_ipv4_gateway", "TEXT NOT NULL DEFAULT ''"},
|
||||||
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
{"tasks", "cfg_ipv6_count", "INTEGER"},
|
||||||
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
{"tasks", "cfg_ipv6_addresses", "TEXT"},
|
||||||
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
|
||||||
{"tasks", "cfg_ssh_password", "TEXT"},
|
{"tasks", "cfg_ssh_password", "TEXT"},
|
||||||
{"tasks", "cfg_ssh_public_key", "TEXT"},
|
{"tasks", "cfg_ssh_public_key", "TEXT"},
|
||||||
|
{"tasks", "cfg_allowed_image_ids", "TEXT"},
|
||||||
|
{"tasks", "cfg_image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"port_mappings", "host_ip", "TEXT"},
|
{"port_mappings", "host_ip", "TEXT"},
|
||||||
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
{"container_public_ipv4s", "prefix_len", "INTEGER"},
|
||||||
{"container_public_ipv4s", "gateway", "TEXT"},
|
{"container_public_ipv4s", "gateway", "TEXT"},
|
||||||
|
{"sub_users", "allowed_image_ids", "TEXT"},
|
||||||
|
{"sub_users", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
{"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
@@ -425,6 +457,13 @@ func ensureSchemaMigrations() error {
|
|||||||
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
|
||||||
{"containers", "firewall_rules", "TEXT"},
|
{"containers", "firewall_rules", "TEXT"},
|
||||||
|
{"containers", "allowed_image_ids", "TEXT"},
|
||||||
|
{"containers", "image_limit_configured", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"containers", "lan_ipv4_mode", "TEXT"},
|
||||||
|
{"containers", "lan_interface", "TEXT"},
|
||||||
|
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
||||||
|
{"containers", "lan_ipv4_prefix_len", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"containers", "lan_ipv4_gateway", "TEXT NOT NULL DEFAULT ''"},
|
||||||
} {
|
} {
|
||||||
wasAdded, err := ensureColumn(column.table, column.name, column.def)
|
wasAdded, err := ensureColumn(column.table, column.name, column.def)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -466,6 +505,18 @@ func ensureSchemaMigrations() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE containers
|
||||||
|
SET lan_ipv4_address = COALESCE(lan_ipv4_address, ''),
|
||||||
|
lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0),
|
||||||
|
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE tasks
|
||||||
|
SET cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''),
|
||||||
|
cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0),
|
||||||
|
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +575,8 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
|||||||
NextContainerID: atoi(meta["next_container_id"]),
|
NextContainerID: atoi(meta["next_container_id"]),
|
||||||
NextVNCPort: atoi(meta["next_vnc_port"]),
|
NextVNCPort: atoi(meta["next_vnc_port"]),
|
||||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||||
|
NATPortStart: atoi(meta["nat_port_start"]),
|
||||||
|
NATPortEnd: atoi(meta["nat_port_end"]),
|
||||||
SetupComplete: atob(meta["setup_complete"]),
|
SetupComplete: atob(meta["setup_complete"]),
|
||||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||||
Language: meta["language"],
|
Language: meta["language"],
|
||||||
@@ -651,6 +704,8 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
|
"next_container_id": strconv.Itoa(AppConfig.NextContainerID),
|
||||||
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
|
"next_vnc_port": strconv.Itoa(AppConfig.NextVNCPort),
|
||||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
"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),
|
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||||
"language": NormalizeLanguage(AppConfig.Language),
|
"language": NormalizeLanguage(AppConfig.Language),
|
||||||
@@ -673,30 +728,33 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
func saveContainers(tx *sql.Tx) error {
|
func saveContainers(tx *sql.Tx) error {
|
||||||
for _, c := range AppConfig.Containers {
|
for _, c := range AppConfig.Containers {
|
||||||
NormalizeContainerResourceAliases(&c)
|
NormalizeContainerResourceAliases(&c)
|
||||||
|
allowedImageIDs := encodeStringSlice(c.AllowedImageIDs)
|
||||||
if _, err := tx.Exec(`INSERT INTO containers (
|
if _, err := tx.Exec(`INSERT INTO containers (
|
||||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
||||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
||||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
status, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||||
|
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||||
firewall_enabled, firewall_default_action, firewall_rules
|
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
|
||||||
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
|
||||||
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
|
||||||
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
|
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
|
||||||
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
|
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
|
||||||
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
c.Status, c.IP, c.LANIPv4Mode, c.LANInterface, c.LANIPv4Address, c.LANIPv4PrefixLen, c.LANIPv4Gateway,
|
||||||
|
c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||||
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
||||||
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
|
||||||
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
c.SnapshotScheduleLastRun, c.SnapshotScheduleNextRun, c.SnapshotScheduleCreatedBy,
|
||||||
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
boolInt(c.PolicyBlocked), c.PolicyBlockedReason, c.PolicyBlockedAt,
|
||||||
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules),
|
boolInt(c.FirewallEnabled), normalizeFirewallDefaultAction(c.FirewallDefaultAction), marshalFirewallRules(c.FirewallRules), allowedImageIDs, boolInt(c.ImageLimitConfigured),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -724,8 +782,9 @@ func saveContainers(tx *sql.Tx) error {
|
|||||||
|
|
||||||
func saveSubUsers(tx *sql.Tx) error {
|
func saveSubUsers(tx *sql.Tx) error {
|
||||||
for _, su := range AppConfig.SubUsers {
|
for _, su := range AppConfig.SubUsers {
|
||||||
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version)
|
allowedImageIDs := encodeStringSlice(su.AllowedImageIDs)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion); err != nil {
|
if _, err := tx.Exec(`INSERT INTO sub_users(id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, su.ID, su.Username, su.Password, su.PassHash, su.AccessCode, su.CreatedAt, su.TokenVersion, allowedImageIDs, boolInt(su.ImageLimitConfigured)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i, name := range su.ContainerNames {
|
for i, name := range su.ContainerNames {
|
||||||
@@ -834,19 +893,21 @@ func saveTasksDB(tx *sql.Tx) error {
|
|||||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||||
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||||
|
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
|
||||||
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
|
||||||
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
|
||||||
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
|
||||||
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
|
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
|
||||||
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
|
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.LANIPv4Mode, cfg.LANInterface,
|
||||||
|
cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway, cfg.SnapshotLimit,
|
||||||
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
|
||||||
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
|
||||||
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
|
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, encodeStringSlice(cfg.AllowedImageIDs), boolInt(cfg.ImageLimitConfigured), cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -895,12 +956,13 @@ func loadContainers() ([]Container, error) {
|
|||||||
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
monthly_traffic_gb, traffic_mode, traffic_in_gb,
|
||||||
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
|
||||||
io_speed_mbps, io_read_mbps, io_write_mbps,
|
io_speed_mbps, io_read_mbps, io_write_mbps,
|
||||||
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
status, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||||
|
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
||||||
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
|
||||||
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
|
||||||
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
policy_blocked, policy_blocked_reason, policy_blocked_at,
|
||||||
firewall_enabled, firewall_default_action, firewall_rules
|
firewall_enabled, firewall_default_action, firewall_rules, allowed_image_ids, image_limit_configured
|
||||||
FROM containers ORDER BY id`)
|
FROM containers ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -910,31 +972,41 @@ func loadContainers() ([]Container, error) {
|
|||||||
result := []Container{}
|
result := []Container{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c Container
|
var c Container
|
||||||
var scheduleEnabled, policyBlocked, firewallEnabled int
|
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
|
||||||
var firewallDefaultAction string
|
var firewallDefaultAction string
|
||||||
var firewallRulesJSON sql.NullString
|
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||||
|
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
||||||
|
var lanIPv4PrefixLen sql.NullInt64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
|
||||||
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
|
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
|
||||||
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
|
||||||
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
|
||||||
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
|
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
|
||||||
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
&c.Status, &c.IP, &c.LANIPv4Mode, &c.LANInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway,
|
||||||
|
&c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||||
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
&c.SnapshotScheduleLastRun, &c.SnapshotScheduleNextRun, &c.SnapshotScheduleCreatedBy,
|
||||||
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
&policyBlocked, &c.PolicyBlockedReason, &c.PolicyBlockedAt,
|
||||||
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON,
|
&firewallEnabled, &firewallDefaultAction, &firewallRulesJSON, &allowedImageIDs, &imageLimitConfigured,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.LANIPv4Address = lanIPv4Address.String
|
||||||
|
if lanIPv4PrefixLen.Valid {
|
||||||
|
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||||
|
}
|
||||||
|
c.LANIPv4Gateway = lanIPv4Gateway.String
|
||||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||||
c.PolicyBlocked = policyBlocked != 0
|
c.PolicyBlocked = policyBlocked != 0
|
||||||
c.FirewallEnabled = firewallEnabled != 0
|
c.FirewallEnabled = firewallEnabled != 0
|
||||||
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
c.FirewallDefaultAction = normalizeFirewallDefaultAction(firewallDefaultAction)
|
||||||
|
c.ImageLimitConfigured = imageLimitConfigured != 0
|
||||||
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
|
||||||
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
|
||||||
}
|
}
|
||||||
|
c.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||||
NormalizeContainerResourceAliases(&c)
|
NormalizeContainerResourceAliases(&c)
|
||||||
result = append(result, c)
|
result = append(result, c)
|
||||||
}
|
}
|
||||||
@@ -1030,7 +1102,7 @@ func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSubUsers() ([]SubUser, error) {
|
func loadSubUsers() ([]SubUser, error) {
|
||||||
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version FROM sub_users ORDER BY created_at, id`)
|
rows, err := db.Query(`SELECT id, username, password, pass_hash, access_code, created_at, token_version, allowed_image_ids, image_limit_configured FROM sub_users ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1038,9 +1110,13 @@ func loadSubUsers() ([]SubUser, error) {
|
|||||||
result := []SubUser{}
|
result := []SubUser{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var su SubUser
|
var su SubUser
|
||||||
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion); err != nil {
|
var allowedImageIDs sql.NullString
|
||||||
|
var imageLimitConfigured int
|
||||||
|
if err := rows.Scan(&su.ID, &su.Username, &su.Password, &su.PassHash, &su.AccessCode, &su.CreatedAt, &su.TokenVersion, &allowedImageIDs, &imageLimitConfigured); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
su.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||||
|
su.ImageLimitConfigured = imageLimitConfigured != 0
|
||||||
result = append(result, su)
|
result = append(result, su)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -1132,9 +1208,10 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
|
||||||
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
|
||||||
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
|
||||||
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
|
cfg_port_mapping_count, cfg_assign_nat, cfg_lan_ipv4_mode, cfg_lan_interface,
|
||||||
|
cfg_lan_ipv4_address, cfg_lan_ipv4_prefix_len, cfg_lan_ipv4_gateway, cfg_snapshot_limit,
|
||||||
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
|
||||||
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
|
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_allowed_image_ids, cfg_image_limit_configured, cfg_expires_at
|
||||||
FROM tasks ORDER BY created_at, id`)
|
FROM tasks ORDER BY created_at, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1145,19 +1222,20 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t SavedTask
|
var t SavedTask
|
||||||
var cfg savedTaskConfig
|
var cfg savedTaskConfig
|
||||||
var assignIPv4, assignIPv6 int
|
var assignIPv4, assignIPv6, imageLimitConfigured int
|
||||||
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
|
||||||
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
|
var lanIPv4Mode, lanInterface, lanIPv4Address, lanIPv4Gateway, sshAuthMode, sshPassword, sshPublicKey, allowedImageIDs sql.NullString
|
||||||
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
|
var assignNAT, lanIPv4PrefixLen, ipv4Count, ipv6Count sql.NullInt64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
|
||||||
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
|
||||||
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
|
&cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
|
||||||
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
&cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
|
||||||
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
|
||||||
&cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
|
&cfg.PortMappingCount, &assignNAT, &lanIPv4Mode, &lanInterface,
|
||||||
|
&lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &cfg.SnapshotLimit,
|
||||||
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
|
||||||
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
|
&sshAuthMode, &sshPassword, &sshPublicKey, &allowedImageIDs, &imageLimitConfigured, &cfg.ExpiresAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1167,6 +1245,13 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
value := assignNAT.Int64 != 0
|
value := assignNAT.Int64 != 0
|
||||||
cfg.AssignNAT = &value
|
cfg.AssignNAT = &value
|
||||||
}
|
}
|
||||||
|
cfg.LANIPv4Mode = lanIPv4Mode.String
|
||||||
|
cfg.LANInterface = lanInterface.String
|
||||||
|
cfg.LANIPv4Address = lanIPv4Address.String
|
||||||
|
if lanIPv4PrefixLen.Valid {
|
||||||
|
cfg.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||||
|
}
|
||||||
|
cfg.LANIPv4Gateway = lanIPv4Gateway.String
|
||||||
cfg.AssignIPv4 = assignIPv4 != 0
|
cfg.AssignIPv4 = assignIPv4 != 0
|
||||||
if ipv4Count.Valid {
|
if ipv4Count.Valid {
|
||||||
cfg.IPv4Count = int(ipv4Count.Int64)
|
cfg.IPv4Count = int(ipv4Count.Int64)
|
||||||
@@ -1180,6 +1265,8 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
cfg.SSHAuthMode = sshAuthMode.String
|
cfg.SSHAuthMode = sshAuthMode.String
|
||||||
cfg.SSHPassword = sshPassword.String
|
cfg.SSHPassword = sshPassword.String
|
||||||
cfg.SSHPublicKey = sshPublicKey.String
|
cfg.SSHPublicKey = sshPublicKey.String
|
||||||
|
cfg.AllowedImageIDs = decodeStringSlice(allowedImageIDs.String)
|
||||||
|
cfg.ImageLimitConfigured = imageLimitConfigured != 0
|
||||||
normalizeSavedTaskConfigLimits(&cfg)
|
normalizeSavedTaskConfigLimits(&cfg)
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
configs = append(configs, cfg)
|
configs = append(configs, cfg)
|
||||||
|
|||||||
+159
-36
@@ -20,6 +20,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -375,6 +376,10 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
|
|||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
}
|
}
|
||||||
|
if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" {
|
||||||
|
cfg.AllowedImageIDs = []string{cfg.TemplateID}
|
||||||
|
cfg.ImageLimitConfigured = true
|
||||||
|
}
|
||||||
|
|
||||||
id := config.AllocateContainerID()
|
id := config.AllocateContainerID()
|
||||||
vmName := fmt.Sprintf("vm-%d", id)
|
vmName := fmt.Sprintf("vm-%d", id)
|
||||||
@@ -454,7 +459,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
}
|
}
|
||||||
winAdminPassword = generateWindowsPassword()
|
winAdminPassword = generateWindowsPassword()
|
||||||
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
|
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
|
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)
|
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
|
||||||
@@ -487,7 +492,10 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
sshPort := 0
|
sshPort := 0
|
||||||
portMappings := []config.PortMapping{}
|
portMappings := []config.PortMapping{}
|
||||||
if allocatePorts && cfg.WantsNAT() {
|
if allocatePorts && cfg.WantsNAT() {
|
||||||
sshPort = config.AllocateSSHPort()
|
sshPort, err = config.AllocateSSHPort()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if IsWindowsImage(image.ID) {
|
if IsWindowsImage(image.ID) {
|
||||||
// Windows: RDP (3389) instead of SSH (22)
|
// Windows: RDP (3389) instead of SSH (22)
|
||||||
portMappings = []config.PortMapping{{
|
portMappings = []config.PortMapping{{
|
||||||
@@ -565,6 +573,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
|
|||||||
}(),
|
}(),
|
||||||
PortMappings: portMappings,
|
PortMappings: portMappings,
|
||||||
PortMappingLimit: cfg.PortMappingCount,
|
PortMappingLimit: cfg.PortMappingCount,
|
||||||
|
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
|
||||||
|
ImageLimitConfigured: cfg.ImageLimitConfigured,
|
||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
@@ -1535,7 +1545,13 @@ func (m *Manager) validateHost(skipCloudInit bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := requireCommand(kvmEmulatorCommand()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if skipCloudInit {
|
if skipCloudInit {
|
||||||
|
if runtime.GOARCH != "amd64" {
|
||||||
|
return fmt.Errorf("Windows KVM is currently supported only on x86_64/amd64 hosts")
|
||||||
|
}
|
||||||
if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil {
|
if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil {
|
||||||
return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err)
|
return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err)
|
||||||
}
|
}
|
||||||
@@ -1569,6 +1585,40 @@ func requireAnyCommand(names ...string) error {
|
|||||||
return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", "))
|
return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func kvmLibvirtArch() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return "aarch64"
|
||||||
|
default:
|
||||||
|
return "x86_64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvmMachineType() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return "virt"
|
||||||
|
default:
|
||||||
|
return "pc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvmEmulatorCommand() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return "qemu-system-aarch64"
|
||||||
|
default:
|
||||||
|
return "qemu-system-x86_64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvmEmulatorPath() string {
|
||||||
|
if path, err := exec.LookPath(kvmEmulatorCommand()); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return "/usr/bin/" + kvmEmulatorCommand()
|
||||||
|
}
|
||||||
|
|
||||||
func ensureDefaultNetwork() error {
|
func ensureDefaultNetwork() error {
|
||||||
// Ensure libvirtd is running
|
// Ensure libvirtd is running
|
||||||
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
|
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
|
||||||
@@ -1680,7 +1730,7 @@ func createEmptyDisk(target string, diskGB int) error {
|
|||||||
return nil
|
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")
|
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
|
||||||
if tool == "" {
|
if tool == "" {
|
||||||
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
|
||||||
@@ -1706,13 +1756,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []st
|
|||||||
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
_ = os.Remove(target)
|
_ = os.Remove(target)
|
||||||
@@ -1822,7 +1872,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{
|
commands := []string{
|
||||||
"$ErrorActionPreference='Continue'",
|
"$ErrorActionPreference='Continue'",
|
||||||
"$ProgressPreference='SilentlyContinue'",
|
"$ProgressPreference='SilentlyContinue'",
|
||||||
@@ -1832,9 +1882,9 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
|
|||||||
"net user Administrator " + shellQuoteWindows(adminPassword) + " /active:yes",
|
"net user Administrator " + shellQuoteWindows(adminPassword) + " /active:yes",
|
||||||
"Set-LocalUser -Name 'Administrator' -PasswordNeverExpires $true -ErrorAction SilentlyContinue",
|
"Set-LocalUser -Name 'Administrator' -PasswordNeverExpires $true -ErrorAction SilentlyContinue",
|
||||||
"Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force",
|
"Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope LocalMachine -Force",
|
||||||
"$iface=$null",
|
windowsAdapterDiscoveryPowerShell(mac),
|
||||||
"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=Wait-ClicdNetworkAdapter",
|
||||||
"$iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1",
|
"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-NetIPInterface -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -Dhcp Enabled -ErrorAction SilentlyContinue }",
|
||||||
"if ($iface) { Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ResetServerAddresses -ErrorAction SilentlyContinue }",
|
"if ($iface) { Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ResetServerAddresses -ErrorAction SilentlyContinue }",
|
||||||
"Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue",
|
"Get-NetConnectionProfile | Set-NetConnectionProfile -NetworkCategory Private -ErrorAction SilentlyContinue",
|
||||||
@@ -1852,17 +1902,23 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
|
|||||||
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
|
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
|
||||||
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
|
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
|
||||||
}
|
}
|
||||||
|
networkCommands := []string{}
|
||||||
ipv6s = normalizeKVMIPv6List(ipv6s)
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
if len(ipv6s) > 0 {
|
if len(ipv6s) > 0 {
|
||||||
commands = append(commands,
|
networkCommands = append(networkCommands, windowsIPv6PowerShell(ipv6s, mac))
|
||||||
windowsIPv6PowerShell(ipv6s),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ipv4s = normalizeKVMIPv4List(ipv4s)
|
ipv4s = normalizeKVMIPv4List(ipv4s)
|
||||||
if len(ipv4s) > 0 {
|
if len(ipv4s) > 0 {
|
||||||
commands = append(commands,
|
networkCommands = append(networkCommands, windowsIPv4PowerShell(ipv4s, mac))
|
||||||
windowsIPv4PowerShell(ipv4s),
|
}
|
||||||
)
|
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,
|
commands = append(commands,
|
||||||
"New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null",
|
"New-Item -ItemType File -Force -Path 'C:\\CLICD\\init.done' | Out-Null",
|
||||||
@@ -1871,18 +1927,58 @@ func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []s
|
|||||||
return strings.Join(commands, "\r\n") + "\r\n"
|
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)
|
ipv6s = normalizeKVMIPv6List(ipv6s)
|
||||||
if len(ipv6s) == 0 {
|
if len(ipv6s) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
quoted := make([]string, 0, len(ipv6s))
|
quoted := make([]string, 0, len(ipv6s))
|
||||||
for _, ipv6 := range ipv6s {
|
for _, ipv6 := range ipv6s {
|
||||||
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
|
quoted = append(quoted, powerShellSingleQuote(ipv6))
|
||||||
}
|
}
|
||||||
return strings.Join([]string{
|
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, ",") + ")",
|
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
|
||||||
// Reuse $iface already found by the main script
|
|
||||||
"if ($iface) {",
|
"if ($iface) {",
|
||||||
" foreach ($ip in $clicdIPv6) {",
|
" foreach ($ip in $clicdIPv6) {",
|
||||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||||
@@ -1895,18 +1991,20 @@ func windowsIPv6PowerShell(ipv6s []string) string {
|
|||||||
}, "\r\n")
|
}, "\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowsIPv4PowerShell(ipv4s []string) string {
|
func windowsIPv4PowerShell(ipv4s []string, mac string) string {
|
||||||
ipv4s = normalizeKVMIPv4List(ipv4s)
|
ipv4s = normalizeKVMIPv4List(ipv4s)
|
||||||
if len(ipv4s) == 0 {
|
if len(ipv4s) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
quoted := make([]string, 0, len(ipv4s))
|
quoted := make([]string, 0, len(ipv4s))
|
||||||
for _, ipv4 := range ipv4s {
|
for _, ipv4 := range ipv4s {
|
||||||
quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'")
|
quoted = append(quoted, powerShellSingleQuote(ipv4))
|
||||||
}
|
}
|
||||||
return strings.Join([]string{
|
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, ",") + ")",
|
"$clicdIPv4=@(" + strings.Join(quoted, ",") + ")",
|
||||||
// Reuse $iface already found by the main script
|
|
||||||
"if ($iface) {",
|
"if ($iface) {",
|
||||||
" foreach ($ip in $clicdIPv4) {",
|
" foreach ($ip in $clicdIPv4) {",
|
||||||
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
|
||||||
@@ -1930,6 +2028,10 @@ func normalizeKVMIPv4List(values []string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func powerShellSingleQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
func shellQuoteWindows(value string) string {
|
func shellQuoteWindows(value string) string {
|
||||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
||||||
}
|
}
|
||||||
@@ -2131,6 +2233,26 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
|||||||
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
|
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
|
||||||
input = "\n\t <input type='tablet' bus='usb'/>"
|
input = "\n\t <input type='tablet' bus='usb'/>"
|
||||||
}
|
}
|
||||||
|
osAttrs := ""
|
||||||
|
features := "<features><acpi/><apic/></features>"
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
osAttrs = " firmware='efi'"
|
||||||
|
features = "<features><acpi/><gic version='3'/></features>"
|
||||||
|
}
|
||||||
|
seedDisk := fmt.Sprintf(`<disk type='file' device='cdrom'>
|
||||||
|
<driver name='qemu' type='raw'/>
|
||||||
|
<source file='%s'/>
|
||||||
|
<target dev='hdb' bus='ide'/>
|
||||||
|
<readonly/>
|
||||||
|
</disk>`, xmlEscape(seedPath))
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
seedDisk = fmt.Sprintf(`<disk type='file' device='disk'>
|
||||||
|
<driver name='qemu' type='raw'/>
|
||||||
|
<source file='%s'/>
|
||||||
|
<target dev='vdb' bus='virtio'/>
|
||||||
|
<readonly/>
|
||||||
|
</disk>`, xmlEscape(seedPath))
|
||||||
|
}
|
||||||
return fmt.Sprintf(`<domain type='kvm'>
|
return fmt.Sprintf(`<domain type='kvm'>
|
||||||
<name>%s</name>
|
<name>%s</name>
|
||||||
%s
|
%s
|
||||||
@@ -2138,29 +2260,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
|||||||
<currentMemory unit='MiB'>%d</currentMemory>
|
<currentMemory unit='MiB'>%d</currentMemory>
|
||||||
<vcpu placement='static' current='%d'>%d</vcpu>
|
<vcpu placement='static' current='%d'>%d</vcpu>
|
||||||
<cputune><shares>2048</shares></cputune>
|
<cputune><shares>2048</shares></cputune>
|
||||||
<os>
|
<os%s>
|
||||||
<type arch='x86_64' machine='pc'>hvm</type>
|
<type arch='%s' machine='%s'>hvm</type>
|
||||||
<boot dev='hd'/>
|
<boot dev='hd'/>
|
||||||
</os>
|
</os>
|
||||||
<features><acpi/><apic/></features>
|
%s
|
||||||
<cpu mode='host-passthrough' check='none'/>
|
<cpu mode='host-passthrough' check='none'/>
|
||||||
<clock offset='utc'/>
|
<clock offset='utc'/>
|
||||||
<on_poweroff>destroy</on_poweroff>
|
<on_poweroff>destroy</on_poweroff>
|
||||||
<on_reboot>restart</on_reboot>
|
<on_reboot>restart</on_reboot>
|
||||||
<on_crash>restart</on_crash>
|
<on_crash>restart</on_crash>
|
||||||
<devices>
|
<devices>
|
||||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
<emulator>%s</emulator>
|
||||||
<disk type='file' device='disk'>
|
<disk type='file' device='disk'>
|
||||||
<driver name='qemu' type='qcow2' cache='none'/>
|
<driver name='qemu' type='qcow2' cache='none'/>
|
||||||
<source file='%s'/>
|
<source file='%s'/>
|
||||||
<target dev='vda' bus='virtio'/>%s
|
<target dev='vda' bus='virtio'/>%s
|
||||||
</disk>
|
</disk>
|
||||||
<disk type='file' device='cdrom'>
|
%s
|
||||||
<driver name='qemu' type='raw'/>
|
|
||||||
<source file='%s'/>
|
|
||||||
<target dev='hdb' bus='ide'/>
|
|
||||||
<readonly/>
|
|
||||||
</disk>
|
|
||||||
<interface type='network'>
|
<interface type='network'>
|
||||||
<mac address='%s'/>
|
<mac address='%s'/>
|
||||||
<source network='default'/>
|
<source network='default'/>
|
||||||
@@ -2177,7 +2294,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
|||||||
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
|
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
|
||||||
%s
|
%s
|
||||||
</devices>
|
</devices>
|
||||||
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
|
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, osAttrs, kvmLibvirtArch(), kvmMachineType(), features, xmlEscape(kvmEmulatorPath()), xmlEscape(diskPath), iotune, seedDisk, xmlEscape(mac), bandwidth, input, video)
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string {
|
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string {
|
||||||
@@ -2354,7 +2471,11 @@ func normalizeKVMManagementPortMapping(c *config.Container) {
|
|||||||
}
|
}
|
||||||
hostPort := c.SSHPort
|
hostPort := c.SSHPort
|
||||||
if hostPort <= 0 {
|
if hostPort <= 0 {
|
||||||
hostPort = config.AllocateSSHPort()
|
allocated, err := config.AllocateSSHPort()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hostPort = allocated
|
||||||
c.SSHPort = hostPort
|
c.SSHPort = hostPort
|
||||||
}
|
}
|
||||||
desiredPort := 22
|
desiredPort := 22
|
||||||
@@ -3488,13 +3609,14 @@ func (m *Manager) applyGuestIPv6(c *config.Container) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) applyWindowsGuestIPv6(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
|
return nil
|
||||||
}
|
}
|
||||||
if err := qemuGuestPing(c.VirshName()); err != nil {
|
if err := qemuGuestPing(c.VirshName()); err != nil {
|
||||||
return err
|
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)
|
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3855,7 +3977,8 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ports := make([]int, 0, count)
|
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] {
|
if !used[next] {
|
||||||
ports = append(ports, next)
|
ports = append(ports, next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package kvm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
@@ -16,6 +17,15 @@ type Image struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetImages() []Image {
|
func GetImages() []Image {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return arm64Images()
|
||||||
|
default:
|
||||||
|
return amd64Images()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func amd64Images() []Image {
|
||||||
return []Image{
|
return []Image{
|
||||||
{
|
{
|
||||||
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
||||||
@@ -36,12 +46,25 @@ func GetImages() []Image {
|
|||||||
Description: "Ubuntu 22.04 LTS cloud image for KVM",
|
Description: "Ubuntu 22.04 LTS cloud image for KVM",
|
||||||
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-debian-trixie", Name: "Debian 13 KVM",
|
||||||
|
Distro: "debian", Release: "trixie", Arch: "amd64",
|
||||||
|
Description: "Debian 13 generic cloud image for KVM",
|
||||||
|
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||||
Description: "Debian 12 generic cloud image for KVM",
|
Description: "Debian 12 generic cloud image for KVM",
|
||||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-debian-trixie-xfce", Name: "Debian 13 XFCE KVM",
|
||||||
|
Distro: "debian", Release: "trixie", Arch: "amd64",
|
||||||
|
Description: "Debian 13 generic cloud image with XFCE desktop provisioned via cloud-init",
|
||||||
|
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2",
|
||||||
|
Desktop: "xfce",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
|
ID: "kvm-debian-bookworm-xfce", Name: "Debian 12 XFCE KVM",
|
||||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||||
@@ -94,6 +117,59 @@ func GetImages() []Image {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func arm64Images() []Image {
|
||||||
|
return []Image{
|
||||||
|
{
|
||||||
|
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
||||||
|
Distro: "ubuntu", Release: "noble", Arch: "arm64",
|
||||||
|
Description: "Ubuntu 24.04 LTS cloud image for ARM64 KVM",
|
||||||
|
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
|
||||||
|
Distro: "ubuntu", Release: "jammy", Arch: "arm64",
|
||||||
|
Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM",
|
||||||
|
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-debian-trixie", Name: "Debian 13 KVM",
|
||||||
|
Distro: "debian", Release: "trixie", Arch: "arm64",
|
||||||
|
Description: "Debian 13 generic cloud image for ARM64 KVM",
|
||||||
|
URL: "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||||
|
Distro: "debian", Release: "bookworm", Arch: "arm64",
|
||||||
|
Description: "Debian 12 generic cloud image for ARM64 KVM",
|
||||||
|
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
|
||||||
|
Distro: "debian", Release: "bullseye", Arch: "arm64",
|
||||||
|
Description: "Debian 11 generic cloud image for ARM64 KVM",
|
||||||
|
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM",
|
||||||
|
Distro: "centos", Release: "9-stream", Arch: "arm64",
|
||||||
|
Description: "CentOS Stream 9 GenericCloud image for ARM64 KVM",
|
||||||
|
URL: "https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-fedora-44", Name: "Fedora 44 KVM",
|
||||||
|
Distro: "fedora", Release: "44", Arch: "arm64",
|
||||||
|
Description: "Fedora 44 GenericCloud image for ARM64 KVM",
|
||||||
|
URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM",
|
||||||
|
Distro: "rockylinux", Release: "9", Arch: "arm64",
|
||||||
|
Description: "Rocky Linux 9 GenericCloud image for ARM64 KVM",
|
||||||
|
URL: "https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base.latest.aarch64.qcow2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func FindImage(id string) *Image {
|
func FindImage(id string) *Image {
|
||||||
for _, image := range GetImages() {
|
for _, image := range GetImages() {
|
||||||
if image.ID == id {
|
if image.ID == id {
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ func (m *Manager) DetectIPv6Status() IPv6Status {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
|
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
|
||||||
|
if configured := ConfiguredPublicIPv6Prefixes(); len(configured) > 0 {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
|
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+503
-14
@@ -8,6 +8,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -81,6 +83,13 @@ func (m *Manager) WarmRunningContainersSSH() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
config.UpdateContainerStatus(c.ID, "running")
|
config.UpdateContainerStatus(c.ID, "running")
|
||||||
|
if current := config.FindContainer(c.ID); current != nil {
|
||||||
|
m.refreshContainerIPv4Details(current)
|
||||||
|
c = *current
|
||||||
|
}
|
||||||
|
if err := m.ensureLANHostAccess(&c); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", c.LxcName(), err)
|
||||||
|
}
|
||||||
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
|
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -238,7 +247,14 @@ type ContainerConfig struct {
|
|||||||
ExtraPorts []int `json:"extra_ports"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
|
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||||
|
LANInterface string `json:"lan_interface,omitempty"`
|
||||||
|
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||||
|
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||||
|
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
@@ -287,9 +303,24 @@ func (cfg *ContainerConfig) NormalizeResourceAliases() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cfg ContainerConfig) WantsNAT() bool {
|
func (cfg ContainerConfig) WantsNAT() bool {
|
||||||
|
if cfg.WantsLANIPv4() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return cfg.AssignNAT == nil || *cfg.AssignNAT
|
return cfg.AssignNAT == nil || *cfg.AssignNAT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg ContainerConfig) WantsLANDHCP() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeDHCP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg ContainerConfig) WantsLANStaticIPv4() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(cfg.LANIPv4Mode), config.LANIPv4ModeStatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg ContainerConfig) WantsLANIPv4() bool {
|
||||||
|
return cfg.WantsLANDHCP() || cfg.WantsLANStaticIPv4()
|
||||||
|
}
|
||||||
|
|
||||||
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
|
||||||
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
||||||
cfg.NormalizeResourceAliases()
|
cfg.NormalizeResourceAliases()
|
||||||
@@ -306,6 +337,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
}
|
}
|
||||||
|
if !cfg.ImageLimitConfigured && len(cfg.AllowedImageIDs) == 0 && cfg.TemplateID != "" {
|
||||||
|
cfg.AllowedImageIDs = []string{cfg.TemplateID}
|
||||||
|
cfg.ImageLimitConfigured = true
|
||||||
|
}
|
||||||
|
|
||||||
if !config.IsValidContainerName(cfg.Name) {
|
if !config.IsValidContainerName(cfg.Name) {
|
||||||
return fmt.Errorf("invalid container name: %s", cfg.Name)
|
return fmt.Errorf("invalid container name: %s", cfg.Name)
|
||||||
@@ -349,6 +384,14 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if cfg.WantsLANIPv4() {
|
||||||
|
iface, err := m.applyLANIPv4Config(lxcName, cfg)
|
||||||
|
if err != nil {
|
||||||
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.LANInterface = iface
|
||||||
|
}
|
||||||
|
|
||||||
// Apply resource limits and mandatory security hardening.
|
// Apply resource limits and mandatory security hardening.
|
||||||
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
|
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
|
||||||
@@ -381,7 +424,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
sshPort := 0
|
sshPort := 0
|
||||||
portMappings := []config.PortMapping{}
|
portMappings := []config.PortMapping{}
|
||||||
if cfg.WantsNAT() {
|
if cfg.WantsNAT() {
|
||||||
sshPort = config.AllocateSSHPort()
|
sshPort, err = config.AllocateSSHPort()
|
||||||
|
if err != nil {
|
||||||
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Setup default port mappings (SSH only)
|
// Setup default port mappings (SSH only)
|
||||||
portMappings = SetupDefaultPortMappings(sshPort)
|
portMappings = SetupDefaultPortMappings(sshPort)
|
||||||
@@ -424,6 +471,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Virtualization: config.VirtualizationLXC,
|
Virtualization: config.VirtualizationLXC,
|
||||||
|
LXCName: lxcName,
|
||||||
Template: cfg.TemplateID,
|
Template: cfg.TemplateID,
|
||||||
VCPU: cfg.VCPU,
|
VCPU: cfg.VCPU,
|
||||||
RAMMB: cfg.RAMMB,
|
RAMMB: cfg.RAMMB,
|
||||||
@@ -441,6 +489,12 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
IOWriteMBps: cfg.IOWriteMBps,
|
IOWriteMBps: cfg.IOWriteMBps,
|
||||||
Status: "stopped",
|
Status: "stopped",
|
||||||
IP: "",
|
IP: "",
|
||||||
|
LANIPv4Mode: normalizedLANIPv4Mode(cfg.LANIPv4Mode),
|
||||||
|
LANInterface: strings.TrimSpace(cfg.LANInterface),
|
||||||
|
LANIPv4Address: strings.TrimSpace(cfg.LANIPv4Address),
|
||||||
|
LANIPv4PrefixLen: cfg.LANIPv4PrefixLen,
|
||||||
|
LANIPv4Gateway: strings.TrimSpace(cfg.LANIPv4Gateway),
|
||||||
|
MACAddress: readLXCConfigValue(lxcName, "lxc.net.0.hwaddr"),
|
||||||
PublicIPv4s: publicIPv4s,
|
PublicIPv4s: publicIPv4s,
|
||||||
IPv6Addresses: ipv6Assignments,
|
IPv6Addresses: ipv6Assignments,
|
||||||
VNCPort: 0,
|
VNCPort: 0,
|
||||||
@@ -448,6 +502,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
SSHPassword: sshPassword,
|
SSHPassword: sshPassword,
|
||||||
PortMappings: portMappings,
|
PortMappings: portMappings,
|
||||||
PortMappingLimit: cfg.PortMappingCount,
|
PortMappingLimit: cfg.PortMappingCount,
|
||||||
|
AllowedImageIDs: append([]string(nil), cfg.AllowedImageIDs...),
|
||||||
|
ImageLimitConfigured: cfg.ImageLimitConfigured,
|
||||||
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: cfg.ExpiresAt,
|
ExpiresAt: cfg.ExpiresAt,
|
||||||
@@ -457,7 +513,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
|
|
||||||
// Pre-configure network and SSH in the rootfs before first boot.
|
// Pre-configure network and SSH in the rootfs before first boot.
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
|
m.preconfigureNetwork(rootfsPath, cfg)
|
||||||
if len(ipv6Assignments) > 0 {
|
if len(ipv6Assignments) > 0 {
|
||||||
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
|
||||||
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
|
||||||
@@ -490,7 +546,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
|
func (m *Manager) preconfigureNetwork(rootfsPath string, cfg ContainerConfig) {
|
||||||
|
templateID := cfg.TemplateID
|
||||||
osRelease := ""
|
osRelease := ""
|
||||||
if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil {
|
if data, err := os.ReadFile(filepath.Join(rootfsPath, "etc", "os-release")); err == nil {
|
||||||
osRelease = strings.ToLower(string(data))
|
osRelease = strings.ToLower(string(data))
|
||||||
@@ -506,7 +563,13 @@ func (m *Manager) preconfigureNetwork(rootfsPath, templateID string) {
|
|||||||
|
|
||||||
if isAlpine {
|
if isAlpine {
|
||||||
interfaces := filepath.Join(rootfsPath, "etc", "network", "interfaces")
|
interfaces := filepath.Join(rootfsPath, "etc", "network", "interfaces")
|
||||||
content := "auto lo\niface lo inet loopback\n\nauto eth0\niface eth0 inet dhcp\n"
|
content := "auto lo\niface lo inet loopback\n\nauto eth0\n"
|
||||||
|
if cfg.WantsLANStaticIPv4() {
|
||||||
|
content += fmt.Sprintf("iface eth0 inet static\n address %s\n netmask %s\n gateway %s\n",
|
||||||
|
cfg.LANIPv4Address, subnetMaskFromPrefixLen(cfg.LANIPv4PrefixLen), cfg.LANIPv4Gateway)
|
||||||
|
} else {
|
||||||
|
content += "iface eth0 inet dhcp\n"
|
||||||
|
}
|
||||||
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
|
_ = os.MkdirAll(filepath.Dir(interfaces), 0755)
|
||||||
_ = os.WriteFile(interfaces, []byte(content), 0644)
|
_ = os.WriteFile(interfaces, []byte(content), 0644)
|
||||||
_ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
|
_ = m.runRootfsCommand(rootfsPath, "rc-update", "add", "networking", "boot")
|
||||||
@@ -523,8 +586,16 @@ interface-name=eth0
|
|||||||
autoconnect=true
|
autoconnect=true
|
||||||
|
|
||||||
[ipv4]
|
[ipv4]
|
||||||
method=auto
|
`
|
||||||
|
if cfg.WantsLANStaticIPv4() {
|
||||||
|
keyfile += fmt.Sprintf(`method=manual
|
||||||
|
address1=%s/%d,%s
|
||||||
|
`, cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway)
|
||||||
|
} else {
|
||||||
|
keyfile += `method=auto
|
||||||
|
`
|
||||||
|
}
|
||||||
|
keyfile += `
|
||||||
[ipv6]
|
[ipv6]
|
||||||
method=ignore
|
method=ignore
|
||||||
`
|
`
|
||||||
@@ -540,9 +611,14 @@ method=ignore
|
|||||||
Name=eth0
|
Name=eth0
|
||||||
|
|
||||||
[Network]
|
[Network]
|
||||||
DHCP=ipv4
|
`
|
||||||
|
if cfg.WantsLANStaticIPv4() {
|
||||||
|
network += fmt.Sprintf("Address=%s/%d\nGateway=%s\nIPv6AcceptRA=no\n", cfg.LANIPv4Address, cfg.LANIPv4PrefixLen, cfg.LANIPv4Gateway)
|
||||||
|
} else {
|
||||||
|
network += `DHCP=ipv4
|
||||||
IPv6AcceptRA=no
|
IPv6AcceptRA=no
|
||||||
`
|
`
|
||||||
|
}
|
||||||
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
|
_ = os.WriteFile(filepath.Join(networkdDir, "10-eth0.network"), []byte(network), 0644)
|
||||||
}
|
}
|
||||||
if !isRHELFamily {
|
if !isRHELFamily {
|
||||||
@@ -550,6 +626,226 @@ IPv6AcceptRA=no
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizedLANIPv4Mode(mode string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(mode), config.LANIPv4ModeDHCP) {
|
||||||
|
return config.LANIPv4ModeDHCP
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(mode), config.LANIPv4ModeStatic) {
|
||||||
|
return config.LANIPv4ModeStatic
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) applyLANIPv4Config(lxcName string, cfg ContainerConfig) (string, error) {
|
||||||
|
if !cfg.WantsLANIPv4() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if cfg.WantsLANStaticIPv4() {
|
||||||
|
if err := validateLANStaticIPv4(cfg); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iface := cfg.LANInterface
|
||||||
|
iface = strings.TrimSpace(iface)
|
||||||
|
if iface == "" || isInvalidLANUplinkInterface(iface) {
|
||||||
|
iface = defaultLANInterface()
|
||||||
|
}
|
||||||
|
if iface == "" {
|
||||||
|
return "", fmt.Errorf("LAN IPv4 requires an uplink interface")
|
||||||
|
}
|
||||||
|
if isInvalidLANUplinkInterface(iface) {
|
||||||
|
return "", fmt.Errorf("invalid LAN IPv4 uplink interface: %s", iface)
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("ip", "link", "show", "dev", iface).CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("LAN IPv4 uplink interface %s not found: %v, output: %s", iface, err, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := filepath.Join(m.LxcPath, lxcName, "config")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read LXC config for LAN DHCP: %v", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||||
|
values := map[string]string{
|
||||||
|
"lxc.net.0.type": "macvlan",
|
||||||
|
"lxc.net.0.link": iface,
|
||||||
|
"lxc.net.0.flags": "up",
|
||||||
|
"lxc.net.0.macvlan.mode": "bridge",
|
||||||
|
}
|
||||||
|
if cfg.WantsLANStaticIPv4() {
|
||||||
|
values["lxc.net.0.ipv4.address"] = fmt.Sprintf("%s/%d", strings.TrimSpace(cfg.LANIPv4Address), cfg.LANIPv4PrefixLen)
|
||||||
|
values["lxc.net.0.ipv4.gateway"] = strings.TrimSpace(cfg.LANIPv4Gateway)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
next := make([]string, 0, len(lines)+len(values))
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !cfg.WantsLANStaticIPv4() && (strings.HasPrefix(trimmed, "lxc.net.0.ipv4.address") || strings.HasPrefix(trimmed, "lxc.net.0.ipv4.gateway")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
replaced := false
|
||||||
|
for key, value := range values {
|
||||||
|
if strings.HasPrefix(trimmed, key+" ") || strings.HasPrefix(trimmed, key+"=") {
|
||||||
|
next = append(next, fmt.Sprintf("%s = %s", key, value))
|
||||||
|
seen[key] = true
|
||||||
|
replaced = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
next = append(next, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"lxc.net.0.type", "lxc.net.0.link", "lxc.net.0.flags", "lxc.net.0.macvlan.mode", "lxc.net.0.ipv4.address", "lxc.net.0.ipv4.gateway"} {
|
||||||
|
if !seen[key] {
|
||||||
|
if value, ok := values[key]; ok {
|
||||||
|
next = append(next, fmt.Sprintf("%s = %s", key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(configPath, []byte(strings.Join(next, "\n")), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write LXC LAN IPv4 config: %v", err)
|
||||||
|
}
|
||||||
|
return iface, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLANStaticIPv4(cfg ContainerConfig) error {
|
||||||
|
addr, err := netip.ParseAddr(strings.TrimSpace(cfg.LANIPv4Address))
|
||||||
|
if err != nil || !addr.Is4() {
|
||||||
|
return fmt.Errorf("LAN static IPv4 address is invalid")
|
||||||
|
}
|
||||||
|
gateway, err := netip.ParseAddr(strings.TrimSpace(cfg.LANIPv4Gateway))
|
||||||
|
if err != nil || !gateway.Is4() {
|
||||||
|
return fmt.Errorf("LAN static IPv4 gateway is invalid")
|
||||||
|
}
|
||||||
|
if cfg.LANIPv4PrefixLen < 1 || cfg.LANIPv4PrefixLen > 32 {
|
||||||
|
return fmt.Errorf("LAN static IPv4 prefix length must be 1-32")
|
||||||
|
}
|
||||||
|
prefix := netip.PrefixFrom(addr, cfg.LANIPv4PrefixLen).Masked()
|
||||||
|
if !prefix.Contains(gateway) && cfg.LANIPv4PrefixLen < 32 {
|
||||||
|
return fmt.Errorf("LAN static IPv4 gateway must be in the same subnet")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subnetMaskFromPrefixLen(prefixLen int) string {
|
||||||
|
if prefixLen < 0 || prefixLen > 32 {
|
||||||
|
return "255.255.255.0"
|
||||||
|
}
|
||||||
|
mask := uint32(0)
|
||||||
|
if prefixLen > 0 {
|
||||||
|
mask = ^uint32(0) << (32 - prefixLen)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.%d.%d.%d", byte(mask>>24), byte(mask>>16), byte(mask>>8), byte(mask))
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultLANInterface() string {
|
||||||
|
out, err := exec.Command("ip", "-4", "route", "show", "default").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
for i := 0; i+1 < len(fields); i++ {
|
||||||
|
if fields[i] == "dev" && !isInvalidLANUplinkInterface(fields[i+1]) {
|
||||||
|
return fields[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInvalidLANUplinkInterface(name string) bool {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
return name == "" ||
|
||||||
|
name == "lo" ||
|
||||||
|
strings.HasPrefix(name, "lxc") ||
|
||||||
|
strings.HasPrefix(name, "docker") ||
|
||||||
|
strings.HasPrefix(name, "br-") ||
|
||||||
|
strings.HasPrefix(name, "veth") ||
|
||||||
|
strings.HasPrefix(name, "virbr") ||
|
||||||
|
strings.HasPrefix(name, "clmv-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLXCConfigValue(lxcName string, key string) string {
|
||||||
|
data, err := os.ReadFile(filepath.Join("/var/lib/lxc", lxcName, "config"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
prefix := key + " "
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, prefix) || strings.HasPrefix(trimmed, key+"=") {
|
||||||
|
parts := strings.SplitN(trimmed, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ensureLANHostAccess(c *config.Container) error {
|
||||||
|
if c == nil || !c.UsesLANIPv4() || strings.TrimSpace(c.IP) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
uplink := strings.TrimSpace(c.LANInterface)
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = defaultLANInterface()
|
||||||
|
}
|
||||||
|
if uplink == "" {
|
||||||
|
return fmt.Errorf("missing LAN IPv4 uplink interface")
|
||||||
|
}
|
||||||
|
shim := lanHostShimName(uplink)
|
||||||
|
if _, err := exec.Command("ip", "link", "show", "dev", shim).Output(); err != nil {
|
||||||
|
if out, addErr := exec.Command("ip", "link", "add", shim, "link", uplink, "type", "macvlan", "mode", "bridge").CombinedOutput(); addErr != nil {
|
||||||
|
return fmt.Errorf("failed to create host macvlan shim %s on %s: %v, output: %s", shim, uplink, addErr, string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runQuiet("ip", "link", "set", shim, "up")
|
||||||
|
if out, err := exec.Command("ip", "route", "replace", c.IP+"/32", "dev", shim).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to route %s through %s: %v, output: %s", c.IP, shim, err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) removeLANHostRoute(c *config.Container) {
|
||||||
|
if c == nil || !c.UsesLANIPv4() || strings.TrimSpace(c.IP) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uplink := strings.TrimSpace(c.LANInterface)
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = defaultLANInterface()
|
||||||
|
}
|
||||||
|
if uplink == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runQuiet("ip", "route", "del", c.IP+"/32", "dev", lanHostShimName(uplink))
|
||||||
|
}
|
||||||
|
|
||||||
|
func lanHostShimName(uplink string) string {
|
||||||
|
cleaned := make([]rune, 0, len(uplink))
|
||||||
|
for _, r := range uplink {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||||
|
cleaned = append(cleaned, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := strings.ToLower(string(cleaned))
|
||||||
|
if base == "" {
|
||||||
|
base = "if"
|
||||||
|
}
|
||||||
|
if len(base) <= 10 {
|
||||||
|
return "clmv-" + base
|
||||||
|
}
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(uplink))
|
||||||
|
suffix := fmt.Sprintf("%04x", h.Sum32()&0xffff)
|
||||||
|
if len(base) > 6 {
|
||||||
|
base = base[:6]
|
||||||
|
}
|
||||||
|
return "clmv-" + base + suffix
|
||||||
|
}
|
||||||
|
|
||||||
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
||||||
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
|
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
|
||||||
_ = templateID
|
_ = templateID
|
||||||
@@ -628,8 +924,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
|
|||||||
// Keep sys_admin: unprivileged containers need it to mount tmpfs (/dev/shm, /run, etc.)
|
// Keep sys_admin: unprivileged containers need it to mount tmpfs (/dev/shm, /run, etc.)
|
||||||
// All capabilities are already confined to the container's user namespace.
|
// All capabilities are already confined to the container's user namespace.
|
||||||
newLines = append(newLines, "lxc.cap.drop = mac_admin mac_override sys_module sys_rawio sys_time sys_boot sys_nice sys_resource sys_ptrace sys_pacct mknod audit_control audit_read")
|
newLines = append(newLines, "lxc.cap.drop = mac_admin mac_override sys_module sys_rawio sys_time sys_boot sys_nice sys_resource sys_ptrace sys_pacct mknod audit_control audit_read")
|
||||||
newLines = append(newLines, "lxc.prlimit.nofile = 1024:4096")
|
newLines = append(newLines, managedPrlimitLines()...)
|
||||||
newLines = append(newLines, "lxc.prlimit.nproc = 128:256")
|
|
||||||
newLines = append(newLines, "", "# clicd managed resource limits (cgroup v2)")
|
newLines = append(newLines, "", "# clicd managed resource limits (cgroup v2)")
|
||||||
|
|
||||||
if cfg.VCPU > 0 {
|
if cfg.VCPU > 0 {
|
||||||
@@ -659,6 +954,13 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func managedPrlimitLines() []string {
|
||||||
|
// Do not set lxc.prlimit.nproc for unprivileged containers: RLIMIT_NPROC is
|
||||||
|
// accounted by the host-mapped UID, so containers sharing a uid_map would
|
||||||
|
// consume one shared quota and fail to fork/exec during batch starts.
|
||||||
|
return []string{"lxc.prlimit.nofile = 1024:4096"}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) {
|
func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) {
|
||||||
if readMBps < 0 {
|
if readMBps < 0 {
|
||||||
readMBps = 0
|
readMBps = 0
|
||||||
@@ -1013,12 +1315,99 @@ func findSeccompProfile() (string, error) {
|
|||||||
"/etc/lxc/common.seccomp",
|
"/etc/lxc/common.seccomp",
|
||||||
} {
|
} {
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return path, nil
|
return ensureCVE202643499SeccompProfile(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", errors.New("required LXC seccomp profile not found")
|
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) {
|
func findAppArmorProfile() (string, error) {
|
||||||
data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles")
|
data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1355,6 +1744,7 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
c = config.FindContainer(id)
|
c = config.FindContainer(id)
|
||||||
if c != nil {
|
if c != nil {
|
||||||
c.IP = ip
|
c.IP = ip
|
||||||
|
m.refreshContainerIPv4Details(c)
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1368,6 +1758,9 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
|
if err := m.ensureLANHostAccess(c); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", lxcName, err)
|
||||||
|
}
|
||||||
if err := m.EnsureSSH(id); err != nil {
|
if err := m.EnsureSSH(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1391,7 +1784,6 @@ func (m *Manager) StartContainer(id int) error {
|
|||||||
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Container %d (%s) started, IP: %s\n", id, c.Name, ip)
|
fmt.Printf("Container %d (%s) started, IP: %s\n", id, c.Name, ip)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1432,13 +1824,35 @@ func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
|
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
|
||||||
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
|
// ApplyContainerLimits re-applies persisted LXC config limits and, when running,
|
||||||
|
// runtime cgroup/tc limits.
|
||||||
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
|
||||||
if c == nil || c.Status != "running" {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
config.NormalizeContainerResourceAliases(c)
|
config.NormalizeContainerResourceAliases(c)
|
||||||
lxcName := c.LxcName()
|
lxcName := c.LxcName()
|
||||||
|
if err := m.applyResourceLimits(lxcName, ContainerConfig{
|
||||||
|
Name: c.Name,
|
||||||
|
TemplateID: c.Template,
|
||||||
|
VCPU: c.VCPU,
|
||||||
|
RAMMB: c.RAMMB,
|
||||||
|
DiskGB: c.DiskGB,
|
||||||
|
NetworkBWMbps: c.NetworkBWMbps,
|
||||||
|
NetworkDownMbps: c.NetworkDownMbps,
|
||||||
|
NetworkUpMbps: c.NetworkUpMbps,
|
||||||
|
MonthlyTrafficGB: c.MonthlyTrafficGB,
|
||||||
|
IOSpeedMBps: c.IOSpeedMBps,
|
||||||
|
IOReadMBps: c.IOReadMBps,
|
||||||
|
IOWriteMBps: c.IOWriteMBps,
|
||||||
|
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
|
||||||
|
ExpiresAt: c.ExpiresAt,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.Status != "running" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CPU: write cpu.max
|
// CPU: write cpu.max
|
||||||
cpuQuota := int(c.VCPU * 100000)
|
cpuQuota := int(c.VCPU * 100000)
|
||||||
@@ -1646,6 +2060,7 @@ ip -4 addr show eth0 2>/dev/null | awk '/inet / {sub(/\/.*/, "", $2); print $2;
|
|||||||
return "", fmt.Errorf("no IPv4 address after DHCP repair in %s", lxcName)
|
return "", fmt.Errorf("no IPv4 address after DHCP repair in %s", lxcName)
|
||||||
}
|
}
|
||||||
c.IP = ip
|
c.IP = ip
|
||||||
|
m.refreshContainerIPv4Details(c)
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
return ip, nil
|
return ip, nil
|
||||||
}
|
}
|
||||||
@@ -1667,6 +2082,10 @@ func (m *Manager) WarmSSH(id int) error {
|
|||||||
if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" {
|
if ip, err := m.GetContainerIP(lxcName); err == nil && ip != "" {
|
||||||
if current := config.FindContainer(id); current != nil {
|
if current := config.FindContainer(id); current != nil {
|
||||||
current.IP = ip
|
current.IP = ip
|
||||||
|
m.refreshContainerIPv4Details(current)
|
||||||
|
if err := m.ensureLANHostAccess(current); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", lxcName, err)
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -1676,6 +2095,10 @@ func (m *Manager) WarmSSH(id int) error {
|
|||||||
if current := config.FindContainer(id); current != nil && current.IP == "" {
|
if current := config.FindContainer(id); current != nil && current.IP == "" {
|
||||||
if ip, err := m.EnsureContainerIPv4(id); err == nil && ip != "" {
|
if ip, err := m.EnsureContainerIPv4(id); err == nil && ip != "" {
|
||||||
current.IP = ip
|
current.IP = ip
|
||||||
|
m.refreshContainerIPv4Details(current)
|
||||||
|
if err := m.ensureLANHostAccess(current); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to prepare LAN IPv4 host access for %s: %v\n", lxcName, err)
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2423,6 +2846,71 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
|
|||||||
return "", fmt.Errorf("no IPv4 address found for %s (IPv6 is disabled for containers)", lxcName)
|
return "", fmt.Errorf("no IPv4 address found for %s (IPv6 is disabled for containers)", lxcName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetContainerIPv4Details(lxcName string) (string, int, string, error) {
|
||||||
|
script := `
|
||||||
|
addr="$(ip -4 -o addr show dev eth0 scope global 2>/dev/null | awk '{print $4; exit}')"
|
||||||
|
gateway="$(ip route show default 0.0.0.0/0 dev eth0 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="via") {print $(i+1); exit}}')"
|
||||||
|
printf '%s\n%s\n' "$addr" "$gateway"
|
||||||
|
`
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return "", 0, "", fmt.Errorf("timed out reading IPv4 details for %s", lxcName)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, "", fmt.Errorf("failed to read IPv4 details for %s: %v, output: %s", lxcName, err, string(output))
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimRight(string(output), "\n"), "\n")
|
||||||
|
if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" {
|
||||||
|
return "", 0, "", fmt.Errorf("no IPv4 address details found for %s", lxcName)
|
||||||
|
}
|
||||||
|
prefix, err := netip.ParsePrefix(strings.TrimSpace(lines[0]))
|
||||||
|
if err != nil || !prefix.Addr().Is4() {
|
||||||
|
return "", 0, "", fmt.Errorf("invalid IPv4 address details for %s: %s", lxcName, strings.TrimSpace(lines[0]))
|
||||||
|
}
|
||||||
|
gateway := ""
|
||||||
|
if len(lines) > 1 {
|
||||||
|
candidate := strings.TrimSpace(lines[1])
|
||||||
|
if addr, err := netip.ParseAddr(candidate); err == nil && addr.Is4() {
|
||||||
|
gateway = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix.Addr().String(), prefix.Bits(), gateway, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) refreshContainerIPv4Details(c *config.Container) {
|
||||||
|
if c == nil || c.IsKVM() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip, prefixLen, gateway, err := m.GetContainerIPv4Details(c.LxcName())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
if ip != "" && c.IP != ip {
|
||||||
|
c.IP = ip
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if c.UsesLANDHCP() {
|
||||||
|
if prefixLen > 0 && c.LANIPv4PrefixLen != prefixLen {
|
||||||
|
c.LANIPv4PrefixLen = prefixLen
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if gateway != "" && c.LANIPv4Gateway != gateway {
|
||||||
|
c.LANIPv4Gateway = gateway
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.NormalizeNetworkAssignments() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
config.SaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListContainers lists all LXC containers and updates statuses
|
// ListContainers lists all LXC containers and updates statuses
|
||||||
func (m *Manager) ListContainers() ([]config.Container, error) {
|
func (m *Manager) ListContainers() ([]config.Container, error) {
|
||||||
containers := config.AppConfig.Containers
|
containers := config.AppConfig.Containers
|
||||||
@@ -2438,6 +2926,7 @@ func (m *Manager) ListContainers() ([]config.Container, error) {
|
|||||||
ip, err := m.GetContainerIP(containers[i].LxcName())
|
ip, err := m.GetContainerIP(containers[i].LxcName())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
containers[i].IP = ip
|
containers[i].IP = ip
|
||||||
|
m.refreshContainerIPv4Details(&containers[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2697,7 +3186,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
|
|||||||
|
|
||||||
// Set root password and pre-configure network/SSH via chroot.
|
// Set root password and pre-configure network/SSH via chroot.
|
||||||
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
|
||||||
m.preconfigureNetwork(rootfsPath, templateID)
|
m.preconfigureNetwork(rootfsPath, ContainerConfig{TemplateID: templateID})
|
||||||
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
|
||||||
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
||||||
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
|
||||||
|
|||||||
@@ -88,3 +88,56 @@ func TestSafeRootfsPathRejectsSiblingPrefix(t *testing.T) {
|
|||||||
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
|
t.Fatalf("safeRootfsPath returned %v, want unsafe rootfs path error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsLXCVDenylistSeccompProfile(t *testing.T) {
|
||||||
|
tests := []string{`
|
||||||
|
# base profile
|
||||||
|
2
|
||||||
|
denylist
|
||||||
|
[all]
|
||||||
|
open_by_handle_at errno 1
|
||||||
|
`, `
|
||||||
|
2
|
||||||
|
blacklist allow
|
||||||
|
[all]
|
||||||
|
open_by_handle_at errno 1
|
||||||
|
`}
|
||||||
|
|
||||||
|
for _, profile := range tests {
|
||||||
|
if !isLXCVDenylistSeccompProfile(profile) {
|
||||||
|
t.Fatalf("expected v2 denylist profile for\n%s", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isLXCVDenylistSeccompProfile("1\nallowlist\n1\n") {
|
||||||
|
t.Fatal("did not expect v1 allowlist profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedPrlimitLinesDoNotSetNproc(t *testing.T) {
|
||||||
|
for _, line := range managedPrlimitLines() {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "lxc.prlimit.nproc") {
|
||||||
|
t.Fatalf("managed prlimit lines must not set nproc: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ func EnsureForwardRules(bridge string) {
|
|||||||
if bridge == "" {
|
if bridge == "" {
|
||||||
bridge = "lxcbr0"
|
bridge = "lxcbr0"
|
||||||
}
|
}
|
||||||
|
ensureLibvirtForwardRules(bridge)
|
||||||
rules := [][]string{
|
rules := [][]string{
|
||||||
{"-i", bridge, "-j", "ACCEPT"},
|
{"-i", bridge, "-j", "ACCEPT"},
|
||||||
{"-o", bridge, "-j", "ACCEPT"},
|
{"-o", bridge, "-j", "ACCEPT"},
|
||||||
@@ -269,6 +270,33 @@ func EnsureForwardRules(bridge string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureLibvirtForwardRules(bridge string) {
|
||||||
|
if bridge != "virbr0" || exec.Command("iptables", "-L", "LIBVIRT_FWI", "-n").Run() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rules := []struct {
|
||||||
|
chain string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{chain: "LIBVIRT_FWI", args: []string{"-o", bridge, "-j", "ACCEPT"}},
|
||||||
|
{chain: "LIBVIRT_FWO", args: []string{"-i", bridge, "-j", "ACCEPT"}},
|
||||||
|
{chain: "LIBVIRT_FWX", args: []string{"-i", bridge, "-o", bridge, "-j", "ACCEPT"}},
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if exec.Command("iptables", "-L", rule.chain, "-n").Run() != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
deleteArgs := append([]string{"-D", rule.chain}, rule.args...)
|
||||||
|
if exec.Command("iptables", deleteArgs...).Run() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
insertArgs := append([]string{"-I", rule.chain, "1"}, rule.args...)
|
||||||
|
exec.Command("iptables", insertArgs...).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CleanPortMappings removes all iptables rules for a container
|
// CleanPortMappings removes all iptables rules for a container
|
||||||
func (m *Manager) CleanPortMappings(id int) error {
|
func (m *Manager) CleanPortMappings(id int) error {
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
@@ -395,6 +423,10 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
if pm.HostPort <= 0 {
|
if pm.HostPort <= 0 {
|
||||||
pm.HostPort = pm.ContainerPort
|
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
|
// Check current container's own mappings
|
||||||
for i, existing := range c.PortMappings {
|
for i, existing := range c.PortMappings {
|
||||||
if i == skipIndex {
|
if i == skipIndex {
|
||||||
@@ -444,16 +476,12 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ports := make([]int, 0, count)
|
ports := make([]int, 0, count)
|
||||||
next := 20000
|
start, end := config.NATPortRange()
|
||||||
for len(ports) < count {
|
for next := start; next <= end && len(ports) < count; next++ {
|
||||||
hostIP := c.PrimaryPublicIPv4()
|
hostIP := c.PrimaryPublicIPv4()
|
||||||
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
if !used[hostPortKey(hostIP, next)] && !used[next] {
|
||||||
ports = append(ports, next)
|
ports = append(ports, next)
|
||||||
}
|
}
|
||||||
next++
|
|
||||||
if next > 65535 || len(ports) >= count {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ports
|
return ports
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package lxc
|
package lxc
|
||||||
|
|
||||||
|
import "runtime"
|
||||||
|
|
||||||
// Template represents an LXC image template
|
// Template represents an LXC image template
|
||||||
type Template struct {
|
type Template struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -13,55 +15,70 @@ type Template struct {
|
|||||||
|
|
||||||
// GetTemplates returns available LXC image templates (only verified working ones)
|
// GetTemplates returns available LXC image templates (only verified working ones)
|
||||||
func GetTemplates() []Template {
|
func GetTemplates() []Template {
|
||||||
|
arch := defaultTemplateArch()
|
||||||
return []Template{
|
return []Template{
|
||||||
{
|
{
|
||||||
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
||||||
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
Distro: "ubuntu", Release: "noble", Arch: arch,
|
||||||
Description: "Ubuntu 24.04 LTS",
|
Description: "Ubuntu 24.04 LTS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
|
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
|
||||||
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
Distro: "ubuntu", Release: "jammy", Arch: arch,
|
||||||
Description: "Ubuntu 22.04 LTS",
|
Description: "Ubuntu 22.04 LTS",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "debian-trixie", Name: "Debian 13",
|
||||||
|
Distro: "debian", Release: "trixie", Arch: arch,
|
||||||
|
Description: "Debian 13 (Trixie)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "debian-bookworm", Name: "Debian 12",
|
ID: "debian-bookworm", Name: "Debian 12",
|
||||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
Distro: "debian", Release: "bookworm", Arch: arch,
|
||||||
Description: "Debian 12 (Bookworm)",
|
Description: "Debian 12 (Bookworm)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "debian-bullseye", Name: "Debian 11",
|
ID: "debian-bullseye", Name: "Debian 11",
|
||||||
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
Distro: "debian", Release: "bullseye", Arch: arch,
|
||||||
Description: "Debian 11 (Bullseye)",
|
Description: "Debian 11 (Bullseye)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "alpine-3.21", Name: "Alpine 3.21",
|
ID: "alpine-3.21", Name: "Alpine 3.21",
|
||||||
Distro: "alpine", Release: "3.21", Arch: "amd64",
|
Distro: "alpine", Release: "3.21", Arch: arch,
|
||||||
Description: "Alpine Linux 3.21",
|
Description: "Alpine Linux 3.21",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "centos-9-stream", Name: "CentOS 9 Stream",
|
ID: "centos-9-stream", Name: "CentOS 9 Stream",
|
||||||
Distro: "centos", Release: "9-Stream", Arch: "amd64",
|
Distro: "centos", Release: "9-Stream", Arch: arch,
|
||||||
Description: "CentOS 9 Stream",
|
Description: "CentOS 9 Stream",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "archlinux-current", Name: "Arch Linux",
|
ID: "archlinux-current", Name: "Arch Linux",
|
||||||
Distro: "archlinux", Release: "current", Arch: "amd64",
|
Distro: "archlinux", Release: "current", Arch: arch,
|
||||||
Description: "Arch Linux (Rolling)",
|
Description: "Arch Linux (Rolling)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "fedora-44", Name: "Fedora 44",
|
ID: "fedora-44", Name: "Fedora 44",
|
||||||
Distro: "fedora", Release: "44", Arch: "amd64",
|
Distro: "fedora", Release: "44", Arch: arch,
|
||||||
Description: "Fedora 44",
|
Description: "Fedora 44",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
||||||
Distro: "rockylinux", Release: "10", Arch: "amd64",
|
Distro: "rockylinux", Release: "10", Arch: arch,
|
||||||
Description: "Rocky Linux 10",
|
Description: "Rocky Linux 10",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultTemplateArch() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return "arm64"
|
||||||
|
default:
|
||||||
|
return "amd64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FindTemplate finds a template by ID
|
// FindTemplate finds a template by ID
|
||||||
func FindTemplate(id string) *Template {
|
func FindTemplate(id string) *Template {
|
||||||
templates := GetTemplates()
|
templates := GetTemplates()
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
|
||||||
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
|
||||||
|
mux.HandleFunc("/api/host-history", corsMiddleware(api.AdminMiddleware(api.HandleHostHistory)))
|
||||||
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
|
||||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||||
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
@@ -104,6 +105,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
|
mux.HandleFunc("/api/v1/images/toggle", corsMiddleware(api.AuthMiddleware(api.HandleImageToggle)))
|
||||||
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
mux.HandleFunc("/api/v1/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
|
||||||
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
|
||||||
|
mux.HandleFunc("/api/v1/host-history", corsMiddleware(api.AuthMiddleware(api.HandleHostHistory)))
|
||||||
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
|
||||||
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
|
||||||
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
|
||||||
@@ -174,6 +176,8 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
func Run() error {
|
func Run() error {
|
||||||
// Use embedded frontend files
|
// Use embedded frontend files
|
||||||
webFS = GetEmbeddedFS()
|
webFS = GetEmbeddedFS()
|
||||||
|
api.StartHostMetricSampler()
|
||||||
|
api.StartContainerMetricSampler()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
setupRoutes(mux)
|
setupRoutes(mux)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.19"
|
Version = "1.1.24"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
BUILD_DIR="$SCRIPT_DIR/build"
|
BUILD_DIR="$SCRIPT_DIR/build"
|
||||||
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
FRONTEND_DIR="$SCRIPT_DIR/frontend"
|
FRONTEND_DIR="$SCRIPT_DIR/frontend"
|
||||||
BACKEND_DIR="$SCRIPT_DIR/backend"
|
BACKEND_DIR="$SCRIPT_DIR/backend"
|
||||||
WEB_DIR="$SCRIPT_DIR/web"
|
WEB_DIR="$SCRIPT_DIR/web"
|
||||||
@@ -17,9 +18,11 @@ echo "====================================="
|
|||||||
|
|
||||||
# Clean previous build
|
# Clean previous build
|
||||||
rm -rf "$BUILD_DIR"
|
rm -rf "$BUILD_DIR"
|
||||||
|
rm -rf "$DIST_DIR"
|
||||||
rm -rf "$WEB_DIR"
|
rm -rf "$WEB_DIR"
|
||||||
rm -rf "$EMBED_WEB_DIR"
|
rm -rf "$EMBED_WEB_DIR"
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
mkdir -p "$WEB_DIR"
|
mkdir -p "$WEB_DIR"
|
||||||
mkdir -p "$EMBED_WEB_DIR"
|
mkdir -p "$EMBED_WEB_DIR"
|
||||||
touch "$EMBED_WEB_DIR/.gitkeep"
|
touch "$EMBED_WEB_DIR/.gitkeep"
|
||||||
@@ -51,9 +54,26 @@ cd "$BACKEND_DIR"
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# Build for Linux amd64
|
|
||||||
BUILD_VERSION="${CLICD_VERSION:-dev}"
|
BUILD_VERSION="${CLICD_VERSION:-dev}"
|
||||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" .
|
TARGET_GOOS="${CLICD_GOOS:-linux}"
|
||||||
|
TARGET_GOARCH="${CLICD_GOARCH:-amd64}"
|
||||||
|
|
||||||
|
case "$TARGET_GOARCH" in
|
||||||
|
all) TARGET_GOARCH_LIST="amd64 arm64" ;;
|
||||||
|
amd64|arm64) TARGET_GOARCH_LIST="$TARGET_GOARCH" ;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported CLICD_GOARCH: $TARGET_GOARCH (expected amd64, arm64, or all)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
for arch in $TARGET_GOARCH_LIST; do
|
||||||
|
echo "Target: ${TARGET_GOOS}/${arch}"
|
||||||
|
GOOS="$TARGET_GOOS" GOARCH="$arch" CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd-linux-${arch}" .
|
||||||
|
done
|
||||||
|
|
||||||
|
first_arch="${TARGET_GOARCH_LIST%% *}"
|
||||||
|
cp "$BUILD_DIR/clicd-linux-${first_arch}" "$BUILD_DIR/clicd"
|
||||||
|
|
||||||
echo "Go backend built successfully"
|
echo "Go backend built successfully"
|
||||||
|
|
||||||
@@ -62,7 +82,20 @@ echo ""
|
|||||||
echo "[3/3] Packaging..."
|
echo "[3/3] Packaging..."
|
||||||
cp -r "$WEB_DIR" "$BUILD_DIR/web"
|
cp -r "$WEB_DIR" "$BUILD_DIR/web"
|
||||||
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
|
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
|
||||||
chmod +x "$BUILD_DIR/clicd"
|
chmod +x "$BUILD_DIR"/clicd*
|
||||||
|
|
||||||
|
for arch in $TARGET_GOARCH_LIST; do
|
||||||
|
asset_dir="clicd-linux-${arch}"
|
||||||
|
package_root="$BUILD_DIR/package-${arch}"
|
||||||
|
rm -rf "$package_root"
|
||||||
|
mkdir -p "$package_root/$asset_dir"
|
||||||
|
cp "$BUILD_DIR/clicd-linux-${arch}" "$package_root/$asset_dir/clicd"
|
||||||
|
cp "$BUILD_DIR/install.sh" "$package_root/$asset_dir/install.sh" 2>/dev/null || true
|
||||||
|
chmod +x "$package_root/$asset_dir/clicd"
|
||||||
|
[ ! -f "$package_root/$asset_dir/install.sh" ] || chmod +x "$package_root/$asset_dir/install.sh"
|
||||||
|
tar -C "$package_root" -czf "$DIST_DIR/${asset_dir}.tar.gz" "$asset_dir"
|
||||||
|
cp "$BUILD_DIR/clicd-linux-${arch}" "$DIST_DIR/${asset_dir}"
|
||||||
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
@@ -70,6 +103,11 @@ echo " Build Complete!"
|
|||||||
echo "====================================="
|
echo "====================================="
|
||||||
echo " Output: $BUILD_DIR/clicd"
|
echo " Output: $BUILD_DIR/clicd"
|
||||||
echo " Web: $BUILD_DIR/web/"
|
echo " Web: $BUILD_DIR/web/"
|
||||||
|
echo " Dist: $DIST_DIR/"
|
||||||
|
for arch in $TARGET_GOARCH_LIST; do
|
||||||
|
echo " dist/clicd-linux-${arch}"
|
||||||
|
echo " dist/clicd-linux-${arch}.tar.gz"
|
||||||
|
done
|
||||||
echo ""
|
echo ""
|
||||||
echo " To deploy:"
|
echo " To deploy:"
|
||||||
echo " 1. Copy build/ directory to server"
|
echo " 1. Copy build/ directory to server"
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ export default defineConfig({
|
|||||||
head: [
|
head: [
|
||||||
['link', { rel: 'icon', href: '/favicon.svg' }],
|
['link', { rel: 'icon', href: '/favicon.svg' }],
|
||||||
],
|
],
|
||||||
|
vite: {
|
||||||
|
esbuild: {
|
||||||
|
supported: {
|
||||||
|
destructuring: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
locales: {
|
locales: {
|
||||||
root: {
|
root: {
|
||||||
label: '简体中文',
|
label: '简体中文',
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ bash build.sh
|
|||||||
|
|
||||||
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
|
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
|
||||||
|
|
||||||
|
默认目标为 Linux amd64。需要构建 ARM64 包时可以指定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLICD_GOARCH=arm64 bash build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
需要同时构建 amd64 和 arm64 发布包时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLICD_GOARCH=all bash build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
构建完成后会生成:
|
||||||
|
|
||||||
|
- `dist/clicd-linux-amd64`
|
||||||
|
- `dist/clicd-linux-amd64.tar.gz`
|
||||||
|
- `dist/clicd-linux-arm64`
|
||||||
|
- `dist/clicd-linux-arm64.tar.gz`
|
||||||
|
|
||||||
## 文档站构建
|
## 文档站构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
|
|||||||
|
|
||||||
## Release 产物
|
## Release 产物
|
||||||
|
|
||||||
安装脚本会优先下载 Linux AMD64 产物:
|
安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clicd-linux-amd64.tar.gz
|
clicd-linux-amd64.tar.gz
|
||||||
|
clicd-linux-arm64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
在部分场景中也会尝试下载单独二进制:
|
在部分场景中也会尝试下载单独二进制:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clicd-linux-amd64
|
clicd-linux-amd64
|
||||||
|
clicd-linux-arm64
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安装脚本行为
|
## 安装脚本行为
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ bash build.sh
|
|||||||
|
|
||||||
The script chains frontend build, static asset sync, and Go binary build.
|
The script chains frontend build, static asset sync, and Go binary build.
|
||||||
|
|
||||||
|
The default target is Linux amd64. To build an ARM64 package, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLICD_GOARCH=arm64 bash build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To build both amd64 and arm64 release assets at once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLICD_GOARCH=all bash build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The build writes:
|
||||||
|
|
||||||
|
- `dist/clicd-linux-amd64`
|
||||||
|
- `dist/clicd-linux-amd64.tar.gz`
|
||||||
|
- `dist/clicd-linux-arm64`
|
||||||
|
- `dist/clicd-linux-arm64.tar.gz`
|
||||||
|
|
||||||
## Docs Build
|
## Docs Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ Check the version in:
|
|||||||
|
|
||||||
## Release Artifacts
|
## Release Artifacts
|
||||||
|
|
||||||
The installer first tries to download the Linux AMD64 archive:
|
The installer first tries to download the Linux AMD64 or ARM64 archive for the host architecture:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clicd-linux-amd64.tar.gz
|
clicd-linux-amd64.tar.gz
|
||||||
|
clicd-linux-arm64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
In some cases, it may also try the standalone binary:
|
In some cases, it may also try the standalone binary:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clicd-linux-amd64
|
clicd-linux-amd64
|
||||||
|
clicd-linux-arm64
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installer Behavior
|
## Installer Behavior
|
||||||
|
|||||||
+194
-10
@@ -53,7 +53,11 @@ Create container example:
|
|||||||
"ssh_auth_mode": "auto_password",
|
"ssh_auth_mode": "auto_password",
|
||||||
"ssh_password": "",
|
"ssh_password": "",
|
||||||
"ssh_public_key": "",
|
"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_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_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. |
|
| `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:
|
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.
|
`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
|
## Python Example
|
||||||
|
|
||||||
Fetch containers:
|
Fetch containers:
|
||||||
@@ -140,6 +246,7 @@ print(resp.json())
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/api/v1/dashboard` | Dashboard statistics |
|
| GET | `/api/v1/dashboard` | Dashboard statistics |
|
||||||
| GET | `/api/v1/host-info` | Host resources |
|
| GET | `/api/v1/host-info` | Host resources |
|
||||||
|
| GET | `/api/v1/host-report` | Host inspection report |
|
||||||
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing |
|
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing |
|
||||||
| PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools |
|
| PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools |
|
||||||
| POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment |
|
| POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment |
|
||||||
@@ -151,10 +258,11 @@ print(resp.json())
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| 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/list` | Compatible POST form for container list |
|
||||||
| POST | `/api/v1/containers` | Create container |
|
| 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}/start` | Start |
|
||||||
| POST | `/api/v1/containers/{id}/stop` | Stop |
|
| POST | `/api/v1/containers/{id}/stop` | Stop |
|
||||||
| POST | `/api/v1/containers/{id}/restart` | Restart |
|
| POST | `/api/v1/containers/{id}/restart` | Restart |
|
||||||
@@ -173,10 +281,12 @@ print(resp.json())
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| 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 |
|
| POST | `/api/v1/containers/{id}/port-mappings` | Add port mapping |
|
||||||
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping |
|
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping |
|
||||||
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | Delete 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/snapshots` | Snapshot overview |
|
||||||
| GET | `/api/v1/containers/{id}/snapshots` | Container snapshots |
|
| GET | `/api/v1/containers/{id}/snapshots` | Container snapshots |
|
||||||
| POST | `/api/v1/containers/{id}/snapshots` | Create snapshot |
|
| POST | `/api/v1/containers/{id}/snapshots` | Create snapshot |
|
||||||
@@ -191,6 +301,7 @@ print(resp.json())
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/api/v1/templates` | Template list |
|
| GET | `/api/v1/templates` | Template list |
|
||||||
| GET | `/api/v1/images` | Image management 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/download` | Download image |
|
||||||
| POST | `/api/v1/images/cancel` | Cancel image download |
|
| POST | `/api/v1/images/cancel` | Cancel image download |
|
||||||
| DELETE | `/api/v1/images/delete` | Delete image cache |
|
| DELETE | `/api/v1/images/delete` | Delete image cache |
|
||||||
@@ -203,6 +314,12 @@ print(resp.json())
|
|||||||
| PUT | `/api/v1/security/settings` | Update security settings |
|
| PUT | `/api/v1/security/settings` | Update security settings |
|
||||||
| GET | `/api/v1/swap` | Swap information |
|
| GET | `/api/v1/swap` | Swap information |
|
||||||
| POST | `/api/v1/swap` | Adjust Swap |
|
| 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-create` | Batch create containers |
|
||||||
| POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall |
|
| POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall |
|
||||||
| POST | `/api/v1/ssh-ticket` | Create WebSSH ticket |
|
| 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 }
|
"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": {
|
"GET /api/v1/routing": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
@@ -331,6 +458,10 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
|||||||
"vcpu": 1,
|
"vcpu": 1,
|
||||||
"ram_mb": 512,
|
"ram_mb": 512,
|
||||||
"disk_gb": 10,
|
"disk_gb": 10,
|
||||||
|
"network_down_mbps": 100,
|
||||||
|
"network_up_mbps": 50,
|
||||||
|
"io_read_mbps": 120,
|
||||||
|
"io_write_mbps": 80,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"ip": "10.0.0.10",
|
"ip": "10.0.0.10",
|
||||||
"ipv6": "2001:db8:100::1005",
|
"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": {
|
"POST /api/v1/containers/list": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
@@ -410,7 +547,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"mode": "total",
|
"mode": "total",
|
||||||
"limit_gb": 0,
|
"limit_gb": 1024,
|
||||||
"in_limit_gb": 0,
|
"in_limit_gb": 0,
|
||||||
"out_limit_gb": 0,
|
"out_limit_gb": 0,
|
||||||
"total_used_bytes": 142082,
|
"total_used_bytes": 142082,
|
||||||
@@ -453,7 +590,7 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"GET /api/v1/containers/{id}/random-port": {
|
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": { "port": 61320 }
|
"data": { "port": 61320 }
|
||||||
},
|
},
|
||||||
@@ -474,6 +611,21 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": []
|
"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": {
|
"GET /api/v1/snapshots": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": null
|
"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 }
|
{ "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": {
|
"POST /api/v1/images/download": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Already downloaded"
|
"message": "Already downloaded"
|
||||||
@@ -585,9 +743,35 @@ The samples below are grouped by endpoint path. Resource numbers, task IDs, cont
|
|||||||
},
|
},
|
||||||
"POST /api/v1/swap": {
|
"POST /api/v1/swap": {
|
||||||
"success": true,
|
"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" }
|
"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": {
|
"POST /api/v1/batch-create": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": ["task-12"]
|
"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": {
|
"GET /api/v1/api-keys": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"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": {
|
"POST /api/v1/api-keys": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "API key created. Save this key now - it won't be shown again.",
|
"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}": {
|
"PATCH /api/v1/api-keys/{id}": {
|
||||||
"success": true,
|
"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}": {
|
"DELETE /api/v1/api-keys/{id}": {
|
||||||
"success": true,
|
"success": true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Linux x86_64 host.
|
- Linux x86_64/amd64 or ARM64/aarch64 host.
|
||||||
- Root privileges.
|
- Root privileges.
|
||||||
- systemd.
|
- systemd.
|
||||||
- Network access to GitHub Release downloads.
|
- Network access to GitHub Release downloads.
|
||||||
@@ -17,7 +17,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
|
|||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script defaults to `CLICD_VERSION=latest`, which downloads `clicd-linux-amd64.tar.gz` from `releases/latest`.
|
The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture.
|
||||||
|
|
||||||
## Install a Specific Version
|
## Install a Specific Version
|
||||||
|
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ CLICD is a lightweight virtualization management panel for LXC and KVM. It bring
|
|||||||
|
|
||||||
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
|
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
|
||||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
|
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
|
||||||
- Release: GitHub Actions builds Linux AMD64 release artifacts. The installer fetches the latest release by default.
|
- Release: GitHub Actions builds Linux AMD64/ARM64 release artifacts. The installer fetches the latest release by default.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Which version does the installer install by default?
|
## Which version does the installer install by default?
|
||||||
|
|
||||||
It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 artifact from `releases/latest`.
|
It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 or ARM64 artifact from `releases/latest` according to the host architecture.
|
||||||
|
|
||||||
## Can I pin a specific version?
|
## Can I pin a specific version?
|
||||||
|
|
||||||
|
|||||||
+193
-9
@@ -53,7 +53,11 @@ curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/da
|
|||||||
"ssh_auth_mode": "auto_password",
|
"ssh_auth_mode": "auto_password",
|
||||||
"ssh_password": "",
|
"ssh_password": "",
|
||||||
"ssh_public_key": "",
|
"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_auth_mode` | Linux 创建支持 `auto_password`、`password`、`key`;重装额外支持 `keep`。 |
|
||||||
| `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 |
|
| `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 |
|
||||||
| `ssh_public_key` | `key` 模式下的一行 SSH 公钥。 |
|
| `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 公钥相关字段。
|
`keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。
|
||||||
|
|
||||||
|
## 资源限制与流量限制
|
||||||
|
|
||||||
|
`PUT /api/v1/containers/{id}/resource-limit` 支持按字段局部更新;未传的字段保持不变。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vcpu": 2,
|
||||||
|
"ram_mb": 1024,
|
||||||
|
"network_down_mbps": 100,
|
||||||
|
"network_up_mbps": 50,
|
||||||
|
"io_read_mbps": 120,
|
||||||
|
"io_write_mbps": 80
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
旧版 `network_bw_mbps` 和 `io_speed_mbps` 仍可用,分别表示上下行对称带宽和读写对称 IO 限速。新接入建议使用拆分字段,以便分别控制下载/上传和读取/写入。
|
||||||
|
|
||||||
|
`PUT /api/v1/containers/{id}/traffic-limit` 请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"traffic_mode": "total",
|
||||||
|
"monthly_traffic_gb": 1024,
|
||||||
|
"traffic_in_gb": 0,
|
||||||
|
"traffic_out_gb": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `traffic_mode` | 流量限制模式;常用 `total` 表示总量限制,`split` 表示入站/出站分别限制。 |
|
||||||
|
| `monthly_traffic_gb` | `total` 模式下的月总流量额度,单位 GB;`0` 表示不限制。 |
|
||||||
|
| `traffic_in_gb` | `split` 模式下的月入站额度,单位 GB;`0` 表示不限制。 |
|
||||||
|
| `traffic_out_gb` | `split` 模式下的月出站额度,单位 GB;`0` 表示不限制。 |
|
||||||
|
|
||||||
|
## 容器防火墙
|
||||||
|
|
||||||
|
容器防火墙通过 `GET /api/v1/containers/{id}/firewall` 读取,通过 `PUT /api/v1/containers/{id}/firewall` 更新。容器运行中更新时会立即应用规则。
|
||||||
|
|
||||||
|
更新示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"default_action": "DROP",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"direction": "in",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"action": "ACCEPT",
|
||||||
|
"network": "ipv4",
|
||||||
|
"source_ip": "203.0.113.0/24",
|
||||||
|
"port": "22,80,443",
|
||||||
|
"description": "allow admin and web"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `enabled` | 是否启用容器防火墙。 |
|
||||||
|
| `default_action` | 默认动作:`ACCEPT` 或 `DROP`。 |
|
||||||
|
| `rules[].id` | 可选;新规则可省略,后端会自动生成。 |
|
||||||
|
| `rules[].direction` | 方向:`in` 或 `out`。 |
|
||||||
|
| `rules[].protocol` | 协议:`tcp`、`udp`、`icmp` 或 `all`。 |
|
||||||
|
| `rules[].action` | 动作:`ACCEPT` 或 `DROP`。 |
|
||||||
|
| `rules[].network` | 网络类型:`ipv4`、`ipv6` 或 `all`。 |
|
||||||
|
| `rules[].source_ip` | 可选;源 IP、CIDR 或地址范围。 |
|
||||||
|
| `rules[].port` | 可选;仅 `tcp`/`udp` 支持,可写 `22`、`80,443` 或 `8000-9000`。 |
|
||||||
|
| `rules[].description` | 可选备注。 |
|
||||||
|
|
||||||
|
## API Key 创建与更新
|
||||||
|
|
||||||
|
`POST /api/v1/api-keys` 和 `PATCH /api/v1/api-keys/{id}` 使用相同的字段结构。创建时 `name` 必填;更新时根据需要覆盖字段。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Automation",
|
||||||
|
"ip_whitelist": "198.51.100.23,203.0.113.0/24",
|
||||||
|
"scopes": ["dashboard:read", "container:read", "container:power"],
|
||||||
|
"expires_at": "2026-12-31 23:59:59",
|
||||||
|
"disabled": false,
|
||||||
|
"container_uuids": ["00000000-0000-4000-8000-000000000005"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` | API Key 名称;创建时必填。 |
|
||||||
|
| `ip_whitelist` | 可选;允许的来源 IP/CIDR,多个值用逗号分隔;空值表示不限制。 |
|
||||||
|
| `scopes` | 可选;权限范围。省略时使用默认只读范围,传 `*` 表示全部权限。 |
|
||||||
|
| `expires_at` | 可选;过期时间,空值表示不过期。 |
|
||||||
|
| `disabled` | 是否禁用该 Key。 |
|
||||||
|
| `container_uuids` | 可选;限制该 Key 只能访问指定容器。 |
|
||||||
|
|
||||||
## Python 示例
|
## Python 示例
|
||||||
|
|
||||||
获取容器列表:
|
获取容器列表:
|
||||||
@@ -140,6 +246,7 @@ print(resp.json())
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/api/v1/dashboard` | 控制面板统计 |
|
| GET | `/api/v1/dashboard` | 控制面板统计 |
|
||||||
| GET | `/api/v1/host-info` | 主机资源 |
|
| GET | `/api/v1/host-info` | 主机资源 |
|
||||||
|
| GET | `/api/v1/host-report` | 主机巡检报告 |
|
||||||
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 |
|
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 |
|
||||||
| PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 |
|
| PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 |
|
||||||
| POST | `/api/v1/routing/ipv4-scan` | 扫描公网 IPv4 段 |
|
| 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/list` | 容器列表兼容 POST 写法 |
|
||||||
| POST | `/api/v1/containers` | 创建容器 |
|
| 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}/start` | 开机 |
|
||||||
| POST | `/api/v1/containers/{id}/stop` | 关机 |
|
| POST | `/api/v1/containers/{id}/stop` | 关机 |
|
||||||
| POST | `/api/v1/containers/{id}/restart` | 重启 |
|
| 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` | 添加端口映射 |
|
| POST | `/api/v1/containers/{id}/port-mappings` | 添加端口映射 |
|
||||||
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | 更新端口映射 |
|
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | 更新端口映射 |
|
||||||
| DELETE | `/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/snapshots` | 快照总览 |
|
||||||
| GET | `/api/v1/containers/{id}/snapshots` | 容器快照 |
|
| GET | `/api/v1/containers/{id}/snapshots` | 容器快照 |
|
||||||
| POST | `/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/templates` | 模板列表 |
|
||||||
| GET | `/api/v1/images` | 镜像管理列表 |
|
| GET | `/api/v1/images` | 镜像管理列表 |
|
||||||
|
| GET | `/api/v1/images/enabled` | 已启用且已下载的镜像;支持 `type=lxc\|kvm` |
|
||||||
| POST | `/api/v1/images/download` | 下载镜像 |
|
| POST | `/api/v1/images/download` | 下载镜像 |
|
||||||
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
|
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
|
||||||
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
|
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
|
||||||
@@ -203,6 +314,12 @@ print(resp.json())
|
|||||||
| PUT | `/api/v1/security/settings` | 更新安全设置 |
|
| PUT | `/api/v1/security/settings` | 更新安全设置 |
|
||||||
| GET | `/api/v1/swap` | Swap 信息 |
|
| GET | `/api/v1/swap` | Swap 信息 |
|
||||||
| POST | `/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-create` | 批量创建容器 |
|
||||||
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
|
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
|
||||||
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
|
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
|
||||||
@@ -255,6 +372,16 @@ print(resp.json())
|
|||||||
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
|
"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": {
|
"GET /api/v1/routing": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
@@ -331,6 +458,10 @@ print(resp.json())
|
|||||||
"vcpu": 1,
|
"vcpu": 1,
|
||||||
"ram_mb": 512,
|
"ram_mb": 512,
|
||||||
"disk_gb": 10,
|
"disk_gb": 10,
|
||||||
|
"network_down_mbps": 100,
|
||||||
|
"network_up_mbps": 50,
|
||||||
|
"io_read_mbps": 120,
|
||||||
|
"io_write_mbps": 80,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"ip": "10.0.0.10",
|
"ip": "10.0.0.10",
|
||||||
"ipv6": "2001:db8:100::1005",
|
"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": {
|
"POST /api/v1/containers/list": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
@@ -410,7 +547,7 @@ print(resp.json())
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"mode": "total",
|
"mode": "total",
|
||||||
"limit_gb": 0,
|
"limit_gb": 1024,
|
||||||
"in_limit_gb": 0,
|
"in_limit_gb": 0,
|
||||||
"out_limit_gb": 0,
|
"out_limit_gb": 0,
|
||||||
"total_used_bytes": 142082,
|
"total_used_bytes": 142082,
|
||||||
@@ -453,7 +590,7 @@ print(resp.json())
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"GET /api/v1/containers/{id}/random-port": {
|
"GET /api/v1/containers/{id}/random-port?host_ip=203.0.113.10": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": { "port": 61320 }
|
"data": { "port": 61320 }
|
||||||
},
|
},
|
||||||
@@ -474,6 +611,21 @@ print(resp.json())
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": []
|
"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": {
|
"GET /api/v1/snapshots": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": null
|
"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 }
|
{ "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": {
|
"POST /api/v1/images/download": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Already downloaded"
|
"message": "Already downloaded"
|
||||||
@@ -588,6 +746,32 @@ print(resp.json())
|
|||||||
"message": "SWAP 已调整为 16384 MB",
|
"message": "SWAP 已调整为 16384 MB",
|
||||||
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
|
"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": {
|
"POST /api/v1/batch-create": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": ["task-12"]
|
"data": ["task-12"]
|
||||||
@@ -654,17 +838,17 @@ print(resp.json())
|
|||||||
"GET /api/v1/api-keys": {
|
"GET /api/v1/api-keys": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"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": {
|
"POST /api/v1/api-keys": {
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "API key created. Save this key now - it won't be shown again.",
|
"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}": {
|
"PATCH /api/v1/api-keys/{id}": {
|
||||||
"success": true,
|
"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}": {
|
"DELETE /api/v1/api-keys/{id}": {
|
||||||
"success": true,
|
"success": true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
|||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- Linux x86_64 宿主机。
|
- Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
|
||||||
- root 权限。
|
- root 权限。
|
||||||
- systemd。
|
- systemd。
|
||||||
- 网络可访问 GitHub Release 下载地址。
|
- 网络可访问 GitHub Release 下载地址。
|
||||||
@@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
|||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`。
|
脚本当前默认使用 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 或 `clicd-linux-arm64.tar.gz`。
|
||||||
|
|
||||||
## 安装指定版本
|
## 安装指定版本
|
||||||
|
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿
|
|||||||
|
|
||||||
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
|
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
|
||||||
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
|
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
|
||||||
- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。
|
- 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 安装脚本默认安装哪个版本?
|
## 安装脚本默认安装哪个版本?
|
||||||
|
|
||||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
|
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
|
||||||
|
|
||||||
## 可以固定安装某个版本吗?
|
## 可以固定安装某个版本吗?
|
||||||
|
|
||||||
|
|||||||
Generated
+110
-110
@@ -369,9 +369,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -386,9 +386,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -403,9 +403,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -420,9 +420,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -437,9 +437,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -454,9 +454,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -471,9 +471,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -488,9 +488,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -505,9 +505,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -522,9 +522,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -539,9 +539,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -556,9 +556,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -573,9 +573,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -590,9 +590,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -607,9 +607,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -624,9 +624,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -641,9 +641,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -658,9 +658,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -675,9 +675,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -692,9 +692,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -709,9 +709,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -726,9 +726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -743,9 +743,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -760,9 +760,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -777,9 +777,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -794,9 +794,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1757,9 +1757,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1770,32 +1770,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.25.12",
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
"@esbuild/android-arm": "0.25.12",
|
"@esbuild/android-arm": "0.28.1",
|
||||||
"@esbuild/android-arm64": "0.25.12",
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
"@esbuild/android-x64": "0.25.12",
|
"@esbuild/android-x64": "0.28.1",
|
||||||
"@esbuild/darwin-arm64": "0.25.12",
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
"@esbuild/darwin-x64": "0.25.12",
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
"@esbuild/freebsd-arm64": "0.25.12",
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
"@esbuild/freebsd-x64": "0.25.12",
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
"@esbuild/linux-arm": "0.25.12",
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
"@esbuild/linux-arm64": "0.25.12",
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
"@esbuild/linux-ia32": "0.25.12",
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
"@esbuild/linux-loong64": "0.25.12",
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
"@esbuild/linux-mips64el": "0.25.12",
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
"@esbuild/linux-ppc64": "0.25.12",
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
"@esbuild/linux-riscv64": "0.25.12",
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
"@esbuild/linux-s390x": "0.25.12",
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
"@esbuild/linux-x64": "0.25.12",
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
"@esbuild/netbsd-arm64": "0.25.12",
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
"@esbuild/netbsd-x64": "0.25.12",
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
"@esbuild/openbsd-arm64": "0.25.12",
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
"@esbuild/openbsd-x64": "0.25.12",
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
"@esbuild/openharmony-arm64": "0.25.12",
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
"@esbuild/sunos-x64": "0.25.12",
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
"@esbuild/win32-arm64": "0.25.12",
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
"@esbuild/win32-ia32": "0.25.12",
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
"@esbuild/win32-x64": "0.25.12"
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
@@ -2475,9 +2475,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@
|
|||||||
"vitepress": "^1.6.4"
|
"vitepress": "^1.6.4"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "6.4.2"
|
"vite": "6.4.3",
|
||||||
|
"esbuild": "0.28.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novnc/novnc": "1.5.0",
|
"@novnc/novnc": "1.5.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
@@ -1372,16 +1372,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.19",
|
"version": "1.1.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { CalendarClock, RefreshCw, X } from 'lucide-react'
|
import { CalendarClock, RefreshCw, X } from 'lucide-react'
|
||||||
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
|
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, getHostReport, CreateContainerRequest, HostInfo, HostProbeReport, IPv6Status, Template } from '../services/api'
|
||||||
import { useDialog } from './Dialog'
|
import { useDialog } from './Dialog'
|
||||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||||
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
|
||||||
@@ -33,6 +33,11 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
extra_ports: [],
|
extra_ports: [],
|
||||||
port_mapping_count: 2,
|
port_mapping_count: 2,
|
||||||
assign_nat: true,
|
assign_nat: true,
|
||||||
|
lan_ipv4_mode: '',
|
||||||
|
lan_interface: '',
|
||||||
|
lan_ipv4_address: '',
|
||||||
|
lan_ipv4_prefix_len: 24,
|
||||||
|
lan_ipv4_gateway: '',
|
||||||
snapshot_limit: 1,
|
snapshot_limit: 1,
|
||||||
assign_ipv4: false,
|
assign_ipv4: false,
|
||||||
ipv4_count: 1,
|
ipv4_count: 1,
|
||||||
@@ -43,6 +48,8 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
ssh_auth_mode: 'auto_password',
|
ssh_auth_mode: 'auto_password',
|
||||||
ssh_password: '',
|
ssh_password: '',
|
||||||
ssh_public_key: '',
|
ssh_public_key: '',
|
||||||
|
allowed_image_ids: [],
|
||||||
|
image_limit_configured: false,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +62,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
const [batchCount, setBatchCount] = useState(1)
|
const [batchCount, setBatchCount] = useState(1)
|
||||||
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
||||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||||
|
const [hostReport, setHostReport] = useState<HostProbeReport | null>(null)
|
||||||
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
||||||
const [nameError, setNameError] = useState('')
|
const [nameError, setNameError] = useState('')
|
||||||
|
|
||||||
@@ -67,7 +75,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
setTemplates(data)
|
setTemplates(data)
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
||||||
return applyTemplateDefaults({ ...prev, template_id: templateID })
|
const allowed = new Set(data.map((item) => item.id))
|
||||||
|
const selectedAllowedIDs = (prev.allowed_image_ids || []).filter((id) => allowed.has(id))
|
||||||
|
return applyTemplateDefaults({
|
||||||
|
...prev,
|
||||||
|
template_id: templateID,
|
||||||
|
allowed_image_ids: prev.image_limit_configured ? selectedAllowedIDs : (templateID ? [templateID] : []),
|
||||||
|
image_limit_configured: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -88,6 +103,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
getHostInfo()
|
getHostInfo()
|
||||||
.then((res) => setHostInfo(res.data.data || null))
|
.then((res) => setHostInfo(res.data.data || null))
|
||||||
.catch(() => setHostInfo(null))
|
.catch(() => setHostInfo(null))
|
||||||
|
|
||||||
|
getHostReport()
|
||||||
|
.then((res) => setHostReport(res.data.data || null))
|
||||||
|
.catch(() => setHostReport(null))
|
||||||
}, [isOpen, form.virtualization])
|
}, [isOpen, form.virtualization])
|
||||||
|
|
||||||
const ipv6Available = !!ipv6Status?.available
|
const ipv6Available = !!ipv6Status?.available
|
||||||
@@ -98,9 +117,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
const manualIPv4s = form.public_ipv4s || []
|
const manualIPv4s = form.public_ipv4s || []
|
||||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||||
|
const kvmAvailable = !!hostInfo?.runtime?.kvm_available
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') {
|
||||||
|
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))
|
||||||
|
}
|
||||||
|
}, [hostInfo, kvmAvailable, form.virtualization])
|
||||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||||
const natEnabled = form.assign_nat !== false
|
const lanIPv4Enabled = form.lan_ipv4_mode === 'dhcp' || form.lan_ipv4_mode === 'static'
|
||||||
|
const lanStaticEnabled = form.lan_ipv4_mode === 'static'
|
||||||
|
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
|
||||||
|
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
|
||||||
|
const defaultLANInterface = lanInterfaces[0]?.name || ''
|
||||||
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
|
||||||
const linuxTemplate = !isWindowsTemplate(form.template_id)
|
const linuxTemplate = !isWindowsTemplate(form.template_id)
|
||||||
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
||||||
@@ -153,11 +183,18 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
|
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false && form.lan_ipv4_mode !== 'dhcp' && form.lan_ipv4_mode !== 'static') {
|
||||||
dialog.alert('提示', '请勾选任意一个可用网络')
|
dialog.alert('提示', '请勾选任意一个可用网络')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.lan_ipv4_mode === 'static') {
|
||||||
|
if (!isIPv4Address(form.lan_ipv4_address || '') || !isIPv4Address(form.lan_ipv4_gateway || '') || !form.lan_ipv4_prefix_len) {
|
||||||
|
dialog.alert('局域网 IPv4 配置有误', '请填写有效的 IPv4 地址、子网掩码和网关')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const authError = validateSSHAuthInputs(form)
|
const authError = validateSSHAuthInputs(form)
|
||||||
if (authError) {
|
if (authError) {
|
||||||
dialog.alert('登录方式有误', authError)
|
dialog.alert('登录方式有误', authError)
|
||||||
@@ -190,7 +227,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
await onSuccess(containers)
|
await onSuccess(containers)
|
||||||
onClose()
|
onClose()
|
||||||
setBatchCount(1)
|
setBatchCount(1)
|
||||||
setForm({ ...defaultForm, template_id: templates[0]?.id || '' })
|
setForm({ ...defaultForm, template_id: templates[0]?.id || '', allowed_image_ids: templates[0]?.id ? [templates[0].id] : [], image_limit_configured: true })
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { message?: string } } }
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
|
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
|
||||||
@@ -234,15 +271,21 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '', allowed_image_ids: [], image_limit_configured: false }))}
|
||||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
>
|
>
|
||||||
LXC 容器
|
LXC 容器
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
disabled={!kvmAvailable}
|
||||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||||
|
onClick={() => {
|
||||||
|
if (kvmAvailable) {
|
||||||
|
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '', allowed_image_ids: [], image_limit_configured: false }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
>
|
>
|
||||||
KVM 虚拟机
|
KVM 虚拟机
|
||||||
</button>
|
</button>
|
||||||
@@ -257,7 +300,12 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={form.template_id}
|
value={form.template_id}
|
||||||
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
onChange={(event) => {
|
||||||
|
const templateID = event.target.value
|
||||||
|
const allowed = new Set(form.allowed_image_ids || [])
|
||||||
|
if (templateID) allowed.add(templateID)
|
||||||
|
setForm(applyTemplateDefaults({ ...form, template_id: templateID, allowed_image_ids: Array.from(allowed), image_limit_configured: true }))
|
||||||
|
}}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
@@ -270,6 +318,38 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<Field label="子用户可用镜像">
|
||||||
|
<div className="rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div className="mb-2 text-xs text-gray-500">默认勾选当前系统;取消后,子用户也不能重装该系统。</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{templates.map((template) => {
|
||||||
|
const checked = (form.allowed_image_ids || []).includes(template.id)
|
||||||
|
const current = template.id === form.template_id
|
||||||
|
return (
|
||||||
|
<label key={template.id} className={`flex cursor-pointer items-start gap-2 rounded border px-2.5 py-2 text-xs ${checked ? 'border-black bg-white' : 'border-gray-200 bg-white hover:bg-gray-50'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
const currentIDs = form.allowed_image_ids || []
|
||||||
|
const next = checked ? currentIDs.filter((id) => id !== template.id) : [...currentIDs, template.id]
|
||||||
|
setForm({ ...form, allowed_image_ids: next, image_limit_configured: true })
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-medium text-gray-800">{template.name}{current ? '(当前系统)' : ''}</span>
|
||||||
|
<span className="block text-gray-500">{template.arch} · {template.distro} {template.release}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
{linuxTemplate && (
|
{linuxTemplate && (
|
||||||
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
|
||||||
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
<div className="mb-2 font-medium text-gray-800">登录方式</div>
|
||||||
@@ -329,7 +409,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
...form,
|
...form,
|
||||||
assign_ipv4: event.target.checked,
|
assign_ipv4: event.target.checked,
|
||||||
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
|
public_ipv4s: event.target.checked ? form.public_ipv4s : [],
|
||||||
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [] } : {}),
|
...(event.target.checked ? { assign_nat: false, port_mapping_count: 0, extra_ports: [], lan_ipv4_mode: '', lan_interface: '' } : {}),
|
||||||
})}
|
})}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
@@ -395,6 +475,98 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`rounded-md border px-3 py-2 text-sm ${form.virtualization === 'lxc' && lanInterfaces.length > 0 ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={lanIPv4Enabled}
|
||||||
|
disabled={form.virtualization !== 'lxc' || lanInterfaces.length === 0}
|
||||||
|
onChange={(event) => {
|
||||||
|
const checked = event.target.checked
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
lan_ipv4_mode: checked ? 'dhcp' : '',
|
||||||
|
lan_interface: checked ? (form.lan_interface || defaultLANInterface) : '',
|
||||||
|
assign_nat: checked ? false : form.assign_nat,
|
||||||
|
port_mapping_count: checked ? 0 : form.port_mapping_count,
|
||||||
|
extra_ports: checked ? [] : form.extra_ports,
|
||||||
|
assign_ipv4: checked ? false : form.assign_ipv4,
|
||||||
|
public_ipv4s: checked ? [] : form.public_ipv4s,
|
||||||
|
ipv4_count: checked ? 0 : form.ipv4_count,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-medium text-gray-800">局域网 DHCP</span>
|
||||||
|
<span className="block text-xs text-gray-500">
|
||||||
|
{lanInterfaces.length > 0 ? 'macvlan 独立局域网 IP' : '未检测到可用上联网卡'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{lanIPv4Enabled && (
|
||||||
|
<select
|
||||||
|
value={form.lan_interface || defaultLANInterface}
|
||||||
|
onChange={(event) => setForm({ ...form, lan_interface: event.target.value })}
|
||||||
|
className="h-9 w-32 shrink-0 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-black"
|
||||||
|
>
|
||||||
|
{lanInterfaces.map((item) => (
|
||||||
|
<option key={item.name} value={item.name}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{lanIPv4Enabled && (
|
||||||
|
<div className="mt-3 space-y-3 pl-6">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, lan_ipv4_mode: 'dhcp' })}
|
||||||
|
className={`rounded-md border px-3 py-2 text-xs font-medium ${form.lan_ipv4_mode === 'dhcp' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
DHCP 自动获取
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm({ ...form, lan_ipv4_mode: 'static' })}
|
||||||
|
className={`rounded-md border px-3 py-2 text-xs font-medium ${lanStaticEnabled ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
手动配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{lanStaticEnabled && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Field label="IPv4 地址">
|
||||||
|
<input
|
||||||
|
value={form.lan_ipv4_address || ''}
|
||||||
|
onChange={(event) => setForm({ ...form, lan_ipv4_address: event.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="192.168.2.250"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="子网掩码">
|
||||||
|
<input
|
||||||
|
value={subnetMaskFromPrefixLen(form.lan_ipv4_prefix_len || 24)}
|
||||||
|
onChange={(event) => setForm({ ...form, lan_ipv4_prefix_len: prefixLenFromSubnetMask(event.target.value) || 24 })}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="255.255.255.0"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="网关">
|
||||||
|
<input
|
||||||
|
value={form.lan_ipv4_gateway || ''}
|
||||||
|
onChange={(event) => setForm({ ...form, lan_ipv4_gateway: event.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="192.168.2.202"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||||
@@ -408,7 +580,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
||||||
<span className="block text-xs text-gray-500 truncate">
|
<span className="block text-xs text-gray-500 truncate">
|
||||||
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
|
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -438,7 +610,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
assign_nat: checked,
|
assign_nat: checked,
|
||||||
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
|
||||||
extra_ports: [],
|
extra_ports: [],
|
||||||
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0 } : {}),
|
...(checked ? { assign_ipv4: false, public_ipv4s: [], ipv4_count: 0, lan_ipv4_mode: '', lan_interface: '' } : {}),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
@@ -698,10 +870,13 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
|
|||||||
|
|
||||||
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||||
const normalized = applyTemplateDefaults(form)
|
const normalized = applyTemplateDefaults(form)
|
||||||
|
const wantsLANDHCP = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'dhcp'
|
||||||
|
const wantsLANStatic = normalized.virtualization === 'lxc' && normalized.lan_ipv4_mode === 'static'
|
||||||
|
const wantsLANIPv4 = wantsLANDHCP || wantsLANStatic
|
||||||
const wantsIPv4 = !!normalized.assign_ipv4
|
const wantsIPv4 = !!normalized.assign_ipv4
|
||||||
const wantsIPv6 = !!normalized.assign_ipv6
|
const wantsIPv6 = !!normalized.assign_ipv6
|
||||||
// IPv4 and NAT are mutually exclusive
|
// IPv4 and NAT are mutually exclusive
|
||||||
const wantsNAT = wantsIPv4 ? false : normalized.assign_nat !== false
|
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
|
||||||
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
|
||||||
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||||
return {
|
return {
|
||||||
@@ -711,6 +886,11 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
|||||||
disk_gb: Math.round(normalized.disk_gb),
|
disk_gb: Math.round(normalized.disk_gb),
|
||||||
assign_nat: wantsNAT,
|
assign_nat: wantsNAT,
|
||||||
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
|
||||||
|
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
|
||||||
|
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
|
||||||
|
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
|
||||||
|
lan_ipv4_prefix_len: wantsLANStatic ? clampInt(normalized.lan_ipv4_prefix_len || 24, 1, 32, 24) : 0,
|
||||||
|
lan_ipv4_gateway: wantsLANStatic ? (normalized.lan_ipv4_gateway || '').trim() : '',
|
||||||
assign_ipv4: wantsIPv4,
|
assign_ipv4: wantsIPv4,
|
||||||
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
|
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
|
||||||
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
|
||||||
@@ -733,6 +913,16 @@ function validateSSHAuthInputs(form: CreateContainerRequest) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLANDHCPInterfaces(report: HostProbeReport | null) {
|
||||||
|
const interfaces = report?.network_interfaces || []
|
||||||
|
return interfaces.filter((item) => {
|
||||||
|
const name = item.name || ''
|
||||||
|
if (!name || name === 'lo') return false
|
||||||
|
if (name.startsWith('lxc') || name.startsWith('docker') || name.startsWith('br-') || name.startsWith('veth') || name.startsWith('virbr') || name.startsWith('clmv-')) return false
|
||||||
|
return (item.state || '').toLowerCase() === 'up'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||||
if (!isWindowsTemplate(form.template_id)) return form
|
if (!isWindowsTemplate(form.template_id)) return form
|
||||||
return {
|
return {
|
||||||
@@ -758,11 +948,33 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
|
|||||||
return Math.min(Math.max(next, min), max ?? next)
|
return Math.min(Math.max(next, min), max ?? next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIPv4Address(value: string) {
|
||||||
|
const parts = value.trim().split('.')
|
||||||
|
return parts.length === 4 && parts.every((part) => {
|
||||||
|
if (!/^\d+$/.test(part)) return false
|
||||||
|
const n = Number(part)
|
||||||
|
return n >= 0 && n <= 255
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function subnetMaskFromPrefixLen(prefixLen: number) {
|
||||||
|
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
|
||||||
|
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||||
|
return [24, 16, 8, 0].map((shift) => (mask >>> shift) & 255).join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixLenFromSubnetMask(mask: string) {
|
||||||
|
if (!isIPv4Address(mask)) return 0
|
||||||
|
const bits = mask.split('.').map((part) => Number(part).toString(2).padStart(8, '0')).join('')
|
||||||
|
if (!/^1*0*$/.test(bits)) return 0
|
||||||
|
return bits.indexOf('0') === -1 ? 32 : bits.indexOf('0')
|
||||||
|
}
|
||||||
|
|
||||||
const createNetworkText = {
|
const createNetworkText = {
|
||||||
zh: {
|
zh: {
|
||||||
publicIPv4: '公网 IPv4',
|
publicIPv4: '公网 IPv4',
|
||||||
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
||||||
publicIPv6: '公网 IPv6',
|
publicIPv6: '可分配 IPv6 前缀',
|
||||||
use: '使用',
|
use: '使用',
|
||||||
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
||||||
publicNAT: '公网 NAT',
|
publicNAT: '公网 NAT',
|
||||||
@@ -771,7 +983,7 @@ const createNetworkText = {
|
|||||||
en: {
|
en: {
|
||||||
publicIPv4: 'Public IPv4',
|
publicIPv4: 'Public IPv4',
|
||||||
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
||||||
publicIPv6: 'Public IPv6',
|
publicIPv6: 'Allocatable IPv6 Prefix',
|
||||||
use: 'Use',
|
use: 'Use',
|
||||||
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
||||||
publicNAT: 'Public NAT',
|
publicNAT: 'Public NAT',
|
||||||
@@ -779,6 +991,21 @@ const createNetworkText = {
|
|||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
function formatIPv6StatusReason(reason: string | undefined, language: Language, fallback: string) {
|
||||||
|
if (!reason) return fallback
|
||||||
|
if (reason.includes('/128 single-address IPv6 is not assignable')) {
|
||||||
|
return language === 'en'
|
||||||
|
? 'No allocatable IPv6 prefix. The host only has a /128 single IPv6 address.'
|
||||||
|
: '未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。'
|
||||||
|
}
|
||||||
|
if (reason.includes('outbound IPv6 connectivity test failed')) {
|
||||||
|
return language === 'en'
|
||||||
|
? reason
|
||||||
|
: '宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。'
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
function formatAllocatableIPv4Count(count: number, language: Language) {
|
function formatAllocatableIPv4Count(count: number, language: Language) {
|
||||||
return language === 'en'
|
return language === 'en'
|
||||||
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode, useId } from 'react'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
|
||||||
@@ -9,17 +9,27 @@ export type ChartPoint = {
|
|||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResourceChartSeries = {
|
||||||
|
label: string
|
||||||
|
points: ChartPoint[]
|
||||||
|
current?: number
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ResourceChartConfig = {
|
export type ResourceChartConfig = {
|
||||||
title: string
|
title: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
points: ChartPoint[]
|
points: ChartPoint[]
|
||||||
current: number
|
current: number
|
||||||
|
series?: ResourceChartSeries[]
|
||||||
detail?: string
|
detail?: string
|
||||||
max?: number
|
max?: number
|
||||||
unitLabel?: string
|
unitLabel?: string
|
||||||
formatValue: (value: number) => string
|
formatValue: (value: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartPalette = ['#2563eb', '#16a34a', '#d97706', '#dc2626']
|
||||||
|
|
||||||
const rangeLabels: Record<StatsRangeKey, string> = {
|
const rangeLabels: Record<StatsRangeKey, string> = {
|
||||||
'30m': '30分钟',
|
'30m': '30分钟',
|
||||||
'1h': '1小时',
|
'1h': '1小时',
|
||||||
@@ -77,36 +87,52 @@ export default function ResourceStatsPanel({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2">
|
<div className="grid grid-cols-1 xl:grid-cols-2">
|
||||||
{charts.map((chart, index) => (
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) {
|
function DetailedChart({ chart, range, className }: { chart: ResourceChartConfig; range: StatsRangeKey; className: string }) {
|
||||||
const values = chart.points.map((point) => point.value)
|
const series = chart.series?.length
|
||||||
const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
|
? chart.series
|
||||||
const peak = values.length > 0 ? Math.max(...values) : 0
|
: [{ label: chart.title, points: chart.points, current: chart.current }]
|
||||||
|
const primaryStats = getSeriesStats(series[0], chart.current)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-4 ${className}`}>
|
<div className={`p-4 ${className}`}>
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between mb-2">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
|
<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 className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
|
||||||
<span>{chart.title}</span>
|
<span>{chart.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 text-right">
|
{series.length > 1 ? (
|
||||||
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-right sm:shrink-0">
|
||||||
<Stat label="平均" value={chart.formatValue(avg)} />
|
{series.map((item, index) => (
|
||||||
<Stat label="峰值" value={chart.formatValue(peak)} />
|
<SeriesStat
|
||||||
|
key={item.label}
|
||||||
|
color={item.color || chartPalette[index % chartPalette.length]}
|
||||||
|
label={item.label}
|
||||||
|
stats={getSeriesStats(item, item.current)}
|
||||||
|
formatValue={chart.formatValue}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<LineAreaChart
|
<LineAreaChart
|
||||||
points={chart.points}
|
series={series}
|
||||||
|
range={range}
|
||||||
max={chart.max}
|
max={chart.max}
|
||||||
formatValue={chart.formatValue}
|
formatValue={chart.formatValue}
|
||||||
unitLabel={chart.unitLabel}
|
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 }) {
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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({
|
function LineAreaChart({
|
||||||
points,
|
series,
|
||||||
|
range,
|
||||||
max,
|
max,
|
||||||
formatValue,
|
formatValue,
|
||||||
unitLabel,
|
unitLabel,
|
||||||
}: {
|
}: {
|
||||||
points: ChartPoint[]
|
series: ResourceChartSeries[]
|
||||||
|
range: StatsRangeKey
|
||||||
max?: number
|
max?: number
|
||||||
formatValue: (value: number) => string
|
formatValue: (value: number) => string
|
||||||
unitLabel?: string
|
unitLabel?: string
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const isDark = theme === 'dark'
|
const isDark = theme === 'dark'
|
||||||
|
const gradientId = `resource-chart-fill-${useId().replace(/:/g, '')}`
|
||||||
|
|
||||||
const width = 520
|
const width = 520
|
||||||
const height = 150
|
const height = 150
|
||||||
@@ -146,21 +213,21 @@ function LineAreaChart({
|
|||||||
const bottom = 28
|
const bottom = 28
|
||||||
const innerWidth = width - left - right
|
const innerWidth = width - left - right
|
||||||
const innerHeight = height - top - bottom
|
const innerHeight = height - top - bottom
|
||||||
const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }]
|
const now = Date.now()
|
||||||
const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1)
|
const chartSeries = series.map((item) => {
|
||||||
const minTs = values[0]?.ts || Date.now()
|
const validPoints = item.points.filter((point) => Number.isFinite(point.ts) && Number.isFinite(point.value))
|
||||||
const maxTs = values[values.length - 1]?.ts || minTs + 1
|
return {
|
||||||
const span = Math.max(maxTs - minTs, 1)
|
...item,
|
||||||
|
points: validPoints.length > 0
|
||||||
const coords = values.map((point, index) => {
|
? validPoints
|
||||||
const x = left + ((point.ts - minTs) / span) * innerWidth
|
: [{ ts: now, value: Number.isFinite(item.current) ? Number(item.current) : 0 }],
|
||||||
const y = top + innerHeight - (point.value / maxValue) * innerHeight
|
}
|
||||||
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
|
|
||||||
})
|
})
|
||||||
const fallbackX = left
|
const allPoints = chartSeries.flatMap((item) => item.points)
|
||||||
const fallbackY = top + innerHeight
|
const maxValue = Math.max(max || 0, ...allPoints.map((point) => point.value), 1)
|
||||||
const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}`
|
const maxTs = now
|
||||||
const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}`
|
const minTs = now - statsRanges[range]
|
||||||
|
const span = Math.max(maxTs - minTs, 1)
|
||||||
const yTicks = [1, 0.5, 0]
|
const yTicks = [1, 0.5, 0]
|
||||||
const xTicks = [0, 0.5, 1]
|
const xTicks = [0, 0.5, 1]
|
||||||
|
|
||||||
@@ -171,11 +238,13 @@ function LineAreaChart({
|
|||||||
const lineStroke = isDark ? '#f9fafb' : '#444'
|
const lineStroke = isDark ? '#f9fafb' : '#444'
|
||||||
const gradientTop = isDark ? '#f9fafb' : '#555'
|
const gradientTop = isDark ? '#f9fafb' : '#555'
|
||||||
const gradientBottom = isDark ? '#374151' : '#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 (
|
return (
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
||||||
<defs>
|
<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="0%" stopColor={gradientTop} stopOpacity="0.25" />
|
||||||
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
@@ -214,12 +283,45 @@ function LineAreaChart({
|
|||||||
|
|
||||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
|
<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} />
|
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
|
||||||
<polygon points={area} fill="url(#resource-chart-fill)" />
|
{chartSeries.length === 1 && <polygon points={area} fill={`url(#${gradientId})`} />}
|
||||||
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
{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>
|
</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) {
|
function chartBorderClass(index: number) {
|
||||||
const right = index % 2 === 0 ? 'xl:border-r' : ''
|
const right = index % 2 === 0 ? 'xl:border-r' : ''
|
||||||
const top = index > 1 ? 'border-t' : ''
|
const top = index > 1 ? 'border-t' : ''
|
||||||
|
|||||||
@@ -914,6 +914,7 @@ const responseSamples: Record<string, unknown> = {
|
|||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
nat4: { used: 62, remaining: '45474', total: '45536' },
|
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||||
|
nat4_port_range: { start: 20000, end: 65535 },
|
||||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
ipv4: { used: 1, remaining: '3', total: '4' },
|
||||||
ipv6: { used: 31, remaining: 'large', total: 'large' },
|
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' }],
|
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': {
|
'PUT /api/v1/routing': {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
nat4: { used: 62, remaining: '45474', total: '45536' },
|
||||||
|
nat4_port_range: { start: 20000, end: 65535 },
|
||||||
ipv4: { used: 1, remaining: '3', total: '4' },
|
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' }],
|
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' }],
|
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.')
|
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') {
|
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') {
|
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.')
|
notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
assignIPv6,
|
assignIPv6,
|
||||||
APIResponse,
|
APIResponse,
|
||||||
Container,
|
Container,
|
||||||
|
ContainerMetricPoint as ContainerMetricSample,
|
||||||
ContainerUsage,
|
ContainerUsage,
|
||||||
createSubUser,
|
createSubUser,
|
||||||
createContainerSnapshot,
|
createContainerSnapshot,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
deleteContainerSnapshot,
|
deleteContainerSnapshot,
|
||||||
deletePortMapping,
|
deletePortMapping,
|
||||||
getContainer,
|
getContainer,
|
||||||
|
getContainerHistory,
|
||||||
getContainerSnapshots,
|
getContainerSnapshots,
|
||||||
getContainerUsage,
|
getContainerUsage,
|
||||||
getHostInfo,
|
getHostInfo,
|
||||||
@@ -89,8 +91,12 @@ type MetricPoint = {
|
|||||||
ts: number
|
ts: number
|
||||||
cpu: number
|
cpu: number
|
||||||
memory: number
|
memory: number
|
||||||
network: number
|
network?: number
|
||||||
diskIO: number
|
networkRx?: number
|
||||||
|
networkTx?: number
|
||||||
|
diskIO?: number
|
||||||
|
diskRead?: number
|
||||||
|
diskWrite?: number
|
||||||
}
|
}
|
||||||
type MappingDraft = {
|
type MappingDraft = {
|
||||||
index: number | null
|
index: number | null
|
||||||
@@ -205,33 +211,19 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}, [containerIdentifier, container?.snapshot_limit])
|
}, [containerIdentifier, container?.snapshot_limit])
|
||||||
|
|
||||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
const fetchMetricHistory = useCallback(async () => {
|
||||||
if (!containerIdentifier || !currentContainer) return
|
if (!containerIdentifier) return
|
||||||
|
try {
|
||||||
const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
|
const res = await getContainerHistory(containerIdentifier)
|
||||||
? nextUsage.memory_total_bytes
|
const points = (res.data.data || []).map(normalizeContainerMetricSample)
|
||||||
: currentContainer.ram_mb * 1024 * 1024
|
if (points.length > 0) {
|
||||||
const memoryPct = memoryTotalBytes > 0
|
setHistory(points)
|
||||||
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
localStorage.setItem(historyKey(container?.uuid || containerIdentifier), JSON.stringify(points))
|
||||||
: 0
|
|
||||||
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
|
||||||
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
|
|
||||||
|
|
||||||
const point: MetricPoint = {
|
|
||||||
ts: Date.now(),
|
|
||||||
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
|
||||||
memory: clamp(memoryPct),
|
|
||||||
network: networkBps,
|
|
||||||
diskIO: diskIOBps,
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
setHistory((prev) => {
|
console.error('Failed to fetch metric history:', err)
|
||||||
const cutoff = Date.now() - statsRanges['1w']
|
}
|
||||||
const next = [...prev.filter((item) => item.ts >= cutoff), point]
|
}, [containerIdentifier, container?.uuid])
|
||||||
localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [containerIdentifier])
|
|
||||||
|
|
||||||
const fetchUsage = useCallback(async () => {
|
const fetchUsage = useCallback(async () => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
@@ -239,12 +231,11 @@ export default function ContainerDetail() {
|
|||||||
const res = await getContainerUsage(containerIdentifier)
|
const res = await getContainerUsage(containerIdentifier)
|
||||||
if (res.data.data) {
|
if (res.data.data) {
|
||||||
setUsage(res.data.data)
|
setUsage(res.data.data)
|
||||||
appendUsagePoint(res.data.data, container)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch usage:', err)
|
console.error('Failed to fetch usage:', err)
|
||||||
}
|
}
|
||||||
}, [containerIdentifier, container, appendUsagePoint])
|
}, [containerIdentifier])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
@@ -286,6 +277,12 @@ export default function ContainerDetail() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [fetchUsage])
|
}, [fetchUsage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMetricHistory()
|
||||||
|
const timer = window.setInterval(fetchMetricHistory, 30000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [fetchMetricHistory])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSnapshots) fetchSnapshots()
|
if (showSnapshots) fetchSnapshots()
|
||||||
}, [showSnapshots, fetchSnapshots])
|
}, [showSnapshots, fetchSnapshots])
|
||||||
@@ -513,10 +510,12 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const openReinstall = async () => {
|
const openReinstall = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
const res = await getEnabledImages(container?.virtualization || 'lxc', containerIdentifier)
|
||||||
if (res.data.data) {
|
if (res.data.data) {
|
||||||
setTemplates(res.data.data)
|
const data = res.data.data
|
||||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
setTemplates(data)
|
||||||
|
const currentTemplate = container?.template || ''
|
||||||
|
setSelectedTemplate(data.some((template) => template.id === currentTemplate) ? currentTemplate : (data[0]?.id || ''))
|
||||||
}
|
}
|
||||||
setReinstallAuthMode('keep')
|
setReinstallAuthMode('keep')
|
||||||
setReinstallPasswordDraft('')
|
setReinstallPasswordDraft('')
|
||||||
@@ -907,20 +906,23 @@ export default function ContainerDetail() {
|
|||||||
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100) : 0
|
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 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 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 networkRxBps = usage?.network_rx_bps || 0
|
||||||
const rx = 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 networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps)
|
||||||
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
|
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
|
||||||
const netPct = Math.max(
|
const netPct = Math.max(
|
||||||
directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000),
|
directionUsagePercent(networkRxBps, networkDownLimit, 125000, 125000000),
|
||||||
directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 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 ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps)
|
||||||
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
|
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
|
||||||
const diskIOPct = Math.max(
|
const diskIOPct = Math.max(
|
||||||
directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
directionUsagePercent(diskReadBps, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||||
directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
directionUsagePercent(diskWriteBps, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
|
||||||
)
|
)
|
||||||
const mappingCount = container.port_mappings?.length || 0
|
const mappingCount = container.port_mappings?.length || 0
|
||||||
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
|
||||||
@@ -967,16 +969,24 @@ export default function ContainerDetail() {
|
|||||||
icon: <Network className="w-5 h-5" />,
|
icon: <Network className="w-5 h-5" />,
|
||||||
current: networkBps,
|
current: networkBps,
|
||||||
points: toChartPoints(filtered, 'network'),
|
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,
|
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',
|
title: '磁盘IO',
|
||||||
icon: <HardDrive className="w-5 h-5" />,
|
icon: <HardDrive className="w-5 h-5" />,
|
||||||
current: diskIOBps,
|
current: diskIOBps,
|
||||||
points: toChartPoints(filtered, 'diskIO'),
|
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,
|
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)}%`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2469,6 +2479,20 @@ function readHistory(containerName: string): MetricPoint[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoint {
|
||||||
|
return {
|
||||||
|
ts: point.ts,
|
||||||
|
cpu: clamp(point.cpu),
|
||||||
|
memory: clamp(point.memory),
|
||||||
|
network: point.network || 0,
|
||||||
|
networkRx: point.network_rx || 0,
|
||||||
|
networkTx: point.network_tx || 0,
|
||||||
|
diskIO: point.disk_io || 0,
|
||||||
|
diskRead: point.disk_read || 0,
|
||||||
|
diskWrite: point.disk_write || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function historyKey(containerName: string) {
|
function historyKey(containerName: string) {
|
||||||
return `clicd_container_metric_history:${containerName}`
|
return `clicd_container_metric_history:${containerName}`
|
||||||
}
|
}
|
||||||
@@ -2514,8 +2538,11 @@ function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLa
|
|||||||
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
|
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function toChartPoints<T extends keyof Omit<MetricPoint, 'ts'>>(history: MetricPoint[], key: T): ChartPoint[] {
|
function toChartPoints(history: MetricPoint[], key: keyof Omit<MetricPoint, 'ts'>): ChartPoint[] {
|
||||||
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
|
return history.flatMap((point) => {
|
||||||
|
const value = Number(point[key])
|
||||||
|
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(value: number): string {
|
function formatPercent(value: number): string {
|
||||||
|
|||||||
@@ -395,10 +395,8 @@ export default function Containers() {
|
|||||||
const isPlaceholder = !!container.isPlaceholder
|
const isPlaceholder = !!container.isPlaceholder
|
||||||
const isPolicyBlocked = !!container.policy_blocked
|
const isPolicyBlocked = !!container.policy_blocked
|
||||||
const usage = usageByName[container.name]
|
const usage = usageByName[container.name]
|
||||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
|
||||||
|
|
||||||
const cpuPct = isRunning
|
const cpuPct = isRunning
|
||||||
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
|
? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))
|
||||||
: 0
|
: 0
|
||||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||||
? usage.memory_total_bytes
|
? usage.memory_total_bytes
|
||||||
@@ -969,6 +967,7 @@ function getTemplateName(id: string) {
|
|||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'ubuntu-noble': 'Ubuntu 24.04',
|
'ubuntu-noble': 'Ubuntu 24.04',
|
||||||
'ubuntu-jammy': 'Ubuntu 22.04',
|
'ubuntu-jammy': 'Ubuntu 22.04',
|
||||||
|
'debian-trixie': 'Debian 13',
|
||||||
'debian-bookworm': 'Debian 12',
|
'debian-bookworm': 'Debian 12',
|
||||||
'debian-bullseye': 'Debian 11',
|
'debian-bullseye': 'Debian 11',
|
||||||
'alpine-3.21': 'Alpine 3.21',
|
'alpine-3.21': 'Alpine 3.21',
|
||||||
@@ -978,6 +977,8 @@ function getTemplateName(id: string) {
|
|||||||
'rockylinux-10': 'Rocky 10',
|
'rockylinux-10': 'Rocky 10',
|
||||||
'kvm-ubuntu-noble': 'Ubuntu 24.04',
|
'kvm-ubuntu-noble': 'Ubuntu 24.04',
|
||||||
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
|
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
|
||||||
|
'kvm-debian-trixie': 'Debian 13',
|
||||||
|
'kvm-debian-trixie-xfce': 'Debian 13 XFCE',
|
||||||
'kvm-debian-bookworm': 'Debian 12',
|
'kvm-debian-bookworm': 'Debian 12',
|
||||||
'kvm-debian-bullseye': 'Debian 11',
|
'kvm-debian-bullseye': 'Debian 11',
|
||||||
'kvm-rockylinux-9': 'Rocky 9',
|
'kvm-rockylinux-9': 'Rocky 9',
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ import ResourceStatsPanel, {
|
|||||||
StatsRangeKey,
|
StatsRangeKey,
|
||||||
statsRanges,
|
statsRanges,
|
||||||
} from '../components/ResourceStatsPanel'
|
} from '../components/ResourceStatsPanel'
|
||||||
import { DashboardStats, getDashboard, getHostInfo, HostInfo } from '../services/api'
|
import { DashboardStats, getDashboard, getHostHistory, getHostInfo, HostInfo, HostMetricPoint as HostMetricSample } from '../services/api'
|
||||||
|
|
||||||
type HostMetricPoint = {
|
type HostMetricPoint = {
|
||||||
ts: number
|
ts: number
|
||||||
cpu: number
|
cpu: number
|
||||||
memory: number
|
memory: number
|
||||||
network: number
|
network?: number
|
||||||
diskIO: number
|
networkRx?: number
|
||||||
|
networkTx?: number
|
||||||
|
diskIO?: number
|
||||||
|
diskRead?: number
|
||||||
|
diskWrite?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostHistoryKey = 'clicd_host_metric_history_v2'
|
const hostHistoryKey = 'clicd_host_metric_history_v2'
|
||||||
@@ -26,6 +30,19 @@ export default function Dashboard() {
|
|||||||
const [range, setRange] = useState<StatsRangeKey>('30m')
|
const [range, setRange] = useState<StatsRangeKey>('30m')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getHostHistory()
|
||||||
|
const points = (res.data.data || []).map(normalizeHostMetricSample)
|
||||||
|
if (points.length > 0) {
|
||||||
|
setHistory(points)
|
||||||
|
localStorage.setItem(hostHistoryKey, JSON.stringify(points))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
|
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
|
||||||
@@ -33,7 +50,6 @@ export default function Dashboard() {
|
|||||||
if (hostRes.data.data) {
|
if (hostRes.data.data) {
|
||||||
const nextHost = hostRes.data.data
|
const nextHost = hostRes.data.data
|
||||||
setHost(nextHost)
|
setHost(nextHost)
|
||||||
appendHostPoint(nextHost, setHistory)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
@@ -43,10 +59,15 @@ export default function Dashboard() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchHistory()
|
||||||
fetchData()
|
fetchData()
|
||||||
const interval = window.setInterval(fetchData, 5000)
|
const interval = window.setInterval(fetchData, 5000)
|
||||||
return () => window.clearInterval(interval)
|
const historyInterval = window.setInterval(fetchHistory, 30000)
|
||||||
}, [fetchData])
|
return () => {
|
||||||
|
window.clearInterval(interval)
|
||||||
|
window.clearInterval(historyInterval)
|
||||||
|
}
|
||||||
|
}, [fetchData, fetchHistory])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -58,6 +79,10 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const filtered = filterHistory(history, range)
|
const filtered = filterHistory(history, range)
|
||||||
const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0
|
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 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)
|
const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0)
|
||||||
|
|
||||||
@@ -85,16 +110,24 @@ export default function Dashboard() {
|
|||||||
icon: <Network className="w-5 h-5" />,
|
icon: <Network className="w-5 h-5" />,
|
||||||
current: networkBps,
|
current: networkBps,
|
||||||
points: toChartPoints(filtered, 'network'),
|
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,
|
formatValue: formatRate,
|
||||||
detail: `入 ${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`,
|
detail: `入 ${formatRate(networkRxBps)} / 出 ${formatRate(networkTxBps)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '磁盘IO',
|
title: '磁盘IO',
|
||||||
icon: <HardDrive className="w-5 h-5" />,
|
icon: <HardDrive className="w-5 h-5" />,
|
||||||
current: diskIOBps,
|
current: diskIOBps,
|
||||||
points: toChartPoints(filtered, 'diskIO'),
|
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,
|
formatValue: formatRate,
|
||||||
detail: `读 ${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`,
|
detail: `读 ${formatRate(diskReadBps)} / 写 ${formatRate(diskWriteBps)}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -156,23 +189,6 @@ function SummaryCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
|
|
||||||
const point: HostMetricPoint = {
|
|
||||||
ts: Date.now(),
|
|
||||||
cpu: clamp(host.cpu.usage_pct),
|
|
||||||
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
|
|
||||||
network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0),
|
|
||||||
diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
setHistory((prev) => {
|
|
||||||
const cutoff = Date.now() - statsRanges['1w']
|
|
||||||
const next = [...prev.filter((item) => item.ts >= cutoff), point]
|
|
||||||
localStorage.setItem(hostHistoryKey, JSON.stringify(next))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function readHostHistory(): HostMetricPoint[] {
|
function readHostHistory(): HostMetricPoint[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(hostHistoryKey)
|
const raw = localStorage.getItem(hostHistoryKey)
|
||||||
@@ -190,8 +206,25 @@ function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) {
|
|||||||
return history.filter((point) => point.ts >= cutoff)
|
return history.filter((point) => point.ts >= cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toChartPoints<T extends keyof Omit<HostMetricPoint, 'ts'>>(history: HostMetricPoint[], key: T): ChartPoint[] {
|
function toChartPoints(history: HostMetricPoint[], key: keyof Omit<HostMetricPoint, 'ts'>): ChartPoint[] {
|
||||||
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
|
return history.flatMap((point) => {
|
||||||
|
const value = Number(point[key])
|
||||||
|
return Number.isFinite(value) ? [{ ts: point.ts, value }] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHostMetricSample(point: HostMetricSample): HostMetricPoint {
|
||||||
|
return {
|
||||||
|
ts: point.ts,
|
||||||
|
cpu: clamp(point.cpu),
|
||||||
|
memory: clamp(point.memory),
|
||||||
|
network: point.network || 0,
|
||||||
|
networkRx: point.network_rx || 0,
|
||||||
|
networkTx: point.network_tx || 0,
|
||||||
|
diskIO: point.disk_io || 0,
|
||||||
|
diskRead: point.disk_read || 0,
|
||||||
|
diskWrite: point.disk_write || 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number) {
|
function clamp(value: number) {
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ const hostReportText = {
|
|||||||
ipv4Address: 'IPv4 地址',
|
ipv4Address: 'IPv4 地址',
|
||||||
ipv4Prefix: 'IPv4 段',
|
ipv4Prefix: 'IPv4 段',
|
||||||
ipv6Address: 'IPv6 地址',
|
ipv6Address: 'IPv6 地址',
|
||||||
ipv6Prefix: 'IPv6 段',
|
ipv6Prefix: '可分配 IPv6 前缀',
|
||||||
gateway: '网关',
|
gateway: '网关',
|
||||||
memoryModules: '内存条',
|
memoryModules: '内存条',
|
||||||
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
|
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
|
||||||
@@ -277,7 +277,7 @@ const hostReportText = {
|
|||||||
ipv4Address: 'IPv4 Addresses',
|
ipv4Address: 'IPv4 Addresses',
|
||||||
ipv4Prefix: 'IPv4 Prefixes',
|
ipv4Prefix: 'IPv4 Prefixes',
|
||||||
ipv6Address: 'IPv6 Addresses',
|
ipv6Address: 'IPv6 Addresses',
|
||||||
ipv6Prefix: 'IPv6 Prefixes',
|
ipv6Prefix: 'Allocatable IPv6 Prefixes',
|
||||||
gateway: 'Gateway',
|
gateway: 'Gateway',
|
||||||
memoryModules: 'Memory Modules',
|
memoryModules: 'Memory Modules',
|
||||||
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
|
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
|
||||||
@@ -511,6 +511,7 @@ function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boole
|
|||||||
}
|
}
|
||||||
|
|
||||||
function gpuTypeLabel(value: string, language: Language) {
|
function gpuTypeLabel(value: string, language: Language) {
|
||||||
|
if (value === 'virtual') return language === 'en' ? 'Virtual' : '虚拟'
|
||||||
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
|
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
|
||||||
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
|
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
|
||||||
return value || '-'
|
return value || '-'
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export default function ImageManagement() {
|
|||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{kvmImages.length > 0 && (
|
||||||
<ImageTable
|
<ImageTable
|
||||||
title="KVM 虚拟机镜像"
|
title="KVM 虚拟机镜像"
|
||||||
images={kvmImages}
|
images={kvmImages}
|
||||||
@@ -159,6 +160,7 @@ export default function ImageManagement() {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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.24</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
import { useLanguage, type Language } from '../contexts/LanguageContext'
|
||||||
import {
|
import {
|
||||||
getRoutingInfo,
|
getRoutingInfo,
|
||||||
|
updateRoutingIPv6Prefixes,
|
||||||
updateRoutingIPv4Pool,
|
updateRoutingIPv4Pool,
|
||||||
|
updateRoutingPools,
|
||||||
type IPv4Route,
|
type IPv4Route,
|
||||||
type IPv6Route,
|
type IPv6Route,
|
||||||
|
type LANDHCPRoute,
|
||||||
|
type IPv6PrefixInfo,
|
||||||
|
type NAT4PortRange,
|
||||||
type NAT4Route,
|
type NAT4Route,
|
||||||
type PublicIPv4Info,
|
type PublicIPv4Info,
|
||||||
type RoutingInfo,
|
type RoutingInfo,
|
||||||
@@ -25,6 +30,12 @@ export default function Routing() {
|
|||||||
const [savingIPv4, setSavingIPv4] = useState(false)
|
const [savingIPv4, setSavingIPv4] = useState(false)
|
||||||
const [ipv4Draft, setIPv4Draft] = useState<(PublicIPv4Info & { _id: number })[]>([])
|
const [ipv4Draft, setIPv4Draft] = useState<(PublicIPv4Info & { _id: number })[]>([])
|
||||||
const nextDraftId = useRef(0)
|
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 [nat4Page, setNat4Page] = useState(1)
|
||||||
const [ipv6Page, setIPv6Page] = useState(1)
|
const [ipv6Page, setIPv6Page] = useState(1)
|
||||||
const [nat4Search, setNat4Search] = useState('')
|
const [nat4Search, setNat4Search] = useState('')
|
||||||
@@ -46,12 +57,16 @@ export default function Routing() {
|
|||||||
|
|
||||||
const publicIPv4s = routing?.public_ipv4_addresses || []
|
const publicIPv4s = routing?.public_ipv4_addresses || []
|
||||||
const ipv4Assignments = routing?.ipv4_assignments || []
|
const ipv4Assignments = routing?.ipv4_assignments || []
|
||||||
|
const lanDHCPAssignments = routing?.lan_dhcp_assignments || []
|
||||||
const nat4Mappings = routing?.nat4_mappings || []
|
const nat4Mappings = routing?.nat4_mappings || []
|
||||||
const ipv6Prefixes = routing?.ipv6_prefixes || []
|
const ipv6Prefixes = routing?.ipv6_prefixes || []
|
||||||
const ipv6Assignments = routing?.ipv6_assignments || []
|
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 defaultIPv4Interface = routing?.host_public_ipv4?.interface || publicIPv4s[0]?.interface || 'eth0'
|
||||||
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
|
const defaultIPv4Gateway = routing?.host_public_ipv4?.gateway || publicIPv4s[0]?.gateway || ''
|
||||||
const defaultIPv4PrefixLen = routing?.host_public_ipv4?.prefix_len || publicIPv4s[0]?.prefix_len || 32
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editingIPv4) {
|
if (!editingIPv4) {
|
||||||
@@ -139,6 +154,81 @@ export default function Routing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startEditNAT4 = () => {
|
||||||
|
setNAT4Draft({ start: nat4Range.start || 20000, end: nat4Range.end || 65535 })
|
||||||
|
setEditingNAT4(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNAT4Range = async () => {
|
||||||
|
const start = Math.round(Number(nat4Draft.start || 0))
|
||||||
|
const end = Math.round(Number(nat4Draft.end || 0))
|
||||||
|
if (start < 1 || start > 65535 || end < 1 || end > 65535 || start > end) {
|
||||||
|
alert(text.nat4RangeInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSavingNAT4(true)
|
||||||
|
try {
|
||||||
|
const res = await updateRoutingPools({ nat4_port_range: { start, end } })
|
||||||
|
setRouting(res.data.data || null)
|
||||||
|
setEditingNAT4(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.response?.data?.message || text.saveNAT4RangeFailed)
|
||||||
|
} finally {
|
||||||
|
setSavingNAT4(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEditIPv6 = () => {
|
||||||
|
setIPv6Draft(ipv6Prefixes.map((prefix) => ({ ...prefix, _id: nextDraftId.current++ })))
|
||||||
|
setEditingIPv6(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIPv6Row = () => {
|
||||||
|
setIPv6Draft((items) => [
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
_id: nextDraftId.current++,
|
||||||
|
prefix: '',
|
||||||
|
address: '',
|
||||||
|
prefix_len: 64,
|
||||||
|
interface: defaultIPv6Interface,
|
||||||
|
gateway: defaultIPv6Gateway,
|
||||||
|
source: 'manual',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateIPv6Draft = (index: number, patch: Partial<IPv6PrefixInfo>) => {
|
||||||
|
setIPv6Draft((items) => items.map((item, i) => (i === index ? { ...item, ...patch } : item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveIPv6Prefixes = async () => {
|
||||||
|
setSavingIPv6(true)
|
||||||
|
try {
|
||||||
|
const items = ipv6Draft
|
||||||
|
.map(({ _id, ...item }) => ({
|
||||||
|
...item,
|
||||||
|
prefix: (item.prefix || '').trim(),
|
||||||
|
address: (item.address || '').trim(),
|
||||||
|
interface: (item.interface || defaultIPv6Interface).trim(),
|
||||||
|
gateway: (item.gateway || '').trim(),
|
||||||
|
prefix_len: Number(item.prefix_len || 0),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.prefix || item.address)
|
||||||
|
if (items.some((item) => !item.interface)) {
|
||||||
|
alert(text.ipv6InterfaceRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await updateRoutingIPv6Prefixes(items)
|
||||||
|
setRouting(res.data.data || null)
|
||||||
|
setEditingIPv6(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.response?.data?.message || text.saveIPv6PrefixesFailed)
|
||||||
|
} finally {
|
||||||
|
setSavingIPv6(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filteredNat4 = useMemo(() => {
|
const filteredNat4 = useMemo(() => {
|
||||||
const q = nat4Search.toLowerCase().trim()
|
const q = nat4Search.toLowerCase().trim()
|
||||||
if (!q) return nat4Mappings
|
if (!q) return nat4Mappings
|
||||||
@@ -188,12 +278,47 @@ export default function Routing() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<CapacityCard title={text.nat4Ports} watermark="NAT4" remaining={routing?.nat4.remaining || '0'} total={routing?.nat4.total || '0'} used={routing?.nat4.used || 0} label={text.remainingTotal} usedLabel={text.used} />
|
<CapacityCard
|
||||||
|
title={text.nat4Ports}
|
||||||
|
watermark="NAT4"
|
||||||
|
remaining={routing?.nat4.remaining || '0'}
|
||||||
|
total={routing?.nat4.total || '0'}
|
||||||
|
used={routing?.nat4.used || 0}
|
||||||
|
label={text.remainingTotal}
|
||||||
|
usedLabel={text.used}
|
||||||
|
detail={formatNATRange(nat4Range, language)}
|
||||||
|
action={
|
||||||
|
<button onClick={startEditNAT4} className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-black" title={text.editNAT4Range}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<CapacityCard title={text.publicIPv4} watermark="IPv4" remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
|
<CapacityCard title={text.publicIPv4} watermark="IPv4" remaining={routing?.ipv4.remaining || '0'} total={routing?.ipv4.total || '0'} used={routing?.ipv4.used || 0} label={formatPoolCount(publicIPv4s.length, language)} usedLabel={text.used} />
|
||||||
|
<CapacityCard title={text.lanDHCP} watermark="LAN" remaining={String(routing?.lan_dhcp.used || 0)} total={routing?.lan_dhcp.total || 'DHCP'} used={routing?.lan_dhcp.used || 0} label={text.dhcpManagedByLAN} usedLabel={text.used} />
|
||||||
<CapacityCard title="IPv6" watermark="IPv6" remaining={formatCapacity(routing?.ipv6.remaining || '0', language)} total={formatCapacity(routing?.ipv6.total || '0', language)} used={routing?.ipv6.used || 0} label={formatDetectedPrefixCount(ipv6Prefixes.length, language)} usedLabel={text.used} />
|
<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>
|
</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
|
<Panel
|
||||||
title={text.publicIPv4Pool}
|
title={text.publicIPv4Pool}
|
||||||
subtitle={formatIPv4PoolSubtitle(publicIPv4s.length, ipv4Assignments.length, language)}
|
subtitle={formatIPv4PoolSubtitle(publicIPv4s.length, ipv4Assignments.length, language)}
|
||||||
@@ -328,8 +453,19 @@ export default function Routing() {
|
|||||||
</RouteModal>
|
</RouteModal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ipv6Prefixes.length > 0 && (
|
<Panel
|
||||||
<Panel title={text.detectedIPv6Prefixes} subtitle={formatPrefixCount(ipv6Prefixes.length, language)}>
|
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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[760px] text-sm">
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
@@ -354,8 +490,101 @@ export default function Routing() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
|
||||||
)}
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{editingIPv6 && (
|
||||||
|
<RouteModal title={text.editIPv6Prefixes} onClose={() => setEditingIPv6(false)} wide>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[860px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.prefix}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.hostAddress}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.interface}</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">{text.gateway}</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">{text.action}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{ipv6Draft.map((item, index) => (
|
||||||
|
<tr key={item._id}>
|
||||||
|
<td className="px-3 py-2"><input value={item.prefix || ''} onChange={(e) => updateIPv6Draft(index, { prefix: e.target.value })} placeholder="2001:db8:100::/64" className={smallInputClass} /></td>
|
||||||
|
<td className="px-3 py-2"><input value={item.address || ''} onChange={(e) => updateIPv6Draft(index, { address: e.target.value })} placeholder="2001:db8:100::1" className={smallInputClass} /></td>
|
||||||
|
<td className="px-3 py-2"><input value={item.interface || ''} onChange={(e) => updateIPv6Draft(index, { interface: e.target.value })} placeholder={defaultIPv6Interface} className={smallInputClass} /></td>
|
||||||
|
<td className="px-3 py-2"><input value={item.gateway || ''} onChange={(e) => updateIPv6Draft(index, { gateway: e.target.value })} placeholder={text.gateway} className={smallInputClass} /></td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={() => setIPv6Draft((items) => items.filter((_, i) => i !== index))} className="inline-flex items-center justify-center rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{ipv6Draft.length === 0 && <EmptyRow colSpan={5} text={text.noIPv6Prefixes} />}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<button onClick={addIPv6Row} className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-50">
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{text.addIPv6Prefix}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setEditingIPv6(false)} disabled={savingIPv6} className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
{text.cancel}
|
||||||
|
</button>
|
||||||
|
<button onClick={saveIPv6Prefixes} disabled={savingIPv6} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50">
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{savingIPv6 ? text.saving : text.save}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouteModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Panel title={text.lanDHCPAssignments} subtitle={formatAddressSubtitle(lanDHCPAssignments.length, lanDHCPAssignments.length, language)}>
|
||||||
|
{lanDHCPAssignments.length === 0 ? (
|
||||||
|
<EmptyState text={text.noLANDHCPAssignments} icon={<Network className="h-7 w-7" />} />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[980px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.container}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.runtimeName}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.guestIPv4}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">模式</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.gateway}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">MAC</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.interface}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">{text.status}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{lanDHCPAssignments.map((item: LANDHCPRoute) => (
|
||||||
|
<tr key={`${item.container_id}-${item.interface}-${item.mac_address || item.address}`} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button onClick={() => navigate(`/container/${item.container_id}`)} className="inline-flex items-center gap-2 text-left font-medium text-black hover:underline">
|
||||||
|
<Server className="h-4 w-4 text-gray-400" />
|
||||||
|
{item.container_name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.lxc_name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-700">{item.address ? `${item.address}${item.prefix_len ? `/${item.prefix_len}` : ''}` : '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-600">{item.mode === 'static' ? '手动' : 'DHCP'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.gateway || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.mac_address || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{item.interface || '-'}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={item.status} language={language} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
|
<Panel title={text.ipv4NAT} subtitle={formatMappingSubtitle(filteredNat4.length, nat4Mappings.length, language)} action={<SearchBox value={nat4Search} onChange={setNat4Search} placeholder={text.searchNAT} />}>
|
||||||
{nat4Mappings.length === 0 ? (
|
{nat4Mappings.length === 0 ? (
|
||||||
@@ -527,7 +756,7 @@ function Pagination({ page, totalPages, totalItems, pageSize, onPageChange, lang
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel }: {
|
function CapacityCard({ title, watermark, remaining, total, used, label, usedLabel, detail, action }: {
|
||||||
title: string
|
title: string
|
||||||
watermark: string
|
watermark: string
|
||||||
remaining: string
|
remaining: string
|
||||||
@@ -535,6 +764,8 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
|
|||||||
used: number
|
used: number
|
||||||
label: string
|
label: string
|
||||||
usedLabel: string
|
usedLabel: string
|
||||||
|
detail?: string
|
||||||
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-lg border border-gray-200 bg-white p-4">
|
<div className="relative overflow-hidden rounded-lg border border-gray-200 bg-white p-4">
|
||||||
@@ -542,6 +773,7 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
|
|||||||
{watermark}
|
{watermark}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
<div className="text-sm font-medium text-gray-700">{title}</div>
|
||||||
<div className="mt-2 flex items-end gap-2">
|
<div className="mt-2 flex items-end gap-2">
|
||||||
@@ -549,13 +781,38 @@ function CapacityCard({ title, watermark, remaining, total, used, label, usedLab
|
|||||||
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
<span className="pb-1 text-sm text-gray-400">/ {total}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 mt-3 text-xs text-gray-500">{label}</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>
|
<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>
|
</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 }) {
|
function EmptyState({ icon, text }: { icon: ReactNode; text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
@@ -632,8 +889,17 @@ const routingText = {
|
|||||||
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
|
pageSubtitle: 'NAT4、公网 IPv4 池和 IPv6 地址分配',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
nat4Ports: 'NAT4 端口',
|
nat4Ports: 'NAT4 端口',
|
||||||
|
editNAT4Range: '编辑 NAT4 范围',
|
||||||
|
rangeStart: '起始端口',
|
||||||
|
rangeEnd: '结束端口',
|
||||||
|
nat4RangeInvalid: 'NAT4 范围必须是 1-65535,且起始端口不能大于结束端口',
|
||||||
|
saveNAT4RangeFailed: '保存 NAT4 范围失败',
|
||||||
remainingTotal: '剩余 / 总数',
|
remainingTotal: '剩余 / 总数',
|
||||||
publicIPv4: '公网 IPv4',
|
publicIPv4: '公网 IPv4',
|
||||||
|
lanDHCP: '局域网 DHCP',
|
||||||
|
dhcpManagedByLAN: '由局域网 DHCP 分配',
|
||||||
|
lanDHCPAssignments: '局域网 DHCP 分配',
|
||||||
|
noLANDHCPAssignments: '暂无局域网 DHCP 分配',
|
||||||
publicIPv4Pool: '公网 IPv4 池',
|
publicIPv4Pool: '公网 IPv4 池',
|
||||||
editPool: '编辑 IP 池',
|
editPool: '编辑 IP 池',
|
||||||
noPublicIPv4Pool: '暂未配置公网 IPv4 池',
|
noPublicIPv4Pool: '暂未配置公网 IPv4 池',
|
||||||
@@ -661,10 +927,17 @@ const routingText = {
|
|||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
detectedIPv6Prefixes: '检测到的 IPv6 前缀',
|
detectedIPv6Prefixes: '检测到的 IPv6 前缀',
|
||||||
|
editPrefixes: '编辑前缀',
|
||||||
|
editIPv6Prefixes: '编辑 IPv6 前缀',
|
||||||
|
addIPv6Prefix: '添加 IPv6 前缀',
|
||||||
|
noIPv6Prefixes: '暂无 IPv6 前缀',
|
||||||
|
ipv6InterfaceRequired: 'IPv6 网卡不能为空',
|
||||||
|
saveIPv6PrefixesFailed: '保存 IPv6 前缀失败',
|
||||||
prefix: '前缀',
|
prefix: '前缀',
|
||||||
hostAddress: '宿主地址',
|
hostAddress: '宿主地址',
|
||||||
source: '来源',
|
source: '来源',
|
||||||
local: '本机',
|
local: '本机',
|
||||||
|
manual: '手动',
|
||||||
ipv4NAT: 'IPv4 NAT',
|
ipv4NAT: 'IPv4 NAT',
|
||||||
searchNAT: '搜索 NAT...',
|
searchNAT: '搜索 NAT...',
|
||||||
noIPv4NATMappings: '暂无 IPv4 NAT 映射',
|
noIPv4NATMappings: '暂无 IPv4 NAT 映射',
|
||||||
@@ -691,8 +964,17 @@ const routingText = {
|
|||||||
pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments',
|
pageSubtitle: 'NAT4, public IPv4 pool, and IPv6 assignments',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
nat4Ports: 'NAT4 ports',
|
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',
|
remainingTotal: 'remaining / total',
|
||||||
publicIPv4: 'Public IPv4',
|
publicIPv4: 'Public IPv4',
|
||||||
|
lanDHCP: 'LAN DHCP',
|
||||||
|
dhcpManagedByLAN: 'Managed by LAN DHCP',
|
||||||
|
lanDHCPAssignments: 'LAN DHCP assignments',
|
||||||
|
noLANDHCPAssignments: 'No LAN DHCP assignments',
|
||||||
publicIPv4Pool: 'Public IPv4 pool',
|
publicIPv4Pool: 'Public IPv4 pool',
|
||||||
editPool: 'Edit pool',
|
editPool: 'Edit pool',
|
||||||
noPublicIPv4Pool: 'No public IPv4 pool configured',
|
noPublicIPv4Pool: 'No public IPv4 pool configured',
|
||||||
@@ -720,10 +1002,17 @@ const routingText = {
|
|||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
detectedIPv6Prefixes: 'Detected IPv6 prefixes',
|
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',
|
prefix: 'Prefix',
|
||||||
hostAddress: 'Host address',
|
hostAddress: 'Host address',
|
||||||
source: 'Source',
|
source: 'Source',
|
||||||
local: 'local',
|
local: 'local',
|
||||||
|
manual: 'manual',
|
||||||
ipv4NAT: 'IPv4 NAT',
|
ipv4NAT: 'IPv4 NAT',
|
||||||
searchNAT: 'Search NAT...',
|
searchNAT: 'Search NAT...',
|
||||||
noIPv4NATMappings: 'No IPv4 NAT mappings',
|
noIPv4NATMappings: 'No IPv4 NAT mappings',
|
||||||
@@ -763,6 +1052,10 @@ function formatDetectedPrefixCount(count: number, language: Language) {
|
|||||||
: `检测到 ${count} 个前缀`
|
: `检测到 ${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) {
|
function formatPrefixCount(count: number, language: Language) {
|
||||||
return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀`
|
return language === 'en' ? `${count} ${count === 1 ? 'prefix' : 'prefixes'}` : `${count} 个前缀`
|
||||||
}
|
}
|
||||||
@@ -801,6 +1094,7 @@ function formatContainerStatus(status: string, language: Language) {
|
|||||||
|
|
||||||
function formatSource(source: string | undefined, language: Language) {
|
function formatSource(source: string | undefined, language: Language) {
|
||||||
if (!source || source === 'local') return routingText[language].local
|
if (!source || source === 'local') return routingText[language].local
|
||||||
|
if (source === 'manual') return routingText[language].manual
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
|
import { Copy, HardDrive, KeyRound, LogIn, RefreshCw, Save, ScrollText, UserCog, X } from 'lucide-react'
|
||||||
import { useDialog } from '../components/Dialog'
|
import { useDialog } from '../components/Dialog'
|
||||||
import api, { AuditLog, LoginLog } from '../services/api'
|
import api, { AuditLog, ImageInfo, LoginLog, getImages, updateSubUserImages } from '../services/api'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
interface SubUserItem {
|
interface SubUserItem {
|
||||||
@@ -9,6 +9,9 @@ interface SubUserItem {
|
|||||||
username: string
|
username: string
|
||||||
container_names: string[]
|
container_names: string[]
|
||||||
container_uuids: string[]
|
container_uuids: string[]
|
||||||
|
allowed_image_ids?: string[]
|
||||||
|
image_limit_configured?: boolean
|
||||||
|
current_image_ids?: string[]
|
||||||
container_name: string
|
container_name: string
|
||||||
container_uuid: string
|
container_uuid: string
|
||||||
access_code: string
|
access_code: string
|
||||||
@@ -34,6 +37,11 @@ export default function SubUserManagement() {
|
|||||||
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
||||||
const [modalTitle, setModalTitle] = useState('')
|
const [modalTitle, setModalTitle] = useState('')
|
||||||
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
||||||
|
const [imageUser, setImageUser] = useState<SubUserItem | null>(null)
|
||||||
|
const [images, setImages] = useState<ImageInfo[]>([])
|
||||||
|
const [selectedImageIDs, setSelectedImageIDs] = useState<string[]>([])
|
||||||
|
const [imagesLoading, setImagesLoading] = useState(false)
|
||||||
|
const [savingImages, setSavingImages] = useState(false)
|
||||||
const [rotatingPassword, setRotatingPassword] = useState(false)
|
const [rotatingPassword, setRotatingPassword] = useState(false)
|
||||||
const [logPage, setLogPage] = useState(1)
|
const [logPage, setLogPage] = useState(1)
|
||||||
const [logPageSize, setLogPageSize] = useState(10)
|
const [logPageSize, setLogPageSize] = useState(10)
|
||||||
@@ -78,6 +86,46 @@ export default function SubUserManagement() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openImageLimit = async (user: SubUserItem) => {
|
||||||
|
setImageUser(user)
|
||||||
|
setSelectedImageIDs(user.allowed_image_ids || [])
|
||||||
|
setImagesLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getImages()
|
||||||
|
const currentIDs = new Set(user.current_image_ids || [])
|
||||||
|
setImages((res.data.data || []).filter((image) => image.downloaded && (image.enabled || currentIDs.has(image.id))))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
dialog.alert('加载失败', error.response?.data?.message || '获取镜像列表失败')
|
||||||
|
} finally {
|
||||||
|
setImagesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleImageID = (id: string) => {
|
||||||
|
setSelectedImageIDs((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveImageLimit = async () => {
|
||||||
|
if (!imageUser) return
|
||||||
|
setSavingImages(true)
|
||||||
|
try {
|
||||||
|
const res = await updateSubUserImages(imageUser.id, selectedImageIDs)
|
||||||
|
const updated = {
|
||||||
|
...imageUser,
|
||||||
|
allowed_image_ids: res.data.data?.allowed_image_ids || selectedImageIDs,
|
||||||
|
image_limit_configured: true,
|
||||||
|
}
|
||||||
|
setUsers((prev) => prev.map((item) => (item.id === imageUser.id ? { ...item, allowed_image_ids: updated.allowed_image_ids, image_limit_configured: true } : item)))
|
||||||
|
setImageUser(null)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
dialog.alert('保存失败', error.response?.data?.message || '保存可用镜像失败')
|
||||||
|
} finally {
|
||||||
|
setSavingImages(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showAuditLogs = async (user: SubUserItem) => {
|
const showAuditLogs = async (user: SubUserItem) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
||||||
@@ -190,6 +238,14 @@ export default function SubUserManagement() {
|
|||||||
<LogIn className="w-3.5 h-3.5" />
|
<LogIn className="w-3.5 h-3.5" />
|
||||||
登录日志
|
登录日志
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openImageLimit(user)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors"
|
||||||
|
title="可用镜像"
|
||||||
|
>
|
||||||
|
<HardDrive className="w-3.5 h-3.5" />
|
||||||
|
可用镜像
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -253,6 +309,75 @@ export default function SubUserManagement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{imageUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-black dark:text-white">可用镜像</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{imageUser.username} · 默认勾选当前系统,取消后将禁止重装该系统</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setImageUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{imagesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-7 w-7 animate-spin rounded-full border-b-2 border-black" />
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-gray-300 px-4 py-10 text-center text-sm text-gray-500">
|
||||||
|
暂无已下载并启用的镜像
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{images.map((image) => {
|
||||||
|
const checked = selectedImageIDs.includes(image.id)
|
||||||
|
const current = (imageUser.current_image_ids || []).includes(image.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={image.id}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-3 py-3 text-sm transition-colors ${checked ? 'border-black bg-gray-50 dark:border-white dark:bg-gray-800' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleImageID(image.id)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium text-black dark:text-white">{image.name}{current ? '(当前系统)' : ''}</span>
|
||||||
|
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{image.type.toUpperCase()} · {image.arch} · {image.distro} {image.release}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">已选择 {selectedImageIDs.length} 个镜像</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setImageUser(null)} className="px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 rounded-md">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveImageLimit}
|
||||||
|
disabled={savingImages || imagesLoading}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{savingImages ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Log Modal */}
|
{/* Log Modal */}
|
||||||
{(auditLogs || loginLogs) && (
|
{(auditLogs || loginLogs) && (
|
||||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export interface Container {
|
|||||||
io_write_mbps: number
|
io_write_mbps: number
|
||||||
status: string
|
status: string
|
||||||
ip: string
|
ip: string
|
||||||
|
lan_ipv4_mode?: string
|
||||||
|
lan_interface?: string
|
||||||
|
lan_ipv4_address?: string
|
||||||
|
lan_ipv4_prefix_len?: number
|
||||||
|
lan_ipv4_gateway?: string
|
||||||
|
mac_address?: string
|
||||||
public_ipv4s?: PublicIPv4Assignment[]
|
public_ipv4s?: PublicIPv4Assignment[]
|
||||||
ipv6: string
|
ipv6: string
|
||||||
ipv6_prefix_len: number
|
ipv6_prefix_len: number
|
||||||
@@ -154,6 +160,11 @@ export interface CreateContainerRequest {
|
|||||||
extra_ports: number[]
|
extra_ports: number[]
|
||||||
port_mapping_count: number
|
port_mapping_count: number
|
||||||
assign_nat?: boolean
|
assign_nat?: boolean
|
||||||
|
lan_ipv4_mode?: string
|
||||||
|
lan_interface?: string
|
||||||
|
lan_ipv4_address?: string
|
||||||
|
lan_ipv4_prefix_len?: number
|
||||||
|
lan_ipv4_gateway?: string
|
||||||
snapshot_limit: number
|
snapshot_limit: number
|
||||||
assign_ipv4?: boolean
|
assign_ipv4?: boolean
|
||||||
ipv4_count?: number
|
ipv4_count?: number
|
||||||
@@ -164,6 +175,8 @@ export interface CreateContainerRequest {
|
|||||||
ssh_auth_mode?: string
|
ssh_auth_mode?: string
|
||||||
ssh_password?: string
|
ssh_password?: string
|
||||||
ssh_public_key?: string
|
ssh_public_key?: string
|
||||||
|
allowed_image_ids?: string[]
|
||||||
|
image_limit_configured?: boolean
|
||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +248,27 @@ export interface HostInfo {
|
|||||||
}
|
}
|
||||||
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
|
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
|
||||||
load: { load1: number; load5: number; load15: number }
|
load: { load1: number; load5: number; load15: number }
|
||||||
|
runtime?: {
|
||||||
|
lxc_available: boolean
|
||||||
|
kvm_available: boolean
|
||||||
|
dev_kvm: boolean
|
||||||
|
nested_virtualization: boolean
|
||||||
|
nested_detail: string
|
||||||
|
support_mode: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostMetricPoint {
|
||||||
|
ts: number
|
||||||
|
cpu: number
|
||||||
|
memory: number
|
||||||
|
network: number
|
||||||
|
network_rx: number
|
||||||
|
network_tx: number
|
||||||
|
disk_io: number
|
||||||
|
disk_read: number
|
||||||
|
disk_write: number
|
||||||
|
disk_usage_pct: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostProbeReport {
|
export interface HostProbeReport {
|
||||||
@@ -345,6 +379,18 @@ export interface ContainerUsage {
|
|||||||
guest_metrics?: boolean
|
guest_metrics?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContainerMetricPoint {
|
||||||
|
ts: number
|
||||||
|
cpu: number
|
||||||
|
memory: number
|
||||||
|
network: number
|
||||||
|
network_rx: number
|
||||||
|
network_tx: number
|
||||||
|
disk_io: number
|
||||||
|
disk_read: number
|
||||||
|
disk_write: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface APIResponse<T = unknown> {
|
export interface APIResponse<T = unknown> {
|
||||||
success: boolean
|
success: boolean
|
||||||
message?: string
|
message?: string
|
||||||
@@ -467,6 +513,9 @@ export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
|
|||||||
export const getContainerUsage = (id: ContainerIdentifier) =>
|
export const getContainerUsage = (id: ContainerIdentifier) =>
|
||||||
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
|
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
|
||||||
|
|
||||||
|
export const getContainerHistory = (id: ContainerIdentifier) =>
|
||||||
|
api.get<APIResponse<ContainerMetricPoint[]>>(`/containers/${id}/history`)
|
||||||
|
|
||||||
export interface TrafficInfo {
|
export interface TrafficInfo {
|
||||||
total_used_bytes: number
|
total_used_bytes: number
|
||||||
rx_used_bytes: number
|
rx_used_bytes: number
|
||||||
@@ -535,6 +584,11 @@ export interface RouteCapacity {
|
|||||||
total: string
|
total: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NAT4PortRange {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface NAT4Route {
|
export interface NAT4Route {
|
||||||
container_id: number
|
container_id: number
|
||||||
container_name: string
|
container_name: string
|
||||||
@@ -559,6 +613,19 @@ export interface IPv4Route {
|
|||||||
gateway?: string
|
gateway?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LANDHCPRoute {
|
||||||
|
container_id: number
|
||||||
|
container_name: string
|
||||||
|
lxc_name: string
|
||||||
|
status: string
|
||||||
|
address: string
|
||||||
|
interface: string
|
||||||
|
prefix_len?: number
|
||||||
|
gateway?: string
|
||||||
|
mac_address?: string
|
||||||
|
mode: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPv6Route {
|
export interface IPv6Route {
|
||||||
container_id: number
|
container_id: number
|
||||||
container_name: string
|
container_name: string
|
||||||
@@ -571,11 +638,14 @@ export interface IPv6Route {
|
|||||||
|
|
||||||
export interface RoutingInfo {
|
export interface RoutingInfo {
|
||||||
nat4: RouteCapacity
|
nat4: RouteCapacity
|
||||||
|
nat4_port_range: NAT4PortRange
|
||||||
ipv4: RouteCapacity
|
ipv4: RouteCapacity
|
||||||
|
lan_dhcp: RouteCapacity
|
||||||
ipv6: RouteCapacity
|
ipv6: RouteCapacity
|
||||||
host_public_ipv4?: PublicIPv4Info
|
host_public_ipv4?: PublicIPv4Info
|
||||||
public_ipv4_addresses: PublicIPv4Info[]
|
public_ipv4_addresses: PublicIPv4Info[]
|
||||||
ipv4_assignments: IPv4Route[]
|
ipv4_assignments: IPv4Route[]
|
||||||
|
lan_dhcp_assignments: LANDHCPRoute[]
|
||||||
nat4_mappings: NAT4Route[]
|
nat4_mappings: NAT4Route[]
|
||||||
ipv6_assignments: IPv6Route[]
|
ipv6_assignments: IPv6Route[]
|
||||||
ipv6_prefixes: IPv6PrefixInfo[]
|
ipv6_prefixes: IPv6PrefixInfo[]
|
||||||
@@ -590,7 +660,7 @@ export interface PublicIPv4ScanResult extends PublicIPv4Info {
|
|||||||
export const getRoutingInfo = () =>
|
export const getRoutingInfo = () =>
|
||||||
api.get<APIResponse<RoutingInfo>>('/routing')
|
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)
|
api.put<APIResponse<RoutingInfo>>('/routing', payload)
|
||||||
|
|
||||||
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
|
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
|
||||||
@@ -643,8 +713,8 @@ export const deleteImage = (templateId: string) =>
|
|||||||
export const toggleImage = (templateId: string, enabled: boolean) =>
|
export const toggleImage = (templateId: string, enabled: boolean) =>
|
||||||
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
||||||
|
|
||||||
export const getEnabledImages = (virtualization = 'lxc') =>
|
export const getEnabledImages = (virtualization = 'lxc', container?: ContainerIdentifier) =>
|
||||||
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
|
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization, ...(container ? { container: String(container) } : {}) } })
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
export const getDashboard = () =>
|
export const getDashboard = () =>
|
||||||
@@ -653,6 +723,9 @@ export const getDashboard = () =>
|
|||||||
export const getHostInfo = () =>
|
export const getHostInfo = () =>
|
||||||
api.get<APIResponse<HostInfo>>('/host-info')
|
api.get<APIResponse<HostInfo>>('/host-info')
|
||||||
|
|
||||||
|
export const getHostHistory = () =>
|
||||||
|
api.get<APIResponse<HostMetricPoint[]>>('/host-history')
|
||||||
|
|
||||||
export const getHostReport = () =>
|
export const getHostReport = () =>
|
||||||
api.get<APIResponse<HostProbeReport>>('/host-report')
|
api.get<APIResponse<HostProbeReport>>('/host-report')
|
||||||
|
|
||||||
@@ -757,6 +830,9 @@ export interface SubUser {
|
|||||||
password?: string
|
password?: string
|
||||||
container_names: string[]
|
container_names: string[]
|
||||||
container_uuids?: string[]
|
container_uuids?: string[]
|
||||||
|
allowed_image_ids?: string[]
|
||||||
|
image_limit_configured?: boolean
|
||||||
|
current_image_ids?: string[]
|
||||||
access_code: string
|
access_code: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -764,6 +840,9 @@ export interface SubUser {
|
|||||||
export const createSubUser = (containerId: ContainerIdentifier) =>
|
export const createSubUser = (containerId: ContainerIdentifier) =>
|
||||||
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
|
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
|
||||||
|
|
||||||
|
export const updateSubUserImages = (id: string, allowedImageIds: string[]) =>
|
||||||
|
api.put<APIResponse<SubUser>>(`/sub-users/${id}/images`, { allowed_image_ids: allowedImageIds })
|
||||||
|
|
||||||
// Audit Logs
|
// Audit Logs
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
time: string
|
time: string
|
||||||
|
|||||||
@@ -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 "=============================="
|
||||||
+155
-31
@@ -3,7 +3,6 @@ set -eu
|
|||||||
|
|
||||||
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
|
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
|
||||||
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
|
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
|
||||||
ASSET="clicd-linux-amd64.tar.gz"
|
|
||||||
ACTION="${1:-install}"
|
ACTION="${1:-install}"
|
||||||
ACTION_CONFIRM="${2:-}"
|
ACTION_CONFIRM="${2:-}"
|
||||||
ISSUE_URL="https://github.com/${REPO}/issues"
|
ISSUE_URL="https://github.com/${REPO}/issues"
|
||||||
@@ -11,6 +10,80 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
|
|||||||
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
|
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
|
||||||
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
|
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
|
||||||
|
|
||||||
|
normalize_clicd_arch() {
|
||||||
|
arch="$1"
|
||||||
|
case "$(printf '%s' "$arch" | tr 'A-Z' 'a-z')" in
|
||||||
|
x86_64|amd64) echo amd64 ;;
|
||||||
|
aarch64|arm64) echo arm64 ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
HOST_ARCH_RAW="$(uname -m 2>/dev/null || echo unknown)"
|
||||||
|
CLICD_ARCH_NORMALIZED="$(normalize_clicd_arch "${CLICD_ARCH:-$HOST_ARCH_RAW}")"
|
||||||
|
ASSET_DIR="clicd-linux-${CLICD_ARCH_NORMALIZED:-unknown}"
|
||||||
|
ASSET="${ASSET_DIR}.tar.gz"
|
||||||
|
BINARY_ASSET="$ASSET_DIR"
|
||||||
|
|
||||||
|
kvm_supported_arch() {
|
||||||
|
[ "$CLICD_ARCH_NORMALIZED" = "amd64" ] || [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
warn_kvm_unsupported_arch() {
|
||||||
|
if ! kvm_supported_arch; then
|
||||||
|
warn "当前架构 ${CLICD_ARCH_NORMALIZED:-unknown} 已适配 CLICD/LXC;KVM 功能当前支持 x86_64/amd64 和 aarch64/arm64,将跳过 KVM 专用依赖。"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu_system_package_apk() {
|
||||||
|
case "$CLICD_ARCH_NORMALIZED" in
|
||||||
|
arm64) echo qemu-system-aarch64 ;;
|
||||||
|
*) echo qemu-system-x86_64 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu_system_package_apt() {
|
||||||
|
case "$CLICD_ARCH_NORMALIZED" in
|
||||||
|
arm64) echo qemu-system-arm ;;
|
||||||
|
*) echo qemu-system-x86 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu_system_package_rpm() {
|
||||||
|
case "$CLICD_ARCH_NORMALIZED" in
|
||||||
|
arm64) echo qemu-system-aarch64 ;;
|
||||||
|
*) echo qemu-kvm ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu_emulator_cmd() {
|
||||||
|
case "$CLICD_ARCH_NORMALIZED" in
|
||||||
|
arm64) echo qemu-system-aarch64 ;;
|
||||||
|
*) echo qemu-system-x86_64 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu_efi_package_apt() {
|
||||||
|
case "$CLICD_ARCH_NORMALIZED" in
|
||||||
|
arm64) echo qemu-efi-aarch64 ;;
|
||||||
|
*) echo ovmf ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu_efi_package_apk() {
|
||||||
|
case "$CLICD_ARCH_NORMALIZED" in
|
||||||
|
arm64) echo edk2-aarch64 ;;
|
||||||
|
*) echo ovmf ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu_efi_package_rpm() {
|
||||||
|
case "$CLICD_ARCH_NORMALIZED" in
|
||||||
|
arm64) echo edk2-aarch64 ;;
|
||||||
|
*) echo edk2-ovmf ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
normalize_lang() {
|
normalize_lang() {
|
||||||
lang="$1"
|
lang="$1"
|
||||||
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
|
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
|
||||||
@@ -286,14 +359,8 @@ run_step() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_os_compatibility() {
|
check_os_compatibility() {
|
||||||
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)"
|
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=${HOST_ARCH_RAW} CLICD_ARCH=${CLICD_ARCH_NORMALIZED:-unsupported}"
|
||||||
case "$(uname -m 2>/dev/null || echo unknown)" in
|
[ -n "$CLICD_ARCH_NORMALIZED" ] || die "当前安装包支持 x86_64/amd64 和 aarch64/arm64,当前架构:${HOST_ARCH_RAW}。"
|
||||||
x86_64|amd64)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)。"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if ! is_systemd && ! is_openrc; then
|
if ! is_systemd && ! is_openrc; then
|
||||||
die "未检测到 systemd 或 OpenRC,无法安装服务。"
|
die "未检测到 systemd 或 OpenRC,无法安装服务。"
|
||||||
fi
|
fi
|
||||||
@@ -470,13 +537,24 @@ remove_clicd_lxc_image_cache() {
|
|||||||
for image in \
|
for image in \
|
||||||
"ubuntu noble amd64" \
|
"ubuntu noble amd64" \
|
||||||
"ubuntu jammy amd64" \
|
"ubuntu jammy amd64" \
|
||||||
|
"debian trixie amd64" \
|
||||||
"debian bookworm amd64" \
|
"debian bookworm amd64" \
|
||||||
"debian bullseye amd64" \
|
"debian bullseye amd64" \
|
||||||
"alpine 3.21 amd64" \
|
"alpine 3.21 amd64" \
|
||||||
"centos 9-Stream amd64" \
|
"centos 9-Stream amd64" \
|
||||||
"archlinux current amd64" \
|
"archlinux current amd64" \
|
||||||
"fedora 44 amd64" \
|
"fedora 44 amd64" \
|
||||||
"rockylinux 10 amd64"
|
"rockylinux 10 amd64" \
|
||||||
|
"ubuntu noble arm64" \
|
||||||
|
"ubuntu jammy arm64" \
|
||||||
|
"debian trixie arm64" \
|
||||||
|
"debian bookworm arm64" \
|
||||||
|
"debian bullseye arm64" \
|
||||||
|
"alpine 3.21 arm64" \
|
||||||
|
"centos 9-Stream arm64" \
|
||||||
|
"archlinux current arm64" \
|
||||||
|
"fedora 44 arm64" \
|
||||||
|
"rockylinux 10 arm64"
|
||||||
do
|
do
|
||||||
set -- $image
|
set -- $image
|
||||||
distro="$1"
|
distro="$1"
|
||||||
@@ -950,15 +1028,21 @@ install_apk() {
|
|||||||
iproute2 \
|
iproute2 \
|
||||||
iptables \
|
iptables \
|
||||||
dnsmasq \
|
dnsmasq \
|
||||||
dbus \
|
dbus
|
||||||
qemu-system-x86_64 \
|
|
||||||
|
if kvm_supported_arch; then
|
||||||
|
apk add --no-cache \
|
||||||
|
"$(qemu_system_package_apk)" \
|
||||||
qemu-img \
|
qemu-img \
|
||||||
libvirt \
|
libvirt \
|
||||||
libvirt-daemon \
|
libvirt-daemon \
|
||||||
libvirt-client \
|
libvirt-client \
|
||||||
libvirt-qemu
|
libvirt-qemu
|
||||||
|
else
|
||||||
|
warn_kvm_unsupported_arch
|
||||||
|
fi
|
||||||
|
|
||||||
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do
|
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools "$(qemu_efi_package_apk)"; do
|
||||||
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -985,9 +1069,12 @@ install_apt() {
|
|||||||
quota \
|
quota \
|
||||||
e2fsprogs \
|
e2fsprogs \
|
||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq-base \
|
dnsmasq-base
|
||||||
qemu-kvm \
|
|
||||||
qemu-system-x86 \
|
if kvm_supported_arch; then
|
||||||
|
if [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]; then
|
||||||
|
apt-get install -y \
|
||||||
|
"$(qemu_system_package_apt)" \
|
||||||
qemu-utils \
|
qemu-utils \
|
||||||
libvirt-daemon-system \
|
libvirt-daemon-system \
|
||||||
libvirt-clients \
|
libvirt-clients \
|
||||||
@@ -996,7 +1083,25 @@ install_apt() {
|
|||||||
xorriso \
|
xorriso \
|
||||||
smartmontools \
|
smartmontools \
|
||||||
virtinst \
|
virtinst \
|
||||||
ovmf
|
"$(qemu_efi_package_apt)"
|
||||||
|
else
|
||||||
|
apt-get install -y \
|
||||||
|
qemu-kvm \
|
||||||
|
"$(qemu_system_package_apt)" \
|
||||||
|
qemu-utils \
|
||||||
|
libvirt-daemon-system \
|
||||||
|
libvirt-clients \
|
||||||
|
cloud-image-utils \
|
||||||
|
genisoimage \
|
||||||
|
xorriso \
|
||||||
|
smartmontools \
|
||||||
|
virtinst \
|
||||||
|
"$(qemu_efi_package_apt)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn_kvm_unsupported_arch
|
||||||
|
apt-get install -y qemu-utils genisoimage xorriso smartmontools >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
enable_el_repos() {
|
enable_el_repos() {
|
||||||
@@ -1032,8 +1137,11 @@ install_dnf() {
|
|||||||
quota \
|
quota \
|
||||||
e2fsprogs \
|
e2fsprogs \
|
||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq \
|
dnsmasq
|
||||||
qemu-kvm \
|
|
||||||
|
if kvm_supported_arch; then
|
||||||
|
dnf install -y \
|
||||||
|
"$(qemu_system_package_rpm)" \
|
||||||
qemu-img \
|
qemu-img \
|
||||||
libvirt \
|
libvirt \
|
||||||
libvirt-daemon-kvm \
|
libvirt-daemon-kvm \
|
||||||
@@ -1041,8 +1149,12 @@ install_dnf() {
|
|||||||
virt-install \
|
virt-install \
|
||||||
cloud-utils \
|
cloud-utils \
|
||||||
genisoimage
|
genisoimage
|
||||||
|
else
|
||||||
|
warn_kvm_unsupported_arch
|
||||||
|
dnf install -y qemu-img genisoimage >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
|
for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
|
||||||
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -1067,8 +1179,11 @@ install_yum() {
|
|||||||
quota \
|
quota \
|
||||||
e2fsprogs \
|
e2fsprogs \
|
||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq \
|
dnsmasq
|
||||||
qemu-kvm \
|
|
||||||
|
if kvm_supported_arch; then
|
||||||
|
yum install -y \
|
||||||
|
"$(qemu_system_package_rpm)" \
|
||||||
qemu-img \
|
qemu-img \
|
||||||
libvirt \
|
libvirt \
|
||||||
libvirt-daemon-kvm \
|
libvirt-daemon-kvm \
|
||||||
@@ -1076,8 +1191,12 @@ install_yum() {
|
|||||||
virt-install \
|
virt-install \
|
||||||
cloud-utils \
|
cloud-utils \
|
||||||
genisoimage
|
genisoimage
|
||||||
|
else
|
||||||
|
warn_kvm_unsupported_arch
|
||||||
|
yum install -y qemu-img genisoimage >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
|
for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do
|
||||||
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -1117,7 +1236,9 @@ install_dependencies() {
|
|||||||
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
|
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
|
||||||
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
|
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
|
||||||
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
|
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
|
||||||
|
if kvm_supported_arch; then
|
||||||
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
|
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
|
||||||
|
has_cmd "$(qemu_emulator_cmd)" || die "依赖安装后仍未找到 $(qemu_emulator_cmd),请检查 QEMU 安装。"
|
||||||
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
|
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
|
||||||
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
|
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
|
||||||
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
|
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
|
||||||
@@ -1126,6 +1247,9 @@ install_dependencies() {
|
|||||||
if [ ! -e /dev/kvm ]; then
|
if [ ! -e /dev/kvm ]; then
|
||||||
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
|
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
warn_kvm_unsupported_arch
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
configure_kernel_networking() {
|
configure_kernel_networking() {
|
||||||
@@ -1384,7 +1508,7 @@ download_release_if_needed() {
|
|||||||
if [ "$archive_ok" = "1" ]; then
|
if [ "$archive_ok" = "1" ]; then
|
||||||
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
|
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
|
||||||
else
|
else
|
||||||
binary_asset="clicd-linux-amd64"
|
binary_asset="$BINARY_ASSET"
|
||||||
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
|
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
|
||||||
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
|
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
|
||||||
else
|
else
|
||||||
@@ -1402,9 +1526,9 @@ download_release_if_needed() {
|
|||||||
[ -n "$url" ] || continue
|
[ -n "$url" ] || continue
|
||||||
log "Trying release binary: $url"
|
log "Trying release binary: $url"
|
||||||
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
|
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
|
||||||
mkdir -p "$tmp_dir/clicd-linux-amd64"
|
mkdir -p "$tmp_dir/$ASSET_DIR"
|
||||||
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
|
cp "$binary_path" "$tmp_dir/$ASSET_DIR/clicd"
|
||||||
chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
|
chmod +x "$tmp_dir/$ASSET_DIR/clicd"
|
||||||
binary_ok=1
|
binary_ok=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -1414,8 +1538,8 @@ download_release_if_needed() {
|
|||||||
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
|
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
|
[ -d "$tmp_dir/$ASSET_DIR" ] || die "Release package layout is invalid: missing $ASSET_DIR directory"
|
||||||
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
[ -f "$tmp_dir/$ASSET_DIR/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_binary() {
|
install_binary() {
|
||||||
@@ -1430,8 +1554,8 @@ install_binary() {
|
|||||||
download_dir=""
|
download_dir=""
|
||||||
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
|
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
|
||||||
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
|
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
|
||||||
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
|
if [ -n "$download_dir" ] && [ -f "$download_dir/$ASSET_DIR/clicd" ]; then
|
||||||
bin_src="$download_dir/clicd-linux-amd64/clicd"
|
bin_src="$download_dir/$ASSET_DIR/clicd"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
|
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
|
||||||
|
|||||||
Reference in New Issue
Block a user