mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
70 Commits
v1.1.19
...
a28ae727f3
| Author | SHA1 | Date | |
|---|---|---|---|
| a28ae727f3 | |||
| 57d6b19c05 | |||
| bae028ade4 | |||
| 2188bddb93 | |||
| 79de3d5552 | |||
| 70679b6fbb | |||
| d739dcbaa4 | |||
| 9eaf5002df | |||
| 73433c035d | |||
| 5474991a6d | |||
| 37b16b83a5 | |||
| 38debab1aa | |||
| 24204609a1 | |||
| deedf86c22 | |||
| 8283b88ded | |||
| 6c9f24bb24 | |||
| f2fa2449e9 | |||
| 53d56be8f9 | |||
| ec38ab9136 | |||
| fdcd7df9e9 | |||
| d6d46296fe | |||
| 3f44c7565f | |||
| 61d842d94c | |||
| ca303d33f6 | |||
| 6bdeafccf2 | |||
| 04cefe0cf1 | |||
| f28117bc5e | |||
| 2324494dd7 | |||
| ebba97f1d6 | |||
| 3dabd93d2f | |||
| 8bad52bd9e | |||
| d05ca8cc4c | |||
| 48fa14f8a7 | |||
| 5c6d6eafc9 | |||
| 2ecdb5c26f | |||
| ae02241370 | |||
| fdd83977fc | |||
| 58d86b5d08 | |||
| 7307255130 | |||
| 0e3c059236 | |||
| 61137b837d | |||
| 596bf86477 | |||
| 47a09aa177 | |||
| 2456b65ce2 | |||
| 292686a19a | |||
| 9eb7c322cf | |||
| 5ec62ca732 | |||
| a79df0d2dd | |||
| cc8fdbfede | |||
| 702d6975e5 | |||
| 84d98e40c6 | |||
| fd974d95b9 | |||
| cd258fd6ac | |||
| 0c2dd457d4 | |||
| 92e846eecc | |||
| a1d9ce8b1c | |||
| 49d8093f45 | |||
| 98ed716225 | |||
| 78276d303b | |||
| 30d2a4f4da | |||
| a54e03b924 | |||
| 4cdc6e68ba | |||
| 2ed42992ed | |||
| 4dfd7c0885 | |||
| 5ed5b4509d | |||
| 18f297b988 | |||
| d5a236943b | |||
| c54f92f892 | |||
| 86f0d079ab | |||
| 3a65d5d24a |
+43
-11
@@ -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
|
||||||
@@ -25,7 +31,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: "22.22.0"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
frontend/package-lock.json
|
frontend/package-lock.json
|
||||||
@@ -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/*
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Publish GitHub Release
|
||||||
|
needs: linux
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: dist-artifacts
|
||||||
|
|
||||||
|
- name: Prepare release assets
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
find dist-artifacts -maxdepth 2 -type f ! -name SHA256SUMS -print -exec cp -f {} dist/ \;
|
||||||
|
sha256sum dist/* > dist/SHA256SUMS
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
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
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22.22.0"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: docs/package-lock.json
|
cache-dependency-path: docs/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 @@ linux.txt
|
|||||||
push-release.ps1
|
push-release.ps1
|
||||||
deploy.ps1
|
deploy.ps1
|
||||||
backend/clicd
|
backend/clicd
|
||||||
|
api.md
|
||||||
|
deploy-arm.ps1
|
||||||
|
deploy-dhcp.ps1
|
||||||
|
deploy-pve-windows.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');
|
||||||
|
|||||||
+1061
-449
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
<style>
|
<style>
|
||||||
.clicd-info{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
|
.clicd-info{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">
|
||||||
@@ -119,10 +119,4 @@ This open-source software is intended solely for educational purposes, specifica
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
|
[](https://meteor-history.com)
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&theme=dark&legend=top-left" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|||||||
+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,94 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type panelAccessPolicyResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AllowedSources []string `json:"allowed_sources"`
|
||||||
|
TrustedProxies []string `json:"trusted_proxies"`
|
||||||
|
CurrentSource string `json:"current_source"`
|
||||||
|
DirectSource string `json:"direct_source"`
|
||||||
|
UsingForwarded bool `json:"using_forwarded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandlePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: panelAccessPolicyStatus(r, config.AppConfig.PanelAccessPolicy)})
|
||||||
|
case http.MethodPut:
|
||||||
|
updatePanelAccessPolicy(w, r)
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePanelAccessPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var requested config.PanelAccessPolicy
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requested); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalized, err := config.NormalizePanelAccessPolicy(requested)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decision := evaluatePanelRequest(r, normalized)
|
||||||
|
if normalized.Enabled && !decision.Allowed {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: "The new access policy does not allow your current source address " + decision.CurrentSource,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := config.AppConfig.PanelAccessPolicy
|
||||||
|
config.AppConfig.PanelAccessPolicy = normalized
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
config.AppConfig.PanelAccessPolicy = previous
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save panel access policy"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail := "enabled=" + strings.ToLower(strings.TrimSpace(boolText(normalized.Enabled))) +
|
||||||
|
",allowed=" + strings.Join(normalized.AllowedSources, ",") +
|
||||||
|
",trusted_proxies=" + strings.Join(normalized.TrustedProxies, ",")
|
||||||
|
auditRequest(r, "settings.panel_access", "Panel access policy", detail, true, "")
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Panel access policy saved",
|
||||||
|
Data: panelAccessPolicyStatus(r, normalized),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func panelAccessPolicyStatus(r *http.Request, policy config.PanelAccessPolicy) panelAccessPolicyResponse {
|
||||||
|
decision := evaluatePanelRequest(r, policy)
|
||||||
|
return panelAccessPolicyResponse{
|
||||||
|
Enabled: policy.Enabled,
|
||||||
|
AllowedSources: append([]string(nil), policy.AllowedSources...),
|
||||||
|
TrustedProxies: append([]string(nil), policy.TrustedProxies...),
|
||||||
|
CurrentSource: decision.CurrentSource,
|
||||||
|
DirectSource: decision.DirectSource,
|
||||||
|
UsingForwarded: decision.UsedForwarded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluatePanelRequest(r *http.Request, policy config.PanelAccessPolicy) config.PanelAccessDecision {
|
||||||
|
return config.EvaluatePanelAccess(policy, r.RemoteAddr, config.ForwardedClientHeaders{
|
||||||
|
ForwardedFor: r.Header.Get("X-Forwarded-For"),
|
||||||
|
RealIP: r.Header.Get("X-Real-IP"),
|
||||||
|
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolText(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -167,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
assignIPv6(w, r, id)
|
assignIPv6(w, r, id)
|
||||||
|
case action == "public-ipv4" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "container:network") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updatePublicIPv4(w, r, id)
|
||||||
|
case action == "ipv6-addresses" && r.Method == http.MethodPut:
|
||||||
|
if !requireScope(w, r, "ipv6:assign") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateIPv6Addresses(w, r, id)
|
||||||
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
|
||||||
handleContainerSnapshots(w, r, id, action)
|
handleContainerSnapshots(w, r, id, action)
|
||||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||||
@@ -240,6 +255,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
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
|
||||||
}
|
}
|
||||||
@@ -253,11 +274,13 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
|
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||||
cfg.PortMappingCount = 2
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
} else if !cfg.WantsNAT() {
|
return
|
||||||
cfg.PortMappingCount = 0
|
}
|
||||||
cfg.ExtraPorts = nil
|
if err := lxc.ValidateCreateNATPortAvailability(cfg); err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if cfg.PortMappingCount > 64 {
|
if cfg.PortMappingCount > 64 {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
|
||||||
@@ -288,6 +311,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateCreateStoragePool(&cfg); err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := validateCreateSSHAuth(cfg); err != nil {
|
if err := validateCreateSSHAuth(cfg); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -452,12 +479,11 @@ 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"
|
||||||
@@ -582,9 +608,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 +682,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) {
|
||||||
|
|||||||
+495
-32
@@ -5,6 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -21,12 +23,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type HostInfo struct {
|
type HostInfo struct {
|
||||||
CPU CpuInfo `json:"cpu"`
|
CPU CpuInfo `json:"cpu"`
|
||||||
RAM MemoryInfo `json:"ram"`
|
RAM MemoryInfo `json:"ram"`
|
||||||
Disk DiskInfo `json:"disk"`
|
Disk DiskInfo `json:"disk"`
|
||||||
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,20 +518,22 @@ 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 {
|
||||||
network.PublicIPv4 = publicIPv4.Address
|
publicIPv4 := detectDisplayPublicIPv4()
|
||||||
network.PublicIPv4Interface = publicIPv4.Interface
|
network.PublicIPv4 = publicIPv4.Address
|
||||||
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
network.PublicIPv4Interface = publicIPv4.Interface
|
||||||
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||||
if len(network.IPv6Prefixes) > 0 {
|
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
|
||||||
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
if len(network.IPv6Prefixes) > 0 {
|
||||||
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
network.PublicIPv6 = network.IPv6Prefixes[0].Address
|
||||||
|
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
|
||||||
|
}
|
||||||
}
|
}
|
||||||
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+653
-43
@@ -2,18 +2,24 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
"clicd/internal/kvm"
|
"clicd/internal/kvm"
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
|
"clicd/internal/safehttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageInfo represents a template image with its download/enable status.
|
// ImageInfo represents a template image with its download/enable status.
|
||||||
@@ -36,10 +42,19 @@ type ImageInfo struct {
|
|||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
ManualPath string `json:"manual_path,omitempty"`
|
ManualPath string `json:"manual_path,omitempty"`
|
||||||
Desktop string `json:"desktop,omitempty"`
|
Desktop string `json:"desktop,omitempty"`
|
||||||
|
Provisioner string `json:"provisioner,omitempty"`
|
||||||
|
Custom bool `json:"custom,omitempty"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var customImageFieldPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
|
||||||
|
var sha256Pattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`)
|
||||||
|
|
||||||
var imageDownloadsMu sync.Mutex
|
var imageDownloadsMu sync.Mutex
|
||||||
var imageDownloads = map[string]*imageDownloadStatus{}
|
var imageDownloads = map[string]*imageDownloadStatus{}
|
||||||
|
var lxcImageCacheMu sync.Mutex
|
||||||
|
var lxcImageDownloadMu sync.Mutex
|
||||||
|
var lxcImageDownloadActive bool
|
||||||
|
|
||||||
type imageDownloadStatus struct {
|
type imageDownloadStatus struct {
|
||||||
Downloading bool
|
Downloading bool
|
||||||
@@ -135,6 +150,22 @@ func isImageDownloadActive(id string) bool {
|
|||||||
return st != nil && st.Downloading
|
return st != nil && st.Downloading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func beginLXCImageDownload() bool {
|
||||||
|
lxcImageDownloadMu.Lock()
|
||||||
|
defer lxcImageDownloadMu.Unlock()
|
||||||
|
if lxcImageDownloadActive {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lxcImageDownloadActive = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func endLXCImageDownload() {
|
||||||
|
lxcImageDownloadMu.Lock()
|
||||||
|
lxcImageDownloadActive = false
|
||||||
|
lxcImageDownloadMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func lxcImageDownloadTempName(id string) string {
|
func lxcImageDownloadTempName(id string) string {
|
||||||
return fmt.Sprintf("clicd-img-dl-%s", id)
|
return fmt.Sprintf("clicd-img-dl-%s", id)
|
||||||
}
|
}
|
||||||
@@ -156,46 +187,68 @@ func cleanupOldImageDownloadErrors() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isImageDownloaded checks if the LXC download cache exists for a template.
|
|
||||||
func isImageDownloaded(distro, release, arch string) bool {
|
|
||||||
downloaded, _ := imageDownloadedInfo(distro, release, arch)
|
|
||||||
return downloaded
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageDownloadedInfo returns whether the image is downloaded and its total size in bytes.
|
// imageDownloadedInfo returns whether the image is downloaded and its total size in bytes.
|
||||||
func imageDownloadedInfo(distro, release, arch string) (bool, int64) {
|
func imageDownloadedInfo(templateID string) (bool, int64) {
|
||||||
cachePath := filepath.Join("/var/cache/lxc/download", distro, release, arch)
|
cachePath, ok := officialLXCImageCachePath(templateID)
|
||||||
|
if !ok {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
info, err := os.Stat(cachePath)
|
info, err := os.Stat(cachePath)
|
||||||
if err != nil || !info.IsDir() {
|
if err != nil || !info.IsDir() {
|
||||||
return false, 0
|
return false, 0
|
||||||
}
|
}
|
||||||
// Check directly for rootfs.tar.xz (some LXC versions store it here)
|
for _, candidate := range []string{
|
||||||
if fi, err := os.Stat(filepath.Join(cachePath, "rootfs.tar.xz")); err == nil {
|
filepath.Join(cachePath, "rootfs.tar.xz"),
|
||||||
return true, fi.Size()
|
filepath.Join(cachePath, "meta.tar.xz"),
|
||||||
}
|
filepath.Join(cachePath, "default", "rootfs.tar.xz"),
|
||||||
if fi, err := os.Stat(filepath.Join(cachePath, "meta.tar.xz")); err == nil {
|
filepath.Join(cachePath, "default", "meta.tar.xz"),
|
||||||
return true, fi.Size()
|
} {
|
||||||
}
|
if fileInfo, err := os.Stat(candidate); err == nil && !fileInfo.IsDir() {
|
||||||
// Check one level deeper (LXC uses variant subdirectories like "default")
|
return true, fileInfo.Size()
|
||||||
entries, err := os.ReadDir(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
return false, 0
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subPath := filepath.Join(cachePath, entry.Name())
|
|
||||||
if fi, err := os.Stat(filepath.Join(subPath, "rootfs.tar.xz")); err == nil {
|
|
||||||
return true, fi.Size()
|
|
||||||
}
|
|
||||||
if fi, err := os.Stat(filepath.Join(subPath, "meta.tar.xz")); err == nil {
|
|
||||||
return true, fi.Size()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, 0
|
return false, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func officialLXCImageCachePath(templateID string) (string, bool) {
|
||||||
|
arch := "amd64"
|
||||||
|
if runtime.GOARCH == "arm64" {
|
||||||
|
arch = "arm64"
|
||||||
|
}
|
||||||
|
base := "/var/cache/lxc/download"
|
||||||
|
switch templateID {
|
||||||
|
case "ubuntu-noble":
|
||||||
|
return filepath.Join(base, "ubuntu", "noble", arch), true
|
||||||
|
case "ubuntu-jammy":
|
||||||
|
return filepath.Join(base, "ubuntu", "jammy", arch), true
|
||||||
|
case "debian-trixie":
|
||||||
|
return filepath.Join(base, "debian", "trixie", arch), true
|
||||||
|
case "debian-bookworm":
|
||||||
|
return filepath.Join(base, "debian", "bookworm", arch), true
|
||||||
|
case "debian-bullseye":
|
||||||
|
return filepath.Join(base, "debian", "bullseye", arch), true
|
||||||
|
case "alpine-3.21":
|
||||||
|
return filepath.Join(base, "alpine", "3.21", arch), true
|
||||||
|
case "centos-9-stream":
|
||||||
|
return filepath.Join(base, "centos", "9-Stream", arch), true
|
||||||
|
case "archlinux-current":
|
||||||
|
return filepath.Join(base, "archlinux", "current", arch), true
|
||||||
|
case "fedora-44":
|
||||||
|
return filepath.Join(base, "fedora", "44", arch), true
|
||||||
|
case "rockylinux-10":
|
||||||
|
return filepath.Join(base, "rockylinux", "10", arch), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lxcTemplateDownloadedInfo(template lxc.Template) (bool, int64) {
|
||||||
|
if template.Custom {
|
||||||
|
return lxc.CustomImageDownloadedInfo(template.ID)
|
||||||
|
}
|
||||||
|
return imageDownloadedInfo(template.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// getEnabledImageSet returns the set of enabled image IDs.
|
// getEnabledImageSet returns the set of enabled image IDs.
|
||||||
// If none have been explicitly set, all templates are enabled by default.
|
// If none have been explicitly set, all templates are enabled by default.
|
||||||
func getEnabledImageSet() map[string]bool {
|
func getEnabledImageSet() map[string]bool {
|
||||||
@@ -227,12 +280,17 @@ 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))
|
||||||
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 := lxcTemplateDownloadedInfo(t)
|
||||||
images = append(images, ImageInfo{
|
images = append(images, ImageInfo{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
@@ -250,13 +308,15 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
Stage: dl.Stage,
|
Stage: dl.Stage,
|
||||||
Error: dl.Error,
|
Error: dl.Error,
|
||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
|
Custom: t.Custom,
|
||||||
|
SHA256: t.SHA256,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
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 := ""
|
||||||
if t.Distro == "windows" {
|
if t.IsWindows() {
|
||||||
manualPath = kvm.ImagePath(t.ID)
|
manualPath = kvm.ImagePath(t.ID)
|
||||||
}
|
}
|
||||||
images = append(images, ImageInfo{
|
images = append(images, ImageInfo{
|
||||||
@@ -278,12 +338,260 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
ManualPath: manualPath,
|
ManualPath: manualPath,
|
||||||
Desktop: t.Desktop,
|
Desktop: t.Desktop,
|
||||||
|
Provisioner: t.Provisioner,
|
||||||
|
Custom: t.Custom,
|
||||||
|
SHA256: t.SHA256,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleCustomKVMImages creates or removes administrator-defined LXC/KVM image sources.
|
||||||
|
func HandleCustomKVMImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
if !requireScope(w, r, "image:download") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleCustomKVMImageCreate(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
if !requireScope(w, r, "image:delete") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleCustomKVMImageDelete(w, r)
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCustomKVMImageCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Distro string `json:"distro"`
|
||||||
|
Release string `json:"release"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Provisioner string `json:"provisioner"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
if req.Type == "" {
|
||||||
|
req.Type = config.VirtualizationKVM
|
||||||
|
}
|
||||||
|
req.Description = strings.TrimSpace(req.Description)
|
||||||
|
req.Distro = strings.ToLower(strings.TrimSpace(req.Distro))
|
||||||
|
req.Release = strings.ToLower(strings.TrimSpace(req.Release))
|
||||||
|
req.Arch = strings.ToLower(strings.TrimSpace(req.Arch))
|
||||||
|
req.URL = strings.TrimSpace(req.URL)
|
||||||
|
req.Provisioner = strings.ToLower(strings.TrimSpace(req.Provisioner))
|
||||||
|
req.SHA256 = strings.ToLower(strings.TrimSpace(req.SHA256))
|
||||||
|
|
||||||
|
if req.Name == "" || len(req.Name) > 100 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "name must be between 1 and 100 characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Description) > 500 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "description must not exceed 500 characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Arch != runtime.GOARCH || (req.Arch != "amd64" && req.Arch != "arm64") {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "image architecture must match the host architecture"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Type == config.VirtualizationLXC {
|
||||||
|
if !customImageFieldPattern.MatchString(req.Distro) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !customImageFieldPattern.MatchString(req.Release) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if req.Type == config.VirtualizationKVM {
|
||||||
|
switch req.Provisioner {
|
||||||
|
case config.KVMProvisionerLinuxCloudInit:
|
||||||
|
if !customImageFieldPattern.MatchString(req.Distro) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "distro contains unsupported characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !customImageFieldPattern.MatchString(req.Release) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "release contains unsupported characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case config.KVMProvisionerWindows10:
|
||||||
|
if req.Arch != "amd64" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Distro = "windows"
|
||||||
|
req.Release = "10"
|
||||||
|
case config.KVMProvisionerWindows11:
|
||||||
|
if req.Arch != "amd64" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Windows unattended installation currently requires an amd64 host"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Distro = "windows"
|
||||||
|
req.Release = "11"
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "unsupported unattended installation template"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "type must be lxc or kvm"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.URL) > 4096 {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "url must not exceed 4096 characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := safehttp.ValidateURL(req.URL); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SHA256 != "" && !sha256Pattern.MatchString(req.SHA256) {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "sha256 must contain exactly 64 hexadecimal characters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Type == config.VirtualizationLXC {
|
||||||
|
for _, existing := range lxc.GetTemplates() {
|
||||||
|
if strings.EqualFold(existing.Name, req.Name) {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing.Custom && existing.URL == req.URL {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, existing := range kvm.GetImages() {
|
||||||
|
if strings.EqualFold(existing.Name, req.Name) {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "an image with this name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing.Custom && existing.URL == req.URL {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "this image URL is already registered"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
random := make([]byte, 5)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to generate image ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createdAt := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if req.Type == config.VirtualizationLXC {
|
||||||
|
image := config.CustomLXCImage{
|
||||||
|
ID: "custom-lxc-" + hex.EncodeToString(random),
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Distro: req.Distro,
|
||||||
|
Release: req.Release,
|
||||||
|
Arch: req.Arch,
|
||||||
|
URL: req.URL,
|
||||||
|
SHA256: req.SHA256,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
if err := config.AddCustomLXCImage(image); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
image := config.CustomKVMImage{
|
||||||
|
ID: "custom-kvm-" + hex.EncodeToString(random), Name: req.Name, Description: req.Description,
|
||||||
|
Distro: req.Distro, Release: req.Release, Arch: req.Arch, URL: req.URL,
|
||||||
|
Provisioner: req.Provisioner, SHA256: req.SHA256, CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
if err := config.AddCustomKVMImage(image); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "failed to save custom image: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Custom image added", Data: image})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCustomKVMImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.ID) == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = strings.TrimSpace(req.ID)
|
||||||
|
kvmImage := kvm.FindImage(req.ID)
|
||||||
|
lxcImage := lxc.FindTemplate(req.ID)
|
||||||
|
isCustomKVM := kvmImage != nil && kvmImage.Custom
|
||||||
|
isCustomLXC := lxcImage != nil && lxcImage.Custom
|
||||||
|
if !isCustomKVM && !isCustomLXC {
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isImageDownloadActive(req.ID) {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Image is downloading; cancel it before removing the source"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range config.AppConfig.Containers {
|
||||||
|
if config.AppConfig.Containers[i].Template == req.ID {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still used by a container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range config.AppConfig.Tasks {
|
||||||
|
task := &config.AppConfig.Tasks[i]
|
||||||
|
if task.Status != "pending" && task.Status != "running" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var taskConfig struct {
|
||||||
|
TemplateID string `json:"template_id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(task.Config), &taskConfig)
|
||||||
|
if task.TemplateID == req.ID || taskConfig.TemplateID == req.ID {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "This image is still referenced by an active task"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var deleteErr error
|
||||||
|
if isCustomLXC {
|
||||||
|
deleteErr = lxc.DeleteCustomImage(req.ID)
|
||||||
|
} else {
|
||||||
|
deleteErr = kvm.DeleteImage(req.ID)
|
||||||
|
}
|
||||||
|
if deleteErr != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + deleteErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeImageEnabled(req.ID)
|
||||||
|
var removed bool
|
||||||
|
var err error
|
||||||
|
if isCustomLXC {
|
||||||
|
removed, err = config.RemoveCustomLXCImage(req.ID)
|
||||||
|
} else {
|
||||||
|
removed, err = config.RemoveCustomKVMImage(req.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to remove custom image: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Custom image not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearImageDownload(req.ID)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Custom image removed"})
|
||||||
|
}
|
||||||
|
|
||||||
// HandleImageDownload starts a template image download in the background.
|
// HandleImageDownload starts a template image download in the background.
|
||||||
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -301,7 +609,6 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
image := kvm.FindImage(req.TemplateID)
|
image := kvm.FindImage(req.TemplateID)
|
||||||
@@ -309,6 +616,14 @@ 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 _, err := config.SelectStoragePoolForContent(config.StorageContentImages, "", 1024*1024*1024); err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||||
ensureImageEnabled(image.ID)
|
ensureImageEnabled(image.ID)
|
||||||
clearImageDownload(image.ID)
|
clearImageDownload(image.ID)
|
||||||
@@ -349,22 +664,79 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !beginLXCImageDownload() {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Another LXC image download is active"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lxcDownloadHandedOff := false
|
||||||
|
defer func() {
|
||||||
|
if !lxcDownloadHandedOff {
|
||||||
|
endLXCImageDownload()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
imagePool, err := config.SelectStoragePoolForContent(
|
||||||
|
config.StorageContentImages,
|
||||||
|
"",
|
||||||
|
dirSizeBytes("/var/cache/lxc/download")+1024*1024*1024,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ensureLXCImageCachePool(*imagePool); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Already downloaded? Just enable if needed.
|
// Already downloaded? Just enable if needed.
|
||||||
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
|
if downloaded, _ := lxcTemplateDownloadedInfo(*tmpl); downloaded {
|
||||||
ensureImageEnabled(tmpl.ID)
|
ensureImageEnabled(tmpl.ID)
|
||||||
clearImageDownload(tmpl.ID)
|
clearImageDownload(tmpl.ID)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, ok := startImageDownload(tmpl.ID, "lxc-create")
|
startStage := "lxc-create"
|
||||||
|
if tmpl.Custom {
|
||||||
|
startStage = "downloading"
|
||||||
|
}
|
||||||
|
ctx, ok := startImageDownload(tmpl.ID, startStage)
|
||||||
if !ok {
|
if !ok {
|
||||||
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tmpl.Custom {
|
||||||
|
go func(tmpl lxc.Template) {
|
||||||
|
defer endLXCImageDownload()
|
||||||
|
err := lxc.DownloadCustomImageWithProgress(ctx, tmpl, func(progress lxc.CustomImageDownloadProgress) {
|
||||||
|
updateImageDownload(tmpl.ID, func(status *imageDownloadStatus) {
|
||||||
|
status.Stage = progress.Stage
|
||||||
|
status.DownloadedBytes = progress.DownloadedBytes
|
||||||
|
status.TotalBytes = progress.TotalBytes
|
||||||
|
status.Progress = progress.Percent
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
|
||||||
|
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
|
||||||
|
finishImageDownload(tmpl.ID, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
finishImageDownload(tmpl.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureImageEnabled(tmpl.ID)
|
||||||
|
finishImageDownload(tmpl.ID, nil)
|
||||||
|
}(*tmpl)
|
||||||
|
lxcDownloadHandedOff = true
|
||||||
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
go func(tmpl lxc.Template) {
|
go func(tmpl lxc.Template) {
|
||||||
|
defer endLXCImageDownload()
|
||||||
// Download via lxc-create with a temp container, then destroy it.
|
// Download via lxc-create with a temp container, then destroy it.
|
||||||
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
tmpName := lxcImageDownloadTempName(tmpl.ID)
|
||||||
args := []string{"-n", tmpName, "-t", "download", "--",
|
args := []string{"-n", tmpName, "-t", "download", "--",
|
||||||
@@ -376,7 +748,7 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
st.Stage = "lxc-create"
|
st.Stage = "lxc-create"
|
||||||
})
|
})
|
||||||
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
cmd := exec.CommandContext(ctx, "lxc-create", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := runLXCImageDownloadCommand(cmd, tmpl.ID)
|
||||||
|
|
||||||
// Clean up the temp container unconditionally.
|
// Clean up the temp container unconditionally.
|
||||||
cleanupLXCImageDownloadTemp(tmpl.ID)
|
cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||||
@@ -393,10 +765,154 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
ensureImageEnabled(tmpl.ID)
|
ensureImageEnabled(tmpl.ID)
|
||||||
finishImageDownload(tmpl.ID, nil)
|
finishImageDownload(tmpl.ID, nil)
|
||||||
}(*tmpl)
|
}(*tmpl)
|
||||||
|
lxcDownloadHandedOff = true
|
||||||
|
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Message: "Download started"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type lxcImageDownloadCommandResult struct {
|
||||||
|
output []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLXCImageDownloadCommand(cmd *exec.Cmd, templateID string) ([]byte, error) {
|
||||||
|
startedAt := time.Now()
|
||||||
|
done := make(chan lxcImageDownloadCommandResult, 1)
|
||||||
|
go func() {
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
done <- lxcImageDownloadCommandResult{output: output, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var lastBytes int64
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case result := <-done:
|
||||||
|
return result.output, result.err
|
||||||
|
case <-ticker.C:
|
||||||
|
downloadedBytes := newestLXCRootfsDownloadSize(startedAt)
|
||||||
|
if downloadedBytes <= 0 || downloadedBytes == lastBytes {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastBytes = downloadedBytes
|
||||||
|
updateImageDownload(templateID, func(st *imageDownloadStatus) {
|
||||||
|
st.Stage = "downloading"
|
||||||
|
st.DownloadedBytes = downloadedBytes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newestLXCRootfsDownloadSize(startedAt time.Time) int64 {
|
||||||
|
matches, _ := filepath.Glob("/tmp/tmp.*/rootfs.tar.xz")
|
||||||
|
var newestTime time.Time
|
||||||
|
var newestSize int64
|
||||||
|
for _, match := range matches {
|
||||||
|
info, err := os.Stat(match)
|
||||||
|
if err != nil || info.IsDir() || info.ModTime().Before(startedAt.Add(-5*time.Second)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.ModTime().After(newestTime) {
|
||||||
|
newestTime = info.ModTime()
|
||||||
|
newestSize = info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newestSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureLXCImageCachePool(pool config.StoragePool) error {
|
||||||
|
lxcImageCacheMu.Lock()
|
||||||
|
defer lxcImageCacheMu.Unlock()
|
||||||
|
|
||||||
|
cachePath := "/var/cache/lxc/download"
|
||||||
|
targetPath := filepath.Join(pool.Path, "images", "lxc")
|
||||||
|
targetAbs, err := filepath.Abs(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(targetAbs, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create LXC image storage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(cachePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Symlink(targetAbs, cachePath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := cachePath
|
||||||
|
linked := info.Mode()&os.ModeSymlink != 0
|
||||||
|
if linked {
|
||||||
|
sourcePath, err = filepath.EvalSymlinks(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve LXC image cache: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceAbs, err := filepath.Abs(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sourceAbs == targetAbs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(targetAbs, sourceAbs+string(os.PathSeparator)) || strings.HasPrefix(sourceAbs, targetAbs+string(os.PathSeparator)) {
|
||||||
|
return fmt.Errorf("LXC image cache source and target must not be nested")
|
||||||
|
}
|
||||||
|
if !info.IsDir() && !linked {
|
||||||
|
return fmt.Errorf("LXC image cache is not a directory: %s", cachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if output, err := exec.Command("cp", "-a", sourceAbs+string(os.PathSeparator)+".", targetAbs+string(os.PathSeparator)).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate LXC image cache: %v, output: %s", err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
tempLink := fmt.Sprintf("%s.clicd-new-%d", cachePath, time.Now().UnixNano())
|
||||||
|
if err := os.Symlink(targetAbs, tempLink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if linked {
|
||||||
|
if err := os.Rename(tempLink, cachePath); err != nil {
|
||||||
|
_ = os.Remove(tempLink)
|
||||||
|
return fmt.Errorf("failed to switch LXC image cache: %v", err)
|
||||||
|
}
|
||||||
|
if isManagedLXCImageCachePath(sourceAbs) {
|
||||||
|
_ = os.RemoveAll(sourceAbs)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
backupPath := fmt.Sprintf("%s.clicd-backup-%d", cachePath, time.Now().UnixNano())
|
||||||
|
if err := os.Rename(cachePath, backupPath); err != nil {
|
||||||
|
_ = os.Remove(tempLink)
|
||||||
|
return fmt.Errorf("failed to prepare LXC image cache migration: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tempLink, cachePath); err != nil {
|
||||||
|
_ = os.Rename(backupPath, cachePath)
|
||||||
|
_ = os.Remove(tempLink)
|
||||||
|
return fmt.Errorf("failed to activate LXC image storage: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(backupPath); err != nil {
|
||||||
|
return fmt.Errorf("LXC image cache migrated but old cache cleanup failed: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isManagedLXCImageCachePath(path string) bool {
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
|
||||||
|
if path == filepath.Clean(filepath.Join(pool.Path, "images", "lxc")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path == filepath.Clean("/var/lib/clicd/images/lxc")
|
||||||
|
}
|
||||||
|
|
||||||
// HandleImageCancel cancels an in-progress image download.
|
// HandleImageCancel cancels an in-progress image download.
|
||||||
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -432,7 +948,12 @@ func HandleImageCancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
os.Remove(kvm.ImagePath(image.ID))
|
os.Remove(kvm.ImagePath(image.ID))
|
||||||
}
|
}
|
||||||
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
|
if tmpl := lxc.FindTemplate(req.TemplateID); tmpl != nil {
|
||||||
go cleanupLXCImageDownloadTemp(tmpl.ID)
|
if tmpl.Custom {
|
||||||
|
_ = os.Remove(lxc.CustomImagePath(tmpl.ID) + ".tmp")
|
||||||
|
_ = os.Remove(lxc.CustomImagePath(tmpl.ID))
|
||||||
|
} else {
|
||||||
|
go cleanupLXCImageDownloadTemp(tmpl.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Cancel requested"})
|
||||||
}
|
}
|
||||||
@@ -473,9 +994,22 @@ func HandleImageDelete(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 tmpl.Custom {
|
||||||
|
if err := lxc.DeleteCustomImage(tmpl.ID); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeImageEnabled(tmpl.ID)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Remove cache directory
|
// Remove cache directory
|
||||||
cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch)
|
cachePath, ok := officialLXCImageCachePath(tmpl.ID)
|
||||||
|
if !ok {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template cache path is not managed by CLICD"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := os.RemoveAll(cachePath); err != nil {
|
if err := os.RemoveAll(cachePath); err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -531,11 +1065,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 +1103,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, _ := lxcTemplateDownloadedInfo(t); 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 +1122,49 @@ 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
|
||||||
|
}
|
||||||
|
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
|
||||||
|
return downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -576,7 +1178,15 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
downloaded, _ := lxcTemplateDownloadedInfo(*tmpl)
|
||||||
|
return enabledSet[tmpl.ID] && downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCustomKVMImageCreateRejectsInvalidSource(t *testing.T) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"name": "Invalid Source",
|
||||||
|
"distro": "ubuntu",
|
||||||
|
"release": "noble",
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"url": "file:///etc/passwd",
|
||||||
|
"provisioner": "linux-cloud-init",
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
HandleCustomKVMImages(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomKVMImageCreateRejectsArchitectureMismatch(t *testing.T) {
|
||||||
|
otherArch := "arm64"
|
||||||
|
if runtime.GOARCH == otherArch {
|
||||||
|
otherArch = "amd64"
|
||||||
|
}
|
||||||
|
payload := map[string]string{
|
||||||
|
"name": "Wrong Architecture",
|
||||||
|
"distro": "ubuntu",
|
||||||
|
"release": "noble",
|
||||||
|
"arch": otherArch,
|
||||||
|
"url": "https://example.test/image.qcow2",
|
||||||
|
"provisioner": "linux-cloud-init",
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
HandleCustomKVMImages(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomLXCImageCreateRejectsInvalidSource(t *testing.T) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"type": "lxc",
|
||||||
|
"name": "Invalid LXC Source",
|
||||||
|
"distro": "alpine",
|
||||||
|
"release": "3.21",
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"url": "file:///tmp/rootfs.tar.xz",
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
HandleCustomKVMImages(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomImageCreateRejectsPrivateNetworkSource(t *testing.T) {
|
||||||
|
for _, imageType := range []string{"lxc", "kvm"} {
|
||||||
|
t.Run(imageType, func(t *testing.T) {
|
||||||
|
payload := map[string]string{
|
||||||
|
"type": imageType,
|
||||||
|
"name": "Private Network Source",
|
||||||
|
"distro": "ubuntu",
|
||||||
|
"release": "noble",
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"url": "http://169.254.169.254/latest/meta-data",
|
||||||
|
"provisioner": "linux-cloud-init",
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/images/custom", bytes.NewReader(body))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
HandleCustomKVMImages(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusBadRequest, response.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfficialLXCImageCachePathUsesAllowlist(t *testing.T) {
|
||||||
|
cachePath, ok := officialLXCImageCachePath("debian-trixie")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("known template cache path was rejected")
|
||||||
|
}
|
||||||
|
normalized := filepath.ToSlash(cachePath)
|
||||||
|
if !strings.Contains(normalized, "/debian/trixie/") {
|
||||||
|
t.Fatalf("cache path = %q, want Debian trixie path", cachePath)
|
||||||
|
}
|
||||||
|
for _, templateID := range []string{
|
||||||
|
"../../../etc",
|
||||||
|
"custom-lxc-attacker",
|
||||||
|
"debian-trixie/../../etc",
|
||||||
|
} {
|
||||||
|
if cachePath, ok := officialLXCImageCachePath(templateID); ok || cachePath != "" {
|
||||||
|
t.Fatalf("officialLXCImageCachePath(%q) = %q, %v; want rejection", templateID, cachePath, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
@@ -22,3 +25,54 @@ func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ipAssignmentRequest struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Auto *bool `json:"auto,omitempty"`
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
|
Addresses []string `json:"addresses,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req ipAssignmentRequest) allocation() ([]string, int, bool) {
|
||||||
|
auto := req.Mode == "random" || req.Mode == "auto"
|
||||||
|
if req.Mode == "custom" {
|
||||||
|
auto = false
|
||||||
|
}
|
||||||
|
if req.Mode == "clear" || req.Mode == "none" {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
if req.Auto != nil {
|
||||||
|
auto = *req.Auto
|
||||||
|
}
|
||||||
|
return req.Addresses, req.Count, auto
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePublicIPv4(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
|
var req ipAssignmentRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addresses, count, auto := req.allocation()
|
||||||
|
c, err := updatePublicIPv4ByRuntime(id, addresses, count, auto)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Public IPv4 assignments updated", Data: c})
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIPv6Addresses(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
|
var req ipAssignmentRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addresses, count, auto := req.allocation()
|
||||||
|
c, err := updateIPv6ByRuntime(id, addresses, count, auto)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assignments updated", Data: c})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
"clicd/internal/kvm"
|
||||||
|
"clicd/internal/lxc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaptureRuntimeRestoreState records which managed workloads are actually
|
||||||
|
// running before the CLICD service exits. On the next host boot, only those
|
||||||
|
// workloads are started again.
|
||||||
|
func CaptureRuntimeRestoreState() {
|
||||||
|
if config.AppConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lxcManager := lxc.NewManager()
|
||||||
|
kvmManager := kvm.NewManager()
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
for i := range config.AppConfig.Containers {
|
||||||
|
c := &config.AppConfig.Containers[i]
|
||||||
|
status, err := runtimeStatus(*c, lxcManager, kvmManager)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to capture runtime state for %s: %v\n", c.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
restore := status == "running"
|
||||||
|
if c.RestoreOnHostBoot != restore {
|
||||||
|
c.RestoreOnHostBoot = restore
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if status != "" && c.Status != status {
|
||||||
|
c.Status = status
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to save host boot restore state: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartHostBootRestore() {
|
||||||
|
go RestoreHostBootState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RestoreHostBootState() {
|
||||||
|
if config.AppConfig == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
lxcManager := lxc.NewManager()
|
||||||
|
kvmManager := kvm.NewManager()
|
||||||
|
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||||
|
|
||||||
|
for _, c := range containers {
|
||||||
|
if !c.RestoreOnHostBoot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.PolicyBlocked {
|
||||||
|
fmt.Printf("Skipping host boot restore for %s: policy blocked\n", c.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lxc.IsExpired(c) {
|
||||||
|
fmt.Printf("Skipping host boot restore for %s: expired at %s\n", c.Name, c.ExpiresAt)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := runtimeStatus(c, lxcManager, kvmManager)
|
||||||
|
if err == nil && status == "running" {
|
||||||
|
config.UpdateContainerStatusAndRestore(c.ID, "running", true)
|
||||||
|
if !c.IsKVM() {
|
||||||
|
_ = lxcManager.ApplyPortMappings(c.ID)
|
||||||
|
} else {
|
||||||
|
_ = lxc.NewManager().ApplyPortMappings(c.ID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Restoring workload after host boot: %s (ID=%d)\n", c.Name, c.ID)
|
||||||
|
if c.IsKVM() {
|
||||||
|
if err := kvmManager.StartContainer(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to restore KVM %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := lxcManager.StartContainer(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to restore LXC %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lxc.EnsureAllRunningPortMappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeStatus(c config.Container, lxcManager *lxc.Manager, kvmManager *kvm.Manager) (string, error) {
|
||||||
|
if c.IsKVM() {
|
||||||
|
return kvmManager.GetContainerStatus(c.VirshName())
|
||||||
|
}
|
||||||
|
return lxcManager.GetContainerStatus(c.LxcName())
|
||||||
|
}
|
||||||
@@ -17,6 +17,16 @@ type routeCapacity struct {
|
|||||||
Total string `json:"total"`
|
Total string `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nat4PortRange struct {
|
||||||
|
Start int `json:"start"`
|
||||||
|
End int `json:"end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nat4Networks struct {
|
||||||
|
LXC config.NATNetwork `json:"lxc"`
|
||||||
|
KVM config.NATNetwork `json:"kvm"`
|
||||||
|
}
|
||||||
|
|
||||||
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 +51,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,20 +76,26 @@ type ipv6Route struct {
|
|||||||
|
|
||||||
type routingResponse struct {
|
type routingResponse struct {
|
||||||
NAT4 routeCapacity `json:"nat4"`
|
NAT4 routeCapacity `json:"nat4"`
|
||||||
|
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||||
|
NAT4NextPort int `json:"nat4_next_port"`
|
||||||
|
NAT4Networks nat4Networks `json:"nat4_networks"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type routingPoolsRequest struct {
|
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 +147,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 +187,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,16 +228,23 @@ 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 {
|
||||||
nat4Remaining = 0
|
nat4Remaining = 0
|
||||||
}
|
}
|
||||||
|
nat4NextPort, _ := config.PreviewSSHPortExcluding(nil)
|
||||||
|
|
||||||
prefixes := lxc.DetectPublicIPv6Prefixes()
|
prefixes := lxc.DetectPublicIPv6Prefixes()
|
||||||
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
hostPublicIPv4 := lxc.DetectPublicIPv4()
|
||||||
@@ -216,11 +266,25 @@ 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,
|
||||||
|
},
|
||||||
|
NAT4NextPort: nat4NextPort,
|
||||||
|
NAT4Networks: nat4Networks{
|
||||||
|
LXC: config.LXCNATNetwork(),
|
||||||
|
KVM: config.KVMNATNetwork(),
|
||||||
|
},
|
||||||
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 +293,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 +311,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 {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -24,3 +25,44 @@ func TestHandleRoutingGetAllowsRoutingWriteScope(t *testing.T) {
|
|||||||
t.Fatal("routing:write scope should be able to receive the routing response after updates")
|
t.Fatal("routing:write scope should be able to receive the routing response after updates")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleRoutingGetReturnsConfiguredNextNATPort(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{
|
||||||
|
NATPortStart: 30000,
|
||||||
|
NATPortEnd: 35000,
|
||||||
|
NextSSHPort: 30000,
|
||||||
|
Containers: []config.Container{{
|
||||||
|
PortMappings: []config.PortMapping{{HostPort: 30000}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil)
|
||||||
|
req = withAuthContext(req, AuthContext{
|
||||||
|
Type: authTypeAPIKey,
|
||||||
|
Scopes: []string{"routing:read"},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleRoutingGet(rec, req)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
NAT4PortRange nat4PortRange `json:"nat4_port_range"`
|
||||||
|
NAT4NextPort int `json:"nat4_next_port"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !response.Success {
|
||||||
|
t.Fatalf("routing response was unsuccessful: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if response.Data.NAT4PortRange.Start != 30000 || response.Data.NAT4PortRange.End != 35000 {
|
||||||
|
t.Fatalf("NAT range = %+v", response.Data.NAT4PortRange)
|
||||||
|
}
|
||||||
|
if response.Data.NAT4NextPort != 30001 {
|
||||||
|
t.Fatalf("next NAT port = %d, want 30001", response.Data.NAT4NextPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -115,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
|||||||
return lxcManager.AssignIPv6(id)
|
return lxcManager.AssignIPv6(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updatePublicIPv4ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
|
||||||
|
}
|
||||||
|
return lxcManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateIPv6ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.UpdateIPv6Assignments(id, requested, count, auto)
|
||||||
|
}
|
||||||
|
return lxcManager.UpdateIPv6Assignments(id, requested, count, auto)
|
||||||
|
}
|
||||||
|
|
||||||
func usageByRuntime(id int) (map[string]interface{}, error) {
|
func usageByRuntime(id int) (map[string]interface{}, error) {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
if c != nil && c.IsKVM() {
|
||||||
@@ -131,12 +147,12 @@ func trafficByRuntime(id int) map[string]interface{} {
|
|||||||
return lxcManager.GetTrafficInfo(id)
|
return lxcManager.GetTrafficInfo(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
if c != nil && c.IsKVM() {
|
if c != nil && c.IsKVM() {
|
||||||
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
|
||||||
}
|
}
|
||||||
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit, storagePoolID...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteSnapshotByRuntime(snapshotID string) error {
|
func deleteSnapshotByRuntime(snapshotID string) error {
|
||||||
|
|||||||
@@ -49,15 +49,19 @@ type connEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type trafficStats struct {
|
type trafficStats struct {
|
||||||
total int
|
total int
|
||||||
totalSynSent int
|
totalSynSent int
|
||||||
destCounts map[string]int
|
destCounts map[string]int
|
||||||
destPorts map[string]map[int]int
|
destPorts map[string]map[int]int
|
||||||
portDestCounts map[int]map[string]int
|
portDestCounts map[int]map[string]int
|
||||||
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
|
||||||
synSentByDst map[string]int
|
udpDestTotalCounts map[string]int
|
||||||
|
synSentByDst map[string]int
|
||||||
|
tcpSynDestPorts map[string]map[int]int
|
||||||
|
tcpSynPortDestCounts map[int]map[string]int
|
||||||
|
tcpSynPortTotalCounts map[int]int
|
||||||
}
|
}
|
||||||
|
|
||||||
var scanner *SecurityScanner
|
var scanner *SecurityScanner
|
||||||
@@ -232,13 +236,17 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
|
|||||||
|
|
||||||
func newTrafficStats() *trafficStats {
|
func newTrafficStats() *trafficStats {
|
||||||
return &trafficStats{
|
return &trafficStats{
|
||||||
destCounts: make(map[string]int),
|
destCounts: make(map[string]int),
|
||||||
destPorts: make(map[string]map[int]int),
|
destPorts: make(map[string]map[int]int),
|
||||||
portDestCounts: make(map[int]map[string]int),
|
portDestCounts: make(map[int]map[string]int),
|
||||||
portTotalCounts: make(map[int]int),
|
portTotalCounts: make(map[int]int),
|
||||||
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{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,6 +49,38 @@ func HandleLanguage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleTaskQueueSettings returns or updates the global task concurrency limit.
|
||||||
|
func HandleTaskQueueSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: globalQueue.Settings()})
|
||||||
|
case http.MethodPut, http.MethodPost:
|
||||||
|
var req struct {
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Concurrency < 1 || req.Concurrency > config.MaxTaskConcurrency {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "任务并发数必须在 1 到 16 之间"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous := config.AppConfig.TaskConcurrency
|
||||||
|
config.AppConfig.TaskConcurrency = req.Concurrency
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
config.AppConfig.TaskConcurrency = previous
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存任务队列设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globalQueue.SetConcurrency(req.Concurrency)
|
||||||
|
auditRequest(r, "settings.task_queue", "task_concurrency", fmt.Sprintf("concurrency=%d", req.Concurrency), true, "")
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "任务队列设置已保存", Data: globalQueue.Settings()})
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
// RecordLoginLog adds a login attempt to the log (persisted to config)
|
||||||
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
func RecordLoginLog(username, ip, userAgent string, success bool) {
|
||||||
config.AddLoginLog(username, ip, userAgent, success)
|
config.AddLoginLog(username, ip, userAgent, success)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -88,6 +89,20 @@ func listContainerSnapshots(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
|
|
||||||
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID int) {
|
||||||
user := requestUser(r)
|
user := requestUser(r)
|
||||||
|
var req struct {
|
||||||
|
StoragePoolID string `json:"storage_pool_id"`
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.StoragePoolID = strings.TrimSpace(req.StoragePoolID)
|
||||||
|
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, req.StoragePoolID, 0); err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if isSubUserRequest(r) {
|
if isSubUserRequest(r) {
|
||||||
c := config.FindContainer(containerID)
|
c := config.FindContainer(containerID)
|
||||||
limit := config.ContainerSnapshotLimit(c)
|
limit := config.ContainerSnapshotLimit(c)
|
||||||
@@ -96,7 +111,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0, req.StoragePoolID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -159,6 +174,12 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
if req.Time == "" {
|
if req.Time == "" {
|
||||||
req.Time = "03:00"
|
req.Time = "03:00"
|
||||||
}
|
}
|
||||||
|
if req.Enabled {
|
||||||
|
if _, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, "", 0); err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
user := requestUser(r)
|
user := requestUser(r)
|
||||||
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
pathpkg "path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storageInfoResponse struct {
|
||||||
|
Pools []storagePoolInfo `json:"pools"`
|
||||||
|
Disks []storageDiskInfo `json:"disks"`
|
||||||
|
ContentTypes []string `json:"content_types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storagePoolInfo struct {
|
||||||
|
config.StoragePool
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
UsedBytes int64 `json:"used_bytes"`
|
||||||
|
FreeBytes int64 `json:"free_bytes"`
|
||||||
|
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
|
||||||
|
ContentUsage []storageContentUsage `json:"content_usage"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storageContentUsage struct {
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storageDiskInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FSType string `json:"fstype"`
|
||||||
|
MountPoint string `json:"mount_point"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
UsedBytes int64 `json:"used_bytes"`
|
||||||
|
FreeBytes int64 `json:"free_bytes"`
|
||||||
|
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||||
|
StoragePath string `json:"storage_path,omitempty"`
|
||||||
|
ClicdUsedBytes int64 `json:"clicd_used_bytes"`
|
||||||
|
ContentUsage []storageContentUsage `json:"content_usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleStorage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
|
||||||
|
case http.MethodPut:
|
||||||
|
var req struct {
|
||||||
|
Pools []config.StoragePool `json:"pools"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pools, err := normalizeStoragePoolsRequest(req.Pools)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pool := range pools {
|
||||||
|
if err := os.MkdirAll(pool.Path, 0755); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: fmt.Sprintf("Failed to create %s: %v", pool.Path, err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.AppConfig.StoragePools = pools
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save storage pools"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: buildStorageInfo()})
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStorageInfo() storageInfoResponse {
|
||||||
|
disks := detectStorageDisks()
|
||||||
|
pools := make([]storagePoolInfo, 0, len(config.AppConfig.StoragePools))
|
||||||
|
for _, pool := range config.AppConfig.StoragePools {
|
||||||
|
info := storagePoolInfo{StoragePool: pool}
|
||||||
|
if filepath.Clean(pool.MountPoint) == string(os.PathSeparator) {
|
||||||
|
_ = os.MkdirAll(pool.Path, 0755)
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(pool.Path); err == nil && st.IsDir() {
|
||||||
|
info.Exists = true
|
||||||
|
} else if err != nil {
|
||||||
|
info.Error = err.Error()
|
||||||
|
}
|
||||||
|
detectedMountPoint := bestMountPointForPath(pool.Path, disks)
|
||||||
|
if info.MountPoint == "" {
|
||||||
|
info.MountPoint = detectedMountPoint
|
||||||
|
}
|
||||||
|
if detectedMountPoint != "" && filepath.Clean(info.MountPoint) == filepath.Clean(detectedMountPoint) {
|
||||||
|
info.Available = info.Exists
|
||||||
|
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(pool.Path)
|
||||||
|
info.ContentUsage, info.ClicdUsedBytes = contentUsageForPool(pool.Path)
|
||||||
|
} else if info.Error == "" {
|
||||||
|
info.Error = "storage disk is not mounted"
|
||||||
|
}
|
||||||
|
pools = append(pools, info)
|
||||||
|
}
|
||||||
|
for i := range disks {
|
||||||
|
for _, pool := range pools {
|
||||||
|
if pool.MountPoint != disks[i].MountPoint {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
disks[i].ClicdUsedBytes += pool.ClicdUsedBytes
|
||||||
|
disks[i].ContentUsage = mergeContentUsage(disks[i].ContentUsage, pool.ContentUsage)
|
||||||
|
if disks[i].StoragePoolID == "" {
|
||||||
|
disks[i].StoragePoolID = pool.ID
|
||||||
|
disks[i].StoragePath = pool.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storageInfoResponse{
|
||||||
|
Pools: pools,
|
||||||
|
Disks: disks,
|
||||||
|
ContentTypes: []string{
|
||||||
|
config.StorageContentLXC,
|
||||||
|
config.StorageContentKVM,
|
||||||
|
config.StorageContentImages,
|
||||||
|
config.StorageContentSnapshots,
|
||||||
|
config.StorageContentBackups,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStoragePoolsRequest(items []config.StoragePool) ([]config.StoragePool, error) {
|
||||||
|
return normalizeStoragePoolsRequestWithDisks(items, detectStorageDisks())
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStoragePoolsRequestWithDisks(items []config.StoragePool, disks []storageDiskInfo) ([]config.StoragePool, error) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one mounted storage disk configuration must be retained")
|
||||||
|
}
|
||||||
|
result := make([]config.StoragePool, 0, len(items))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
defaultSeen := map[string]bool{}
|
||||||
|
for _, item := range items {
|
||||||
|
disk, managedPath, err := storageDiskForPoolRequest(item, disks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, name := storagePoolIdentity(disk)
|
||||||
|
if seen[id] {
|
||||||
|
return nil, fmt.Errorf("duplicate storage disk: %s", disk.MountPoint)
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
|
||||||
|
contentTypes := normalizeStorageContentTypes(item.ContentTypes)
|
||||||
|
defaultContents := normalizeStorageContentTypes(item.DefaultContents)
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, content := range contentTypes {
|
||||||
|
allowed[content] = true
|
||||||
|
}
|
||||||
|
defaults := make([]string, 0, len(defaultContents))
|
||||||
|
for _, content := range defaultContents {
|
||||||
|
if !allowed[content] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if defaultSeen[content] {
|
||||||
|
return nil, fmt.Errorf("only one default storage disk is allowed for %s", content)
|
||||||
|
}
|
||||||
|
defaultSeen[content] = true
|
||||||
|
defaults = append(defaults, content)
|
||||||
|
}
|
||||||
|
result = append(result, config.StoragePool{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Path: managedPath,
|
||||||
|
MountPoint: disk.MountPoint,
|
||||||
|
ContentTypes: contentTypes,
|
||||||
|
DefaultContents: defaults,
|
||||||
|
Enabled: item.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageDiskForPoolRequest(item config.StoragePool, disks []storageDiskInfo) (storageDiskInfo, string, error) {
|
||||||
|
requestedMount := filepath.Clean(strings.TrimSpace(item.MountPoint))
|
||||||
|
if requestedMount == "." {
|
||||||
|
requestedMount = ""
|
||||||
|
}
|
||||||
|
requestedPath := filepath.Clean(strings.TrimSpace(item.Path))
|
||||||
|
if requestedPath == "." {
|
||||||
|
requestedPath = ""
|
||||||
|
}
|
||||||
|
for _, disk := range disks {
|
||||||
|
mountPoint := filepath.Clean(disk.MountPoint)
|
||||||
|
managedPath := managedStoragePath(mountPoint)
|
||||||
|
mountMatches := requestedMount != "" && requestedMount == mountPoint
|
||||||
|
pathMatches := requestedPath != "" && requestedPath == managedPath
|
||||||
|
if !mountMatches && !pathMatches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if requestedMount != "" && !mountMatches {
|
||||||
|
return storageDiskInfo{}, "", fmt.Errorf("storage disk mount point has changed; refresh and try again")
|
||||||
|
}
|
||||||
|
if requestedPath != "" && !pathMatches {
|
||||||
|
return storageDiskInfo{}, "", fmt.Errorf("custom storage paths are not allowed; refresh and try again")
|
||||||
|
}
|
||||||
|
return disk, managedPath, nil
|
||||||
|
}
|
||||||
|
return storageDiskInfo{}, "", fmt.Errorf("storage disk is not mounted or is no longer available")
|
||||||
|
}
|
||||||
|
|
||||||
|
func storagePoolIdentity(disk storageDiskInfo) (string, string) {
|
||||||
|
mountPoint := filepath.Clean(disk.MountPoint)
|
||||||
|
if mountPoint == string(os.PathSeparator) {
|
||||||
|
return "disk-root", "system (/)"
|
||||||
|
}
|
||||||
|
baseName := filepath.Base(mountPoint)
|
||||||
|
if baseName == "" || baseName == "." || baseName == string(os.PathSeparator) {
|
||||||
|
baseName = strings.TrimSpace(disk.Name)
|
||||||
|
}
|
||||||
|
if baseName == "" {
|
||||||
|
baseName = "storage"
|
||||||
|
}
|
||||||
|
devicePath := strings.TrimSpace(disk.Path)
|
||||||
|
if devicePath == "" {
|
||||||
|
devicePath = strings.TrimSpace(disk.Name)
|
||||||
|
}
|
||||||
|
return "disk-" + storageID(baseName), fmt.Sprintf("%s (%s)", baseName, devicePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedStoragePath(mountPoint string) string {
|
||||||
|
if filepath.Clean(mountPoint) == string(os.PathSeparator) {
|
||||||
|
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Clean(mountPoint), "clicd")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStorageContentTypes(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := []string{}
|
||||||
|
for _, value := range values {
|
||||||
|
var next string
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case config.StorageContentLXC:
|
||||||
|
next = config.StorageContentLXC
|
||||||
|
case config.StorageContentKVM:
|
||||||
|
next = config.StorageContentKVM
|
||||||
|
case config.StorageContentImages:
|
||||||
|
next = config.StorageContentImages
|
||||||
|
case config.StorageContentSnapshots:
|
||||||
|
next = config.StorageContentSnapshots
|
||||||
|
case config.StorageContentBackups:
|
||||||
|
next = config.StorageContentBackups
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[next] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[next] = true
|
||||||
|
result = append(result, next)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageID(name string) string {
|
||||||
|
id := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
id = strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-").Replace(id)
|
||||||
|
id = strings.Trim(id, "-")
|
||||||
|
if id == "" {
|
||||||
|
return "storage"
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectStorageDisks() []storageDiskInfo {
|
||||||
|
type lsblkDevice struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FSType string `json:"fstype"`
|
||||||
|
MountPoint string `json:"mountpoint"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ReadOnly bool `json:"ro"`
|
||||||
|
Children []lsblkDevice `json:"children"`
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
BlockDevices []lsblkDevice `json:"blockdevices"`
|
||||||
|
}
|
||||||
|
out, err := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RO").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &payload); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := []storageDiskInfo{}
|
||||||
|
var walk func(lsblkDevice)
|
||||||
|
walk = func(dev lsblkDevice) {
|
||||||
|
info := storageDiskInfo{
|
||||||
|
Name: dev.Name,
|
||||||
|
Path: dev.Path,
|
||||||
|
Type: dev.Type,
|
||||||
|
FSType: dev.FSType,
|
||||||
|
MountPoint: dev.MountPoint,
|
||||||
|
Model: strings.TrimSpace(dev.Model),
|
||||||
|
SizeBytes: dev.Size,
|
||||||
|
}
|
||||||
|
if isUsableStorageMount(dev.Type, dev.FSType, dev.Path, dev.MountPoint, dev.ReadOnly) && !mountIsReadOnly(dev.MountPoint) {
|
||||||
|
info.SizeBytes, info.UsedBytes, info.FreeBytes = dfPath(dev.MountPoint)
|
||||||
|
result = append(result, info)
|
||||||
|
}
|
||||||
|
for _, child := range dev.Children {
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, dev := range payload.BlockDevices {
|
||||||
|
walk(dev)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUsableStorageMount(deviceType, fsType, devicePath, mountPoint string, readOnly bool) bool {
|
||||||
|
if readOnly || strings.TrimSpace(mountPoint) == "" || !strings.HasPrefix(mountPoint, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceType = strings.ToLower(strings.TrimSpace(deviceType))
|
||||||
|
devicePath = strings.ToLower(strings.TrimSpace(devicePath))
|
||||||
|
if deviceType == "loop" || deviceType == "rom" || deviceType == "zram" || strings.HasPrefix(devicePath, "/dev/loop") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fsType = strings.ToLower(strings.TrimSpace(fsType))
|
||||||
|
unsupportedFileSystems := map[string]bool{
|
||||||
|
"": true,
|
||||||
|
"squashfs": true,
|
||||||
|
"iso9660": true,
|
||||||
|
"udf": true,
|
||||||
|
"swap": true,
|
||||||
|
"tmpfs": true,
|
||||||
|
"devtmpfs": true,
|
||||||
|
"overlay": true,
|
||||||
|
"proc": true,
|
||||||
|
"sysfs": true,
|
||||||
|
"cgroup": true,
|
||||||
|
"cgroup2": true,
|
||||||
|
"efivarfs": true,
|
||||||
|
"securityfs": true,
|
||||||
|
}
|
||||||
|
if unsupportedFileSystems[fsType] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
mountPoint = pathpkg.Clean(mountPoint)
|
||||||
|
for _, reserved := range []string{"/snap", "/boot"} {
|
||||||
|
if mountPoint == reserved || strings.HasPrefix(mountPoint, reserved+"/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountIsReadOnly(mountPoint string) bool {
|
||||||
|
out, err := exec.Command("findmnt", "-n", "-o", "OPTIONS", "--target", mountPoint).Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, option := range strings.Split(strings.TrimSpace(string(out)), ",") {
|
||||||
|
if strings.TrimSpace(option) == "ro" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentUsageForPool(poolPath string) ([]storageContentUsage, int64) {
|
||||||
|
mapping := map[string]string{
|
||||||
|
config.StorageContentLXC: "lxc",
|
||||||
|
config.StorageContentKVM: "kvm",
|
||||||
|
config.StorageContentImages: "images",
|
||||||
|
config.StorageContentSnapshots: "snapshots",
|
||||||
|
config.StorageContentBackups: "backups",
|
||||||
|
}
|
||||||
|
result := make([]storageContentUsage, 0, len(mapping))
|
||||||
|
var total int64
|
||||||
|
for _, content := range []string{
|
||||||
|
config.StorageContentLXC,
|
||||||
|
config.StorageContentKVM,
|
||||||
|
config.StorageContentImages,
|
||||||
|
config.StorageContentSnapshots,
|
||||||
|
config.StorageContentBackups,
|
||||||
|
} {
|
||||||
|
size := dirSizeBytes(filepath.Join(poolPath, mapping[content]))
|
||||||
|
result = append(result, storageContentUsage{ContentType: content, SizeBytes: size})
|
||||||
|
total += size
|
||||||
|
}
|
||||||
|
return result, total
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeContentUsage(current []storageContentUsage, next []storageContentUsage) []storageContentUsage {
|
||||||
|
sizes := map[string]int64{}
|
||||||
|
order := []string{}
|
||||||
|
for _, item := range append(current, next...) {
|
||||||
|
if _, ok := sizes[item.ContentType]; !ok {
|
||||||
|
order = append(order, item.ContentType)
|
||||||
|
}
|
||||||
|
sizes[item.ContentType] += item.SizeBytes
|
||||||
|
}
|
||||||
|
result := make([]storageContentUsage, 0, len(order))
|
||||||
|
for _, content := range order {
|
||||||
|
result = append(result, storageContentUsage{ContentType: content, SizeBytes: sizes[content]})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirSizeBytes(path string) int64 {
|
||||||
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||||
|
path = resolved
|
||||||
|
}
|
||||||
|
// Count allocated blocks on this filesystem only. LXC rootfs directories can
|
||||||
|
// contain active mounts such as proc/sys; traversing them is slow and reports
|
||||||
|
// enormous virtual sizes that are not actually occupied by CLICD data.
|
||||||
|
out, err := exec.Command("du", "-skx", path).Output()
|
||||||
|
if err == nil {
|
||||||
|
fields := strings.Fields(string(out))
|
||||||
|
if len(fields) > 0 {
|
||||||
|
var sizeKB int64
|
||||||
|
if _, scanErr := fmt.Sscanf(fields[0], "%d", &sizeKB); scanErr == nil && sizeKB <= (1<<63-1)/1024 {
|
||||||
|
return sizeKB * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
_ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info, statErr := d.Info(); statErr == nil {
|
||||||
|
size += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
func dfPath(path string) (size int64, used int64, free int64) {
|
||||||
|
out, err := exec.Command("df", "-B1", "-P", path).Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
fields := strings.Fields(lines[len(lines)-1])
|
||||||
|
if len(fields) < 6 {
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
fmt.Sscanf(fields[1], "%d", &size)
|
||||||
|
fmt.Sscanf(fields[2], "%d", &used)
|
||||||
|
fmt.Sscanf(fields[3], "%d", &free)
|
||||||
|
return size, used, free
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestMountPointForPath(path string, disks []storageDiskInfo) string {
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
path = pathpkg.Clean(path)
|
||||||
|
best := ""
|
||||||
|
for _, disk := range disks {
|
||||||
|
mp := pathpkg.Clean(strings.ReplaceAll(disk.MountPoint, "\\", "/"))
|
||||||
|
if disk.MountPoint == "" || mp == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches := path == mp
|
||||||
|
if mp == "/" {
|
||||||
|
matches = pathpkg.IsAbs(path)
|
||||||
|
} else if strings.HasPrefix(path, mp+"/") {
|
||||||
|
matches = true
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
if len(mp) > len(best) {
|
||||||
|
best = mp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsUsableStorageMount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
deviceType string
|
||||||
|
fsType string
|
||||||
|
devicePath string
|
||||||
|
mountPoint string
|
||||||
|
readOnly bool
|
||||||
|
wantUsable bool
|
||||||
|
}{
|
||||||
|
{name: "root partition", deviceType: "part", fsType: "ext4", devicePath: "/dev/sda2", mountPoint: "/", wantUsable: true},
|
||||||
|
{name: "mounted data disk", deviceType: "disk", fsType: "xfs", devicePath: "/dev/sdb", mountPoint: "/data", wantUsable: true},
|
||||||
|
{name: "snap loop", deviceType: "loop", fsType: "squashfs", devicePath: "/dev/loop0", mountPoint: "/snap/core20/2105", readOnly: true},
|
||||||
|
{name: "loop without ro flag", deviceType: "loop", fsType: "ext4", devicePath: "/dev/loop7", mountPoint: "/mnt/loop"},
|
||||||
|
{name: "read only disk", deviceType: "part", fsType: "ext4", devicePath: "/dev/sdc1", mountPoint: "/archive", readOnly: true},
|
||||||
|
{name: "optical image", deviceType: "rom", fsType: "iso9660", devicePath: "/dev/sr0", mountPoint: "/media/cdrom"},
|
||||||
|
{name: "efi partition", deviceType: "part", fsType: "vfat", devicePath: "/dev/sda1", mountPoint: "/boot/efi"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := isUsableStorageMount(tt.deviceType, tt.fsType, tt.devicePath, tt.mountPoint, tt.readOnly)
|
||||||
|
if got != tt.wantUsable {
|
||||||
|
t.Fatalf("isUsableStorageMount() = %v, want %v", got, tt.wantUsable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBestMountPointForPath(t *testing.T) {
|
||||||
|
disks := []storageDiskInfo{
|
||||||
|
{Path: "/dev/sda2", MountPoint: "/"},
|
||||||
|
{Path: "/dev/sdb1", MountPoint: "/mnt/clicd-data"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{path: "/var/lib/clicd", want: "/"},
|
||||||
|
{path: "/mnt/clicd-data/clicd", want: "/mnt/clicd-data"},
|
||||||
|
{path: "/mnt/clicd-data", want: "/mnt/clicd-data"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := bestMountPointForPath(tt.path, disks); got != tt.want {
|
||||||
|
t.Fatalf("bestMountPointForPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeStoragePoolsUsesServerManagedPath(t *testing.T) {
|
||||||
|
disks := []storageDiskInfo{
|
||||||
|
{Path: "/dev/sda2", MountPoint: "/"},
|
||||||
|
{Path: "/dev/sdb1", MountPoint: "/mnt/data"},
|
||||||
|
}
|
||||||
|
items := []config.StoragePool{{
|
||||||
|
ID: "disk-data",
|
||||||
|
Name: "data",
|
||||||
|
Path: "/mnt/data/clicd",
|
||||||
|
MountPoint: "/mnt/data",
|
||||||
|
ContentTypes: []string{config.StorageContentLXC},
|
||||||
|
DefaultContents: []string{config.StorageContentLXC},
|
||||||
|
Enabled: true,
|
||||||
|
}}
|
||||||
|
pools, err := normalizeStoragePoolsRequestWithDisks(items, disks)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantPath := filepath.Join(filepath.Clean("/mnt/data"), "clicd")
|
||||||
|
if len(pools) != 1 || pools[0].ID != "disk-data" || pools[0].Name != "data (/dev/sdb1)" || pools[0].Path != wantPath || pools[0].MountPoint != "/mnt/data" {
|
||||||
|
t.Fatalf("unexpected normalized pools: %#v", pools)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeStoragePoolsRejectsUncontrolledPath(t *testing.T) {
|
||||||
|
disks := []storageDiskInfo{{Path: "/dev/sdb1", MountPoint: "/mnt/data"}}
|
||||||
|
for _, path := range []string{"/etc", "/mnt/data/clicd/../../etc", "/mnt/data/other"} {
|
||||||
|
_, err := normalizeStoragePoolsRequestWithDisks([]config.StoragePool{{
|
||||||
|
ID: "disk-data",
|
||||||
|
Name: "data",
|
||||||
|
Path: path,
|
||||||
|
MountPoint: "/mnt/data",
|
||||||
|
Enabled: true,
|
||||||
|
}}, disks)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("path %q was accepted", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirSizeBytesUsesAllocatedBlocks(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("allocated-block behavior is provided by the Linux du command")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
file, err := os.Create(filepath.Join(dir, "sparse.img"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Truncate(1 << 30); err != nil {
|
||||||
|
file.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := dirSizeBytes(dir); got >= 128<<20 {
|
||||||
|
t.Fatalf("dirSizeBytes() = %d, expected allocated size instead of 1 GiB apparent size", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+232
-43
@@ -22,24 +22,30 @@ func generateRandomStr(length int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type subUserResponse struct {
|
type subUserResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
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"`
|
||||||
AccessCode string `json:"access_code"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
|
CurrentImageIDs []string `json:"current_image_ids,omitempty"`
|
||||||
|
AccessCode string `json:"access_code"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
func newSubUserResponse(su config.SubUser, password string) subUserResponse {
|
||||||
return subUserResponse{
|
return subUserResponse{
|
||||||
ID: su.ID,
|
ID: su.ID,
|
||||||
Username: su.Username,
|
Username: su.Username,
|
||||||
Password: password,
|
Password: password,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
AccessCode: su.AccessCode,
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
CreatedAt: su.CreatedAt,
|
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||||
|
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||||
|
AccessCode: su.AccessCode,
|
||||||
|
CreatedAt: su.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +100,10 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
su.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,
|
||||||
@@ -114,14 +124,16 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
accessCode := generateRandomStr(8)
|
accessCode := generateRandomStr(8)
|
||||||
|
|
||||||
subUser := config.SubUser{
|
subUser := config.SubUser{
|
||||||
ID: "sub-" + generateRandomStr(8),
|
ID: "sub-" + generateRandomStr(8),
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
PassHash: string(hash),
|
PassHash: string(hash),
|
||||||
ContainerNames: []string{containerName},
|
ContainerNames: []string{containerName},
|
||||||
ContainerUUIDs: []string{c.UUID},
|
ContainerUUIDs: []string{c.UUID},
|
||||||
AccessCode: accessCode,
|
AllowedImageIDs: effectiveContainerAllowedImageIDs(c),
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
ImageLimitConfigured: true,
|
||||||
|
AccessCode: accessCode,
|
||||||
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
|
|
||||||
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
|
||||||
@@ -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
|
||||||
@@ -580,18 +741,21 @@ func splitBy(s, sep string) []string {
|
|||||||
|
|
||||||
// SubUserListItem is the enriched sub-user info returned by the list API
|
// SubUserListItem is the enriched sub-user info returned by the list API
|
||||||
type SubUserListItem struct {
|
type SubUserListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
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"`
|
||||||
ContainerName string `json:"container_name"`
|
AllowedImageIDs []string `json:"allowed_image_ids"`
|
||||||
ContainerUUID string `json:"container_uuid"`
|
ImageLimitConfigured bool `json:"image_limit_configured"`
|
||||||
AccessCode string `json:"access_code"`
|
CurrentImageIDs []string `json:"current_image_ids"`
|
||||||
Password string `json:"password,omitempty"`
|
ContainerName string `json:"container_name"`
|
||||||
CreatedAt string `json:"created_at"`
|
ContainerUUID string `json:"container_uuid"`
|
||||||
LastLogin string `json:"last_login"`
|
AccessCode string `json:"access_code"`
|
||||||
LastLoginIP string `json:"last_login_ip"`
|
Password string `json:"password,omitempty"`
|
||||||
LastLoginUA string `json:"last_login_ua"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastLogin string `json:"last_login"`
|
||||||
|
LastLoginIP string `json:"last_login_ip"`
|
||||||
|
LastLoginUA string `json:"last_login_ua"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSubUserList returns the list of all sub-users with container info
|
// HandleSubUserList returns the list of all sub-users with container info
|
||||||
@@ -607,13 +771,16 @@ func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
|||||||
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||||
for _, su := range config.AppConfig.SubUsers {
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
item := SubUserListItem{
|
item := SubUserListItem{
|
||||||
ID: su.ID,
|
ID: su.ID,
|
||||||
Username: su.Username,
|
Username: su.Username,
|
||||||
ContainerNames: su.ContainerNames,
|
ContainerNames: su.ContainerNames,
|
||||||
ContainerUUIDs: su.ContainerUUIDs,
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
AccessCode: su.AccessCode,
|
AllowedImageIDs: effectiveSubUserAllowedImageIDs(&su),
|
||||||
Password: su.Password,
|
ImageLimitConfigured: su.ImageLimitConfigured,
|
||||||
CreatedAt: su.CreatedAt,
|
CurrentImageIDs: subUserCurrentImageIDs(&su),
|
||||||
|
AccessCode: su.AccessCode,
|
||||||
|
Password: su.Password,
|
||||||
|
CreatedAt: su.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve container name from first active UUID
|
// Resolve container name from first active UUID
|
||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|||||||
+418
-172
@@ -30,6 +30,8 @@ type Task struct {
|
|||||||
ContainerName string `json:"container_name"`
|
ContainerName string `json:"container_name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Stage string `json:"stage,omitempty"`
|
||||||
|
StageDetail string `json:"stage_detail,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
TemplateID string `json:"template_id,omitempty"`
|
TemplateID string `json:"template_id,omitempty"`
|
||||||
Config lxc.ContainerConfig `json:"config,omitempty"`
|
Config lxc.ContainerConfig `json:"config,omitempty"`
|
||||||
@@ -37,30 +39,74 @@ type Task struct {
|
|||||||
User string `json:"user,omitempty"` // who created this task
|
User string `json:"user,omitempty"` // who created this task
|
||||||
IP string `json:"ip,omitempty"`
|
IP string `json:"ip,omitempty"`
|
||||||
UserAgent string `json:"user_agent,omitempty"`
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
activeKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskQueue struct {
|
type TaskQueue struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
createQueue []*Task
|
createQueue []*Task
|
||||||
opQueue []*Task
|
opQueue []*Task
|
||||||
tasks map[string]*Task
|
tasks map[string]*Task
|
||||||
nextID int
|
nextID int
|
||||||
createCond *sync.Cond
|
createCond *sync.Cond
|
||||||
opCond *sync.Cond
|
opCond *sync.Cond
|
||||||
stop chan struct{}
|
maxConcurrency int
|
||||||
|
activeTasks int
|
||||||
|
activeTargets map[string]bool
|
||||||
|
stop chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskQueueSettings struct {
|
||||||
|
Concurrency int `json:"concurrency"`
|
||||||
|
Active int `json:"active"`
|
||||||
|
Pending int `json:"pending"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var globalQueue *TaskQueue
|
var globalQueue *TaskQueue
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
globalQueue = &TaskQueue{
|
globalQueue = newTaskQueue(config.DefaultTaskConcurrency)
|
||||||
tasks: make(map[string]*Task),
|
go globalQueue.createDispatcher()
|
||||||
stop: make(chan struct{}),
|
go globalQueue.opDispatcher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTaskQueue(concurrency int) *TaskQueue {
|
||||||
|
q := &TaskQueue{
|
||||||
|
tasks: make(map[string]*Task),
|
||||||
|
maxConcurrency: config.NormalizeTaskConcurrency(concurrency),
|
||||||
|
activeTargets: make(map[string]bool),
|
||||||
|
stop: make(chan struct{}),
|
||||||
}
|
}
|
||||||
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
|
q.createCond = sync.NewCond(&q.mu)
|
||||||
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
|
q.opCond = sync.NewCond(&q.mu)
|
||||||
go globalQueue.createWorker()
|
return q
|
||||||
go globalQueue.opWorker()
|
}
|
||||||
|
|
||||||
|
func ConfigureTaskQueue(concurrency int) {
|
||||||
|
globalQueue.SetConcurrency(concurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) SetConcurrency(concurrency int) {
|
||||||
|
q.mu.Lock()
|
||||||
|
q.maxConcurrency = config.NormalizeTaskConcurrency(concurrency)
|
||||||
|
q.createCond.Broadcast()
|
||||||
|
q.opCond.Broadcast()
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) Settings() TaskQueueSettings {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return TaskQueueSettings{
|
||||||
|
Concurrency: q.maxConcurrency,
|
||||||
|
Active: q.activeTasks,
|
||||||
|
Pending: len(q.createQueue) + len(q.opQueue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) signalDispatchers() {
|
||||||
|
q.createCond.Broadcast()
|
||||||
|
q.opCond.Broadcast()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) enqueueTask(task *Task) {
|
func (q *TaskQueue) enqueueTask(task *Task) {
|
||||||
@@ -90,6 +136,8 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
|
|||||||
ContainerID: containerID,
|
ContainerID: containerID,
|
||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
TemplateID: templateID,
|
TemplateID: templateID,
|
||||||
User: user,
|
User: user,
|
||||||
@@ -172,6 +220,8 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
|
|||||||
ContainerID: 0,
|
ContainerID: 0,
|
||||||
ContainerName: cfgCopy.Name,
|
ContainerName: cfgCopy.Name,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
Config: cfgCopy,
|
Config: cfgCopy,
|
||||||
User: user,
|
User: user,
|
||||||
@@ -202,6 +252,8 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
|
|||||||
ContainerID: containerID,
|
ContainerID: containerID,
|
||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
TemplateID: templateID,
|
TemplateID: templateID,
|
||||||
User: user,
|
User: user,
|
||||||
@@ -213,6 +265,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,167 +286,285 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
|
|||||||
return taskID, true
|
return taskID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
|
func (q *TaskQueue) CancelPendingSecurityStops() int {
|
||||||
// If a restored task already has a same-name container in config, it resumes
|
q.mu.Lock()
|
||||||
// initialization instead of creating another ct-{id}.
|
defer q.mu.Unlock()
|
||||||
func (q *TaskQueue) createWorker() {
|
|
||||||
for {
|
|
||||||
q.mu.Lock()
|
|
||||||
for len(q.createQueue) == 0 {
|
|
||||||
q.createCond.Wait()
|
|
||||||
}
|
|
||||||
task := q.createQueue[0]
|
|
||||||
q.createQueue = q.createQueue[1:]
|
|
||||||
task.Status = "running"
|
|
||||||
q.mu.Unlock()
|
|
||||||
|
|
||||||
createdByTask := false
|
cancelled := 0
|
||||||
if task.Config.Name == "" {
|
newOpQueue := make([]*Task, 0, len(q.opQueue))
|
||||||
task.Config.Name = task.ContainerName
|
for _, task := range q.opQueue {
|
||||||
}
|
if isSecurityStopTask(task) && task.Status == "pending" {
|
||||||
task.Config.NormalizeResourceAliases()
|
delete(q.tasks, task.ID)
|
||||||
if task.Config.Name == "" {
|
cancelled++
|
||||||
task.Status = "failed"
|
|
||||||
task.Error = "container name is required"
|
|
||||||
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
|
|
||||||
q.mu.Lock()
|
|
||||||
q.persistTasks()
|
|
||||||
q.mu.Unlock()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := config.FindContainerByName(task.Config.Name)
|
newOpQueue = append(newOpQueue, task)
|
||||||
if c == nil {
|
}
|
||||||
// 1) Download image + apply limits (lxc-create)
|
q.opQueue = newOpQueue
|
||||||
err := createByRuntime(task.Config)
|
|
||||||
if err != nil {
|
|
||||||
task.Status = "failed"
|
|
||||||
task.Error = err.Error()
|
|
||||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
|
|
||||||
q.mu.Lock()
|
|
||||||
q.persistTasks()
|
|
||||||
q.mu.Unlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
createdByTask = true
|
|
||||||
|
|
||||||
// 2) Find created container by name
|
for id, task := range q.tasks {
|
||||||
c = config.FindContainerByName(task.Config.Name)
|
if isSecurityStopTask(task) && task.Status == "pending" {
|
||||||
if c == nil {
|
delete(q.tasks, id)
|
||||||
task.Status = "failed"
|
cancelled++
|
||||||
task.Error = "created but not found in config"
|
|
||||||
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
|
|
||||||
q.mu.Lock()
|
|
||||||
q.persistTasks()
|
|
||||||
q.mu.Unlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
task.ContainerID = c.ID
|
if cancelled > 0 {
|
||||||
task.ContainerName = c.Name
|
|
||||||
|
|
||||||
// 3) Start + initialize SSH/network in the same worker.
|
|
||||||
// If init fails, destroy the container so no dead entry remains.
|
|
||||||
startErr := startByRuntime(c.ID)
|
|
||||||
if startErr != nil {
|
|
||||||
if createdByTask {
|
|
||||||
_ = destroyByRuntime(c.ID)
|
|
||||||
}
|
|
||||||
task.Status = "failed"
|
|
||||||
task.Error = startErr.Error()
|
|
||||||
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
|
|
||||||
} else {
|
|
||||||
task.Status = "done"
|
|
||||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
|
|
||||||
}
|
|
||||||
|
|
||||||
q.mu.Lock()
|
|
||||||
q.persistTasks()
|
q.persistTasks()
|
||||||
q.mu.Unlock()
|
}
|
||||||
|
return cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two dispatchers keep long-running creates from blocking power operations,
|
||||||
|
// while sharing one global concurrency budget.
|
||||||
|
func (q *TaskQueue) createDispatcher() {
|
||||||
|
for {
|
||||||
|
task := q.takeNextTask(true)
|
||||||
|
go q.runCreateTask(task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
|
func (q *TaskQueue) opDispatcher() {
|
||||||
// including the follow-up initialization after a create succeeds.
|
|
||||||
func (q *TaskQueue) opWorker() {
|
|
||||||
for {
|
for {
|
||||||
q.mu.Lock()
|
task := q.takeNextTask(false)
|
||||||
for len(q.opQueue) == 0 {
|
go q.runOperationTask(task)
|
||||||
q.opCond.Wait()
|
|
||||||
}
|
|
||||||
task := q.opQueue[0]
|
|
||||||
q.opQueue = q.opQueue[1:]
|
|
||||||
task.Status = "running"
|
|
||||||
q.mu.Unlock()
|
|
||||||
|
|
||||||
var err error
|
|
||||||
err = resolveTaskContainer(task)
|
|
||||||
// Block operations on expired or traffic-exceeded containers (except stop/delete)
|
|
||||||
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
|
||||||
c := config.FindContainer(task.ContainerID)
|
|
||||||
if c != nil {
|
|
||||||
if lxc.IsExpired(*c) {
|
|
||||||
err = fmt.Errorf("容器已到期,不允许此操作")
|
|
||||||
} else if lxc.IsTrafficExceeded(*c) {
|
|
||||||
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
switch task.Type {
|
|
||||||
case TaskStart:
|
|
||||||
err = startByRuntime(task.ContainerID)
|
|
||||||
case TaskStop:
|
|
||||||
err = stopByRuntime(task.ContainerID)
|
|
||||||
case TaskRestart:
|
|
||||||
err = restartByRuntime(task.ContainerID)
|
|
||||||
case TaskDelete:
|
|
||||||
err = destroyByRuntime(task.ContainerID)
|
|
||||||
if err == nil {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
if config.FindContainer(task.ContainerID) != nil {
|
|
||||||
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case TaskReinstall:
|
|
||||||
if lxc.HasSSHAuthOptions(task.Config) {
|
|
||||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
|
||||||
} else {
|
|
||||||
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
q.mu.Lock()
|
|
||||||
auditUser := task.User
|
|
||||||
if auditUser == "" {
|
|
||||||
auditUser = "admin"
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
task.Status = "failed"
|
|
||||||
task.Error = err.Error()
|
|
||||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
|
||||||
} else {
|
|
||||||
task.Status = "done"
|
|
||||||
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
|
||||||
switch task.Type {
|
|
||||||
case TaskStart:
|
|
||||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
|
||||||
clearPolicyBlockAfterAdminRecovery(task)
|
|
||||||
case TaskStop:
|
|
||||||
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
|
||||||
case TaskRestart:
|
|
||||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
|
||||||
clearPolicyBlockAfterAdminRecovery(task)
|
|
||||||
case TaskReinstall:
|
|
||||||
clearPolicyBlockAfterAdminRecovery(task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
q.persistTasks()
|
|
||||||
q.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) takeNextTask(create bool) *Task {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
cond := q.opCond
|
||||||
|
if create {
|
||||||
|
cond = q.createCond
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
queue := q.opQueue
|
||||||
|
if create {
|
||||||
|
queue = q.createQueue
|
||||||
|
}
|
||||||
|
if q.activeTasks < q.maxConcurrency {
|
||||||
|
if index := runnableTaskIndex(queue, q.activeTargets); index >= 0 {
|
||||||
|
task := queue[index]
|
||||||
|
queue = append(queue[:index], queue[index+1:]...)
|
||||||
|
if create {
|
||||||
|
q.createQueue = queue
|
||||||
|
} else {
|
||||||
|
q.opQueue = queue
|
||||||
|
}
|
||||||
|
task.Status = "running"
|
||||||
|
task.Error = ""
|
||||||
|
task.Stage = "preparing"
|
||||||
|
task.StageDetail = "准备初始化环境"
|
||||||
|
task.activeKey = taskConcurrencyKey(task)
|
||||||
|
q.activeTargets[task.activeKey] = true
|
||||||
|
q.activeTasks++
|
||||||
|
q.persistTasks()
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cond.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runnableTaskIndex(queue []*Task, activeTargets map[string]bool) int {
|
||||||
|
for index, task := range queue {
|
||||||
|
if !activeTargets[taskConcurrencyKey(task)] {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskConcurrencyKey(task *Task) string {
|
||||||
|
if task == nil {
|
||||||
|
return "task:nil"
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(task.ContainerName)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(task.Config.Name)
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
return "name:" + strings.ToLower(name)
|
||||||
|
}
|
||||||
|
if task.ContainerID > 0 {
|
||||||
|
return fmt.Sprintf("id:%d", task.ContainerID)
|
||||||
|
}
|
||||||
|
return "task:" + task.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) finishTask(task *Task, status string, taskErr error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
task.Status = status
|
||||||
|
if taskErr != nil {
|
||||||
|
task.Error = taskErr.Error()
|
||||||
|
if task.Type == TaskCreate {
|
||||||
|
task.Stage = "failed"
|
||||||
|
task.StageDetail = "初始化失败"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
task.Error = ""
|
||||||
|
if task.Type == TaskCreate {
|
||||||
|
task.Stage = "completed"
|
||||||
|
task.StageDetail = "初始化完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task.activeKey != "" {
|
||||||
|
delete(q.activeTargets, task.activeKey)
|
||||||
|
task.activeKey = ""
|
||||||
|
}
|
||||||
|
if q.activeTasks > 0 {
|
||||||
|
q.activeTasks--
|
||||||
|
}
|
||||||
|
q.persistTasks()
|
||||||
|
q.signalDispatchers()
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) updateTaskStage(task *Task, stage, detail string) {
|
||||||
|
q.mu.Lock()
|
||||||
|
task.Stage = stage
|
||||||
|
task.StageDetail = detail
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCreateTask handles lxc-create, resource setup, start, and SSH init. A
|
||||||
|
// restored task resumes initialization when the same-name container exists.
|
||||||
|
func (q *TaskQueue) runCreateTask(task *Task) {
|
||||||
|
q.mu.Lock()
|
||||||
|
createdByTask := false
|
||||||
|
if task.Config.Name == "" {
|
||||||
|
task.Config.Name = task.ContainerName
|
||||||
|
}
|
||||||
|
task.Config.NormalizeResourceAliases()
|
||||||
|
cfg := task.Config
|
||||||
|
q.mu.Unlock()
|
||||||
|
cfg.Progress = func(stage, detail string) {
|
||||||
|
q.updateTaskStage(task, stage, detail)
|
||||||
|
}
|
||||||
|
if cfg.Name == "" {
|
||||||
|
err := fmt.Errorf("container name is required")
|
||||||
|
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+err.Error(), "admin")
|
||||||
|
q.finishTask(task, "failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := config.FindContainerByName(cfg.Name)
|
||||||
|
if c == nil {
|
||||||
|
if err := createByRuntime(cfg); err != nil {
|
||||||
|
config.AddAuditLog(string(task.Type), cfg.Name, "失败: "+err.Error(), "admin")
|
||||||
|
q.finishTask(task, "failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createdByTask = true
|
||||||
|
c = config.FindContainerByName(cfg.Name)
|
||||||
|
if c == nil {
|
||||||
|
err := fmt.Errorf("created but not found in config")
|
||||||
|
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
|
||||||
|
q.finishTask(task, "failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lxc.ReleaseQueuedCreateNATPorts(cfg.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.mu.Lock()
|
||||||
|
task.ContainerID = c.ID
|
||||||
|
task.ContainerName = c.Name
|
||||||
|
q.mu.Unlock()
|
||||||
|
startDetail := "启动容器并等待网络就绪"
|
||||||
|
if strings.EqualFold(cfg.Virtualization, config.VirtualizationKVM) {
|
||||||
|
startDetail = "启动虚拟机并等待网络就绪"
|
||||||
|
}
|
||||||
|
q.updateTaskStage(task, "starting", startDetail)
|
||||||
|
if err := startByRuntime(c.ID); err != nil {
|
||||||
|
if createdByTask {
|
||||||
|
_ = destroyByRuntime(c.ID)
|
||||||
|
}
|
||||||
|
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+err.Error(), "admin")
|
||||||
|
q.finishTask(task, "failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
|
||||||
|
q.finishTask(task, "done", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) runOperationTask(task *Task) {
|
||||||
|
q.mu.Lock()
|
||||||
|
err := resolveTaskContainer(task)
|
||||||
|
q.mu.Unlock()
|
||||||
|
skipped := false
|
||||||
|
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
|
||||||
|
c := config.FindContainer(task.ContainerID)
|
||||||
|
if c != nil {
|
||||||
|
if lxc.IsExpired(*c) {
|
||||||
|
err = fmt.Errorf("容器已到期,不允许此操作")
|
||||||
|
} else if lxc.IsTrafficExceeded(*c) {
|
||||||
|
err = fmt.Errorf("容器流量已超限,不允许此操作")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
|
||||||
|
skipped = true
|
||||||
|
}
|
||||||
|
if err == nil && !skipped {
|
||||||
|
switch task.Type {
|
||||||
|
case TaskStart:
|
||||||
|
err = startByRuntime(task.ContainerID)
|
||||||
|
case TaskStop:
|
||||||
|
err = stopByRuntime(task.ContainerID)
|
||||||
|
case TaskRestart:
|
||||||
|
err = restartByRuntime(task.ContainerID)
|
||||||
|
case TaskDelete:
|
||||||
|
err = destroyByRuntime(task.ContainerID)
|
||||||
|
if err == nil {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
if config.FindContainer(task.ContainerID) != nil {
|
||||||
|
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case TaskReinstall:
|
||||||
|
if lxc.HasSSHAuthOptions(task.Config) {
|
||||||
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
|
||||||
|
} else {
|
||||||
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auditUser := task.User
|
||||||
|
if auditUser == "" {
|
||||||
|
auditUser = "admin"
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||||
|
q.finishTask(task, "failed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if skipped {
|
||||||
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
|
||||||
|
q.finishTask(task, "done", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||||
|
switch task.Type {
|
||||||
|
case TaskStart:
|
||||||
|
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
|
case TaskStop:
|
||||||
|
config.UpdateContainerStatus(task.ContainerID, "stopped")
|
||||||
|
case TaskRestart:
|
||||||
|
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
|
case TaskReinstall:
|
||||||
|
clearPolicyBlockAfterAdminRecovery(task)
|
||||||
|
}
|
||||||
|
q.finishTask(task, "done", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSecurityStopTask(task *Task) bool {
|
||||||
|
return task != nil && task.Type == TaskStop && task.User == "system:security"
|
||||||
|
}
|
||||||
|
|
||||||
func clearPolicyBlockAfterAdminRecovery(task *Task) {
|
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
|
||||||
@@ -458,7 +632,8 @@ func (q *TaskQueue) GetTasks() []*Task {
|
|||||||
result := make([]*Task, 0, len(q.tasks))
|
result := make([]*Task, 0, len(q.tasks))
|
||||||
// Collect all task IDs, sort by creation time (extracted from ID number)
|
// Collect all task IDs, sort by creation time (extracted from ID number)
|
||||||
for _, t := range q.tasks {
|
for _, t := range q.tasks {
|
||||||
result = append(result, t)
|
copyTask := *t
|
||||||
|
result = append(result, ©Task)
|
||||||
}
|
}
|
||||||
// Stable sort by ID number (task-N where N is sequential)
|
// Stable sort by ID number (task-N where N is sequential)
|
||||||
for i := 0; i < len(result); i++ {
|
for i := 0; i < len(result); i++ {
|
||||||
@@ -515,7 +690,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
|
||||||
}
|
}
|
||||||
@@ -573,6 +752,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
activeCreateNames := globalQueue.ActiveCreateNames()
|
activeCreateNames := globalQueue.ActiveCreateNames()
|
||||||
requestNames := make(map[string]bool)
|
requestNames := make(map[string]bool)
|
||||||
|
requestNATPorts := make(map[string]string)
|
||||||
for i := range req.Containers {
|
for i := range req.Containers {
|
||||||
name := strings.TrimSpace(req.Containers[i].Name)
|
name := strings.TrimSpace(req.Containers[i].Name)
|
||||||
req.Containers[i].Name = name
|
req.Containers[i].Name = name
|
||||||
@@ -601,25 +781,57 @@ 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
|
||||||
}
|
}
|
||||||
if req.Containers[i].DiskGB < 1 {
|
if req.Containers[i].DiskGB < 1 {
|
||||||
req.Containers[i].DiskGB = 5
|
req.Containers[i].DiskGB = 5
|
||||||
}
|
}
|
||||||
|
if err := validateCreateStoragePool(&req.Containers[i]); err != nil {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
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
|
||||||
}
|
}
|
||||||
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
|
if err := req.Containers[i].NormalizeCreateNATMappings(); err != nil {
|
||||||
req.Containers[i].PortMappingCount = 2
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
} else if !req.Containers[i].WantsNAT() {
|
return
|
||||||
req.Containers[i].PortMappingCount = 0
|
}
|
||||||
req.Containers[i].ExtraPorts = nil
|
if err := lxc.ValidateCreateNATPortAvailability(req.Containers[i]); err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Containers[i].ManagementPort > 0 {
|
||||||
|
key := fmt.Sprintf("%d/tcp", req.Containers[i].ManagementPort)
|
||||||
|
if owner := requestNATPorts[key]; owner != "" {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT management port %s is also requested by %s", name, key, owner)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestNATPorts[key] = name
|
||||||
|
}
|
||||||
|
for _, mapping := range req.Containers[i].NATPortMappings {
|
||||||
|
key := fmt.Sprintf("%d/%s", mapping.HostPort, mapping.Protocol)
|
||||||
|
if owner := requestNATPorts[key]; owner != "" {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: fmt.Sprintf("%s: NAT host port %s is also requested by %s", name, key, owner)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestNATPorts[key] = name
|
||||||
}
|
}
|
||||||
if req.Containers[i].PortMappingCount > 64 {
|
if req.Containers[i].PortMappingCount > 64 {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
|
||||||
@@ -656,7 +868,12 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
requestNames[name] = true
|
requestNames[name] = true
|
||||||
}
|
}
|
||||||
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
|
planned, err := lxc.ReserveBatchCreateNATPorts(req.Containers)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := globalQueue.EnqueueBatchCreateWithAudit(planned, requestActor(r), clientIP(r), r.UserAgent())
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,6 +949,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()})
|
||||||
@@ -774,7 +995,8 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
globalQueue.mu.Lock()
|
globalQueue.mu.Lock()
|
||||||
if task := globalQueue.tasks[taskID]; task != nil && !isTaskAllowedForRequest(r, task) {
|
task := globalQueue.tasks[taskID]
|
||||||
|
if task != nil && !isTaskAllowedForRequest(r, task) {
|
||||||
globalQueue.mu.Unlock()
|
globalQueue.mu.Unlock()
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this task"})
|
||||||
return
|
return
|
||||||
@@ -797,6 +1019,9 @@ func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
globalQueue.opQueue = newOp
|
globalQueue.opQueue = newOp
|
||||||
globalQueue.persistTasks()
|
globalQueue.persistTasks()
|
||||||
globalQueue.mu.Unlock()
|
globalQueue.mu.Unlock()
|
||||||
|
if task != nil && task.Type == TaskCreate {
|
||||||
|
lxc.ReleaseQueuedCreateNATPorts(task.Config.Name)
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +1041,12 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// RestoreTasks restores task queue from config
|
// RestoreTasks restores task queue from config
|
||||||
func RestoreTasks() {
|
func RestoreTasks() {
|
||||||
|
globalQueue.mu.Lock()
|
||||||
|
defer globalQueue.mu.Unlock()
|
||||||
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)
|
||||||
@@ -842,6 +1072,8 @@ func RestoreTasks() {
|
|||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
Status: st.Status,
|
Status: st.Status,
|
||||||
Error: st.Error,
|
Error: st.Error,
|
||||||
|
Stage: "queued",
|
||||||
|
StageDetail: "排队等待",
|
||||||
CreatedAt: st.CreatedAt,
|
CreatedAt: st.CreatedAt,
|
||||||
TemplateID: st.TemplateID,
|
TemplateID: st.TemplateID,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -871,3 +1103,17 @@ func parseIDNum(id string) int {
|
|||||||
}
|
}
|
||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateCreateStoragePool(cfg *lxc.ContainerConfig) error {
|
||||||
|
required := config.StorageContentLXC
|
||||||
|
if cfg.Virtualization == config.VirtualizationKVM {
|
||||||
|
required = config.StorageContentKVM
|
||||||
|
}
|
||||||
|
requiredBytes := int64(cfg.DiskGB) * 1024 * 1024 * 1024
|
||||||
|
pool, err := config.SelectStoragePoolForContent(required, cfg.StoragePoolID, requiredBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.StoragePoolID = pool.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
"clicd/internal/lxc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunnableTaskIndexSkipsActiveContainer(t *testing.T) {
|
||||||
|
queue := []*Task{
|
||||||
|
{ID: "task-1", Type: TaskStop, ContainerID: 1, ContainerName: "alpha"},
|
||||||
|
{ID: "task-2", Type: TaskStart, ContainerID: 1, ContainerName: "alpha"},
|
||||||
|
{ID: "task-3", Type: TaskStart, ContainerID: 2, ContainerName: "beta"},
|
||||||
|
}
|
||||||
|
active := map[string]bool{taskConcurrencyKey(queue[0]): true}
|
||||||
|
|
||||||
|
if got := runnableTaskIndex(queue[1:], active); got != 1 {
|
||||||
|
t.Fatalf("runnableTaskIndex() = %d, want 1 for the other container", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskConcurrencyKeyUsesContainerName(t *testing.T) {
|
||||||
|
create := &Task{ID: "task-1", Type: TaskCreate, Config: lxcConfigWithName("Example")}
|
||||||
|
operation := &Task{ID: "task-2", Type: TaskDelete, ContainerID: 9, ContainerName: "example"}
|
||||||
|
if taskConcurrencyKey(create) != taskConcurrencyKey(operation) {
|
||||||
|
t.Fatalf("same container received different concurrency keys: %q and %q", taskConcurrencyKey(create), taskConcurrencyKey(operation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskQueueSetConcurrencyNormalizesAndReports(t *testing.T) {
|
||||||
|
q := newTaskQueue(config.DefaultTaskConcurrency)
|
||||||
|
q.SetConcurrency(config.MaxTaskConcurrency + 10)
|
||||||
|
if got := q.Settings().Concurrency; got != config.MaxTaskConcurrency {
|
||||||
|
t.Fatalf("concurrency = %d, want %d", got, config.MaxTaskConcurrency)
|
||||||
|
}
|
||||||
|
q.SetConcurrency(0)
|
||||||
|
if got := q.Settings().Concurrency; got != config.DefaultTaskConcurrency {
|
||||||
|
t.Fatalf("concurrency = %d, want default %d", got, config.DefaultTaskConcurrency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskQueueUpdateTaskStage(t *testing.T) {
|
||||||
|
q := newTaskQueue(config.DefaultTaskConcurrency)
|
||||||
|
task := &Task{ID: "task-1", Type: TaskCreate, Status: "running"}
|
||||||
|
|
||||||
|
q.updateTaskStage(task, "rootfs", "下载模板并创建基础文件系统")
|
||||||
|
|
||||||
|
if task.Stage != "rootfs" || task.StageDetail != "下载模板并创建基础文件系统" {
|
||||||
|
t.Fatalf("unexpected task stage: %q %q", task.Stage, task.StageDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lxcConfigWithName(name string) lxc.ContainerConfig {
|
||||||
|
return lxc.ContainerConfig{Name: name}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunAccessPolicyCommand manages the panel source policy without requiring the
|
||||||
|
// interactive menu. It is intended to remain usable over SSH as a recovery path.
|
||||||
|
func RunAccessPolicyCommand(args []string) error {
|
||||||
|
action := "show"
|
||||||
|
if len(args) > 0 {
|
||||||
|
action = strings.ToLower(strings.TrimSpace(args[0]))
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "show":
|
||||||
|
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
|
||||||
|
return nil
|
||||||
|
case "disable", "off":
|
||||||
|
next := config.AppConfig.PanelAccessPolicy
|
||||||
|
next.Enabled = false
|
||||||
|
if err := savePanelAccessPolicy(next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Panel access allowlist disabled.")
|
||||||
|
return reloadPanelAfterAccessPolicyCommand()
|
||||||
|
case "set", "enable":
|
||||||
|
flags := flag.NewFlagSet("clicd access-policy set", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(new(strings.Builder))
|
||||||
|
var allowed string
|
||||||
|
var trusted string
|
||||||
|
flags.StringVar(&allowed, "allow", "", "comma-separated allowed IP/CIDR values")
|
||||||
|
flags.StringVar(&trusted, "trusted-proxy", "", "comma-separated trusted proxy IP/CIDR values")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return fmt.Errorf("invalid access-policy arguments: %w", err)
|
||||||
|
}
|
||||||
|
next := config.PanelAccessPolicy{
|
||||||
|
Enabled: true,
|
||||||
|
AllowedSources: splitPanelAccessEntries(allowed),
|
||||||
|
TrustedProxies: splitPanelAccessEntries(trusted),
|
||||||
|
}
|
||||||
|
if err := savePanelAccessPolicy(next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Panel access allowlist saved.")
|
||||||
|
printPanelAccessPolicy(config.AppConfig.PanelAccessPolicy)
|
||||||
|
return reloadPanelAfterAccessPolicyCommand()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown access-policy action %q; use show, set, or disable", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePanelAccessPolicy(policy config.PanelAccessPolicy) error {
|
||||||
|
normalized, err := config.NormalizePanelAccessPolicy(policy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
previous := config.AppConfig.PanelAccessPolicy
|
||||||
|
config.AppConfig.PanelAccessPolicy = normalized
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
config.AppConfig.PanelAccessPolicy = previous
|
||||||
|
return fmt.Errorf("save panel access policy: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadPanelAfterAccessPolicyCommand() error {
|
||||||
|
if !isWebPanelRunning() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := restartService("clicd"); err != nil {
|
||||||
|
return fmt.Errorf("policy was saved but clicd service restart failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPanelAccessPolicy(policy config.PanelAccessPolicy) {
|
||||||
|
fmt.Printf("Enabled: %t\n", policy.Enabled)
|
||||||
|
fmt.Printf("Allowed sources: %s\n", strings.Join(policy.AllowedSources, ", "))
|
||||||
|
fmt.Printf("Trusted proxies: %s\n", strings.Join(policy.TrustedProxies, ", "))
|
||||||
|
}
|
||||||
+150
-44
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -53,6 +54,7 @@ var cliTranslations = map[string]string{
|
|||||||
"导入现有 LXC 容器": "Import existing LXC containers",
|
"导入现有 LXC 容器": "Import existing LXC containers",
|
||||||
"检查并升级 CLICD": "Check and upgrade CLICD",
|
"检查并升级 CLICD": "Check and upgrade CLICD",
|
||||||
"卸载 CLICD": "Uninstall CLICD",
|
"卸载 CLICD": "Uninstall CLICD",
|
||||||
|
"面板访问白名单": "Panel access allowlist",
|
||||||
"系统信息": "System info",
|
"系统信息": "System info",
|
||||||
"退出": "Exit",
|
"退出": "Exit",
|
||||||
"获取容器列表失败": "Failed to get container list",
|
"获取容器列表失败": "Failed to get container list",
|
||||||
@@ -125,32 +127,34 @@ var cliTranslations = map[string]string{
|
|||||||
"检查仓库": "Checking repository",
|
"检查仓库": "Checking repository",
|
||||||
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
|
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
|
||||||
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
|
"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",
|
||||||
"当前已经是最新版本。": "The current version is already the latest.",
|
"最新 Release 没有找到": "The latest release does not contain",
|
||||||
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
"无法自动升级。": "automatic upgrade is unavailable.",
|
||||||
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
"当前已经是最新版本。": "The current version is already the latest.",
|
||||||
"已取消。": "Cancelled.",
|
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
||||||
"升级失败": "Upgrade failed",
|
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
||||||
"升级完成": "Upgrade completed",
|
"已取消。": "Cancelled.",
|
||||||
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
|
"升级失败": "Upgrade failed",
|
||||||
"GitHub API 返回": "GitHub API returned",
|
"升级完成": "Upgrade completed",
|
||||||
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
|
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
|
||||||
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
|
"GitHub API 返回": "GitHub API returned",
|
||||||
"GitHub releases/latest 返回": "GitHub releases/latest returned",
|
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
|
||||||
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
|
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
|
||||||
"正在下载升级包...": "Downloading upgrade package...",
|
"GitHub releases/latest 返回": "GitHub releases/latest returned",
|
||||||
"正在解压升级包...": "Extracting upgrade package...",
|
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
|
||||||
"解压失败": "Extraction failed",
|
"正在下载升级包...": "Downloading upgrade package...",
|
||||||
"备份旧二进制失败": "Failed to back up old binary",
|
"正在解压升级包...": "Extracting upgrade package...",
|
||||||
"旧版本已备份": "Old version backed up",
|
"解压失败": "Extraction failed",
|
||||||
"正在替换二进制...": "Replacing binary...",
|
"备份旧二进制失败": "Failed to back up old binary",
|
||||||
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
|
"旧版本已备份": "Old version backed up",
|
||||||
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
|
"正在替换二进制...": "Replacing binary...",
|
||||||
"下载失败,HTTP": "Download failed, HTTP",
|
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
|
||||||
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
|
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
|
||||||
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
|
"下载失败,HTTP": "Download failed, HTTP",
|
||||||
|
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
|
||||||
|
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
|
||||||
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
|
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
|
||||||
"导入失败": "Import failed",
|
"导入失败": "Import failed",
|
||||||
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
|
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
|
||||||
@@ -172,10 +176,26 @@ var cliTranslations = map[string]string{
|
|||||||
"LXC 版本": "LXC version",
|
"LXC 版本": "LXC version",
|
||||||
"暂无可用容器": "No available containers",
|
"暂无可用容器": "No available containers",
|
||||||
"忽略无效端口": "Ignoring invalid port",
|
"忽略无效端口": "Ignoring invalid port",
|
||||||
"?": "? ",
|
"面板访问来源策略": "Panel access source policy",
|
||||||
"。": ". ",
|
"当前状态": "Current status",
|
||||||
",": ", ",
|
"已启用": "enabled",
|
||||||
":": ": ",
|
"已关闭": "disabled",
|
||||||
|
"允许来源": "Allowed sources",
|
||||||
|
"可信代理": "Trusted proxies",
|
||||||
|
"启用或修改白名单": "Enable or update allowlist",
|
||||||
|
"关闭白名单限制": "Disable allowlist",
|
||||||
|
"取消": "Cancel",
|
||||||
|
"允许的 IP/CIDR,多个用逗号分隔": "Allowed IP/CIDR values, comma-separated",
|
||||||
|
"可信代理 IP/CIDR,多个用逗号分隔,可留空": "Trusted proxy IP/CIDR values, comma-separated; optional",
|
||||||
|
"白名单配置无效": "Invalid allowlist configuration",
|
||||||
|
"保存访问来源策略失败": "Failed to save access source policy",
|
||||||
|
"面板访问白名单已保存。": "Panel access allowlist saved.",
|
||||||
|
"面板访问白名单已关闭。": "Panel access allowlist disabled.",
|
||||||
|
"至少填写一个允许的 IP 或网段。": "Enter at least one allowed IP address or network.",
|
||||||
|
"?": "? ",
|
||||||
|
"。": ". ",
|
||||||
|
",": ", ",
|
||||||
|
":": ": ",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the CLI interface.
|
// Run starts the CLI interface.
|
||||||
@@ -190,7 +210,7 @@ func Run() {
|
|||||||
refreshCLILanguage()
|
refreshCLILanguage()
|
||||||
clearScreen()
|
clearScreen()
|
||||||
printMenu()
|
printMenu()
|
||||||
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
|
cliPrint("\n请选择操作 [1-13,l,0/q]: ")
|
||||||
input, _ := reader.ReadString('\n')
|
input, _ := reader.ReadString('\n')
|
||||||
input = strings.TrimSpace(input)
|
input = strings.TrimSpace(input)
|
||||||
|
|
||||||
@@ -243,6 +263,10 @@ func Run() {
|
|||||||
clearScreen()
|
clearScreen()
|
||||||
cliUninstall(reader)
|
cliUninstall(reader)
|
||||||
return
|
return
|
||||||
|
case "13":
|
||||||
|
clearScreen()
|
||||||
|
cliConfigurePanelAccess(reader)
|
||||||
|
waitEnter(reader)
|
||||||
case "0":
|
case "0":
|
||||||
clearScreen()
|
clearScreen()
|
||||||
cliShowInfo()
|
cliShowInfo()
|
||||||
@@ -290,11 +314,80 @@ func printMenu() {
|
|||||||
cliPrintln(" 10. 导入现有 LXC 容器")
|
cliPrintln(" 10. 导入现有 LXC 容器")
|
||||||
cliPrintln(" 11. 检查并升级 CLICD")
|
cliPrintln(" 11. 检查并升级 CLICD")
|
||||||
cliPrintln(" 12. 卸载 CLICD")
|
cliPrintln(" 12. 卸载 CLICD")
|
||||||
|
cliPrintln(" 13. 面板访问白名单")
|
||||||
cliPrintln(" 0. 系统信息")
|
cliPrintln(" 0. 系统信息")
|
||||||
cliPrintln(" l. 切换语言")
|
cliPrintln(" l. 切换语言")
|
||||||
cliPrintln(" q. 退出")
|
cliPrintln(" q. 退出")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cliConfigurePanelAccess(reader *bufio.Reader) {
|
||||||
|
cliPrintf("\n--- %s ---\n", cliT("面板访问来源策略"))
|
||||||
|
policy := config.AppConfig.PanelAccessPolicy
|
||||||
|
status := cliT("已关闭")
|
||||||
|
if policy.Enabled {
|
||||||
|
status = cliT("已启用")
|
||||||
|
}
|
||||||
|
cliPrintf("%s: %s\n", cliT("当前状态"), status)
|
||||||
|
cliPrintf("%s: %s\n", cliT("允许来源"), strings.Join(policy.AllowedSources, ", "))
|
||||||
|
cliPrintf("%s: %s\n", cliT("可信代理"), strings.Join(policy.TrustedProxies, ", "))
|
||||||
|
cliPrintf("\n 1. %s\n", cliT("启用或修改白名单"))
|
||||||
|
cliPrintf(" 2. %s\n", cliT("关闭白名单限制"))
|
||||||
|
cliPrintf(" 0. %s\n", cliT("取消"))
|
||||||
|
|
||||||
|
choice := promptString(reader, "请选择操作", "0")
|
||||||
|
next := policy
|
||||||
|
switch strings.TrimSpace(choice) {
|
||||||
|
case "1":
|
||||||
|
allowed := promptString(reader, "允许的 IP/CIDR,多个用逗号分隔", strings.Join(policy.AllowedSources, ","))
|
||||||
|
allowedSources := splitPanelAccessEntries(allowed)
|
||||||
|
if len(allowedSources) == 0 {
|
||||||
|
cliPrintln("至少填写一个允许的 IP 或网段。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trusted := promptString(reader, "可信代理 IP/CIDR,多个用逗号分隔,可留空", strings.Join(policy.TrustedProxies, ","))
|
||||||
|
next = config.PanelAccessPolicy{
|
||||||
|
Enabled: true,
|
||||||
|
AllowedSources: allowedSources,
|
||||||
|
TrustedProxies: splitPanelAccessEntries(trusted),
|
||||||
|
}
|
||||||
|
case "2":
|
||||||
|
next.Enabled = false
|
||||||
|
case "0", "":
|
||||||
|
cliPrintln("已取消")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
cliPrintln("无效选择")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := config.NormalizePanelAccessPolicy(next)
|
||||||
|
if err != nil {
|
||||||
|
cliPrintf("%s: %v\n", cliT("白名单配置无效"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous := config.AppConfig.PanelAccessPolicy
|
||||||
|
config.AppConfig.PanelAccessPolicy = normalized
|
||||||
|
if err := config.SaveConfig(); err != nil {
|
||||||
|
config.AppConfig.PanelAccessPolicy = previous
|
||||||
|
cliPrintf("%s: %v\n", cliT("保存访问来源策略失败"), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if normalized.Enabled {
|
||||||
|
cliPrintln("面板访问白名单已保存。")
|
||||||
|
} else {
|
||||||
|
cliPrintln("面板访问白名单已关闭。")
|
||||||
|
}
|
||||||
|
if isWebPanelRunning() {
|
||||||
|
restartWebPanelForConfigChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPanelAccessEntries(value string) []string {
|
||||||
|
return strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func cliSwitchLanguage(reader *bufio.Reader) {
|
func cliSwitchLanguage(reader *bufio.Reader) {
|
||||||
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
|
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
|
||||||
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
|
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
|
||||||
@@ -557,11 +650,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 +674,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 +695,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 +703,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 +715,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 +725,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 +743,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 +765,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 +805,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 +823,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
|
||||||
@@ -1228,8 +1334,8 @@ func removeCLICDNATRules() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
deleteNATRule("POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
deleteNATRule("POSTROUTING", "-s", config.LXCNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
|
||||||
deleteNATRule("POSTROUTING", "-s", "192.168.122.0/24", "-o", "eth+", "-j", "MASQUERADE")
|
deleteNATRule("POSTROUTING", "-s", config.KVMNATNetwork().Subnet, "-o", "eth+", "-j", "MASQUERADE")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -109,6 +112,8 @@ type Container struct {
|
|||||||
LXCName string `json:"lxc_name,omitempty"`
|
LXCName string `json:"lxc_name,omitempty"`
|
||||||
KVMName string `json:"kvm_name,omitempty"`
|
KVMName string `json:"kvm_name,omitempty"`
|
||||||
DiskImage string `json:"disk_image,omitempty"`
|
DiskImage string `json:"disk_image,omitempty"`
|
||||||
|
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||||
|
StoragePath string `json:"storage_path,omitempty"`
|
||||||
MACAddress string `json:"mac_address,omitempty"`
|
MACAddress string `json:"mac_address,omitempty"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
@@ -128,7 +133,13 @@ type Container struct {
|
|||||||
IOReadMBps int `json:"io_read_mbps"`
|
IOReadMBps int `json:"io_read_mbps"`
|
||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
RestoreOnHostBoot bool `json:"restore_on_host_boot,omitempty"`
|
||||||
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 +154,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 +173,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 +195,373 @@ 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 normalizeStoragePools() bool {
|
||||||
|
if AppConfig == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
result := make([]StoragePool, 0, len(AppConfig.StoragePools))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
defaultSeen := map[string]bool{}
|
||||||
|
for _, pool := range AppConfig.StoragePools {
|
||||||
|
pool.ID = strings.TrimSpace(pool.ID)
|
||||||
|
pool.Name = strings.TrimSpace(pool.Name)
|
||||||
|
pool.Path = filepath.Clean(strings.TrimSpace(pool.Path))
|
||||||
|
pool.MountPoint = filepath.Clean(strings.TrimSpace(pool.MountPoint))
|
||||||
|
if pool.MountPoint == "." {
|
||||||
|
pool.MountPoint = ""
|
||||||
|
}
|
||||||
|
if pool.MountPoint != "" {
|
||||||
|
managedPath := managedStoragePoolPath(pool.MountPoint)
|
||||||
|
if pool.Path != managedPath {
|
||||||
|
pool.Path = managedPath
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pool.ID == "" {
|
||||||
|
pool.ID = storagePoolIDFromName(pool.Name, pool.Path)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if pool.Name == "" {
|
||||||
|
pool.Name = pool.ID
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if pool.Path == "." || !filepath.IsAbs(pool.Path) || seen[pool.ID] {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pool.ID] = true
|
||||||
|
pool.ContentTypes = normalizeStorageContentTypes(pool.ContentTypes)
|
||||||
|
pool.DefaultContents = normalizeStorageContentTypes(pool.DefaultContents)
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, content := range pool.ContentTypes {
|
||||||
|
allowed[content] = true
|
||||||
|
}
|
||||||
|
defaults := make([]string, 0, len(pool.DefaultContents))
|
||||||
|
for _, content := range pool.DefaultContents {
|
||||||
|
if !allowed[content] || defaultSeen[content] {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defaultSeen[content] = true
|
||||||
|
defaults = append(defaults, content)
|
||||||
|
}
|
||||||
|
pool.DefaultContents = defaults
|
||||||
|
if pool.ContentTypes == nil {
|
||||||
|
pool.ContentTypes = []string{}
|
||||||
|
}
|
||||||
|
result = append(result, pool)
|
||||||
|
}
|
||||||
|
if len(result) != len(AppConfig.StoragePools) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
AppConfig.StoragePools = result
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func managedStoragePoolPath(mountPoint string) string {
|
||||||
|
mountPoint = filepath.Clean(strings.TrimSpace(mountPoint))
|
||||||
|
if mountPoint == string(os.PathSeparator) {
|
||||||
|
return filepath.Join(string(os.PathSeparator), "var", "lib", "clicd")
|
||||||
|
}
|
||||||
|
return filepath.Join(mountPoint, "clicd")
|
||||||
|
}
|
||||||
|
|
||||||
|
func storagePoolIDFromName(name, path string) string {
|
||||||
|
base := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if base == "" {
|
||||||
|
base = filepath.Base(filepath.Clean(path))
|
||||||
|
}
|
||||||
|
replacer := strings.NewReplacer(" ", "-", "_", "-", ".", "-", "/", "-")
|
||||||
|
base = replacer.Replace(base)
|
||||||
|
base = strings.Trim(base, "-")
|
||||||
|
if base == "" {
|
||||||
|
base = "storage"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStorageContentTypes(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
valid := map[string]bool{
|
||||||
|
StorageContentLXC: true,
|
||||||
|
StorageContentKVM: true,
|
||||||
|
StorageContentImages: true,
|
||||||
|
StorageContentSnapshots: true,
|
||||||
|
StorageContentBackups: true,
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := []string{}
|
||||||
|
for _, value := range values {
|
||||||
|
next := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if !valid[next] || seen[next] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[next] = true
|
||||||
|
result = append(result, next)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func StoragePoolsForContent(content string) []StoragePool {
|
||||||
|
if AppConfig == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content = strings.ToLower(strings.TrimSpace(content))
|
||||||
|
result := []StoragePool{}
|
||||||
|
for _, pool := range AppConfig.StoragePools {
|
||||||
|
if !pool.Enabled || !storagePoolAllows(pool, content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, pool)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func StoragePoolByID(id string) *StoragePool {
|
||||||
|
if AppConfig == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
for i := range AppConfig.StoragePools {
|
||||||
|
if AppConfig.StoragePools[i].ID == id {
|
||||||
|
return &AppConfig.StoragePools[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func StoragePoolAllowsContent(pool StoragePool, content string) bool {
|
||||||
|
return storagePoolAllows(pool, strings.ToLower(strings.TrimSpace(content)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func StoragePathForContent(content, fallback string) string {
|
||||||
|
if pool := DefaultStoragePoolForContent(content); pool != nil {
|
||||||
|
return pool.Path
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreferredStoragePoolForContent returns the configured default without doing
|
||||||
|
// filesystem probes. Use SelectStoragePoolForContent for new writes.
|
||||||
|
func PreferredStoragePoolForContent(content string) *StoragePool {
|
||||||
|
if AppConfig == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content = strings.ToLower(strings.TrimSpace(content))
|
||||||
|
for i := range AppConfig.StoragePools {
|
||||||
|
pool := &AppConfig.StoragePools[i]
|
||||||
|
if !pool.Enabled || !storagePoolAllows(*pool, content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range pool.DefaultContents {
|
||||||
|
if item == content {
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range AppConfig.StoragePools {
|
||||||
|
pool := &AppConfig.StoragePools[i]
|
||||||
|
if pool.Enabled && storagePoolAllows(*pool, content) {
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultStoragePoolForContent(content string) *StoragePool {
|
||||||
|
pool, _ := SelectStoragePoolForContent(content, "", 0)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePoolFreeReserveBytes int64 = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
type storagePoolCandidate struct {
|
||||||
|
pool *StoragePool
|
||||||
|
freeBytes int64
|
||||||
|
isDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectStoragePoolForContent picks a writable mounted pool. The requested or
|
||||||
|
// configured default pool is preferred while it has enough space; remaining
|
||||||
|
// pools are tried by available space from largest to smallest.
|
||||||
|
func SelectStoragePoolForContent(content, requestedPoolID string, requiredBytes int64) (*StoragePool, error) {
|
||||||
|
if AppConfig == nil {
|
||||||
|
return nil, fmt.Errorf("storage configuration is not loaded")
|
||||||
|
}
|
||||||
|
content = strings.ToLower(strings.TrimSpace(content))
|
||||||
|
requestedPoolID = strings.TrimSpace(requestedPoolID)
|
||||||
|
if requiredBytes < 0 {
|
||||||
|
requiredBytes = 0
|
||||||
|
}
|
||||||
|
requiredFree := requiredBytes + storagePoolFreeReserveBytes
|
||||||
|
candidates := make([]storagePoolCandidate, 0, len(AppConfig.StoragePools))
|
||||||
|
configured := 0
|
||||||
|
for i := range AppConfig.StoragePools {
|
||||||
|
pool := &AppConfig.StoragePools[i]
|
||||||
|
if !pool.Enabled || !storagePoolAllows(*pool, content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
configured++
|
||||||
|
freeBytes, available := probeStoragePoolFreeBytes(*pool)
|
||||||
|
if !available {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := storagePoolCandidate{pool: pool, freeBytes: freeBytes}
|
||||||
|
for _, item := range pool.DefaultContents {
|
||||||
|
if item == content {
|
||||||
|
candidate.isDefault = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
if configured == 0 {
|
||||||
|
return nil, fmt.Errorf("no storage disk is enabled for %s", storageContentLabel(content))
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("all storage disks enabled for %s are unavailable or unmounted", storageContentLabel(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||||||
|
return candidates[i].freeBytes > candidates[j].freeBytes
|
||||||
|
})
|
||||||
|
preferred := func(match func(storagePoolCandidate) bool) *StoragePool {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if match(candidate) && candidate.freeBytes >= requiredFree {
|
||||||
|
return candidate.pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if requestedPoolID != "" {
|
||||||
|
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.pool.ID == requestedPoolID }); pool != nil {
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pool := preferred(func(candidate storagePoolCandidate) bool { return candidate.isDefault }); pool != nil {
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
if pool := preferred(func(storagePoolCandidate) bool { return true }); pool != nil {
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("storage disks enabled for %s do not have enough free space", storageContentLabel(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
var probeStoragePoolFreeBytes = storagePoolFreeBytes
|
||||||
|
|
||||||
|
func storagePoolFreeBytes(pool StoragePool) (int64, bool) {
|
||||||
|
if strings.TrimSpace(pool.Path) == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(pool.Path); err != nil {
|
||||||
|
if !os.IsNotExist(err) || filepath.Clean(pool.MountPoint) != string(os.PathSeparator) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(pool.Path, 0755); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mountPoint := strings.TrimSpace(pool.MountPoint); mountPoint != "" {
|
||||||
|
out, err := exec.Command("findmnt", "-n", "-o", "TARGET", "--target", pool.Path).Output()
|
||||||
|
if err != nil || filepath.Clean(strings.TrimSpace(string(out))) != filepath.Clean(mountPoint) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := exec.Command("df", "-B1", "-P", pool.Path).Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
fields := strings.Fields(lines[len(lines)-1])
|
||||||
|
if len(fields) < 4 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
freeBytes, err := strconv.ParseInt(fields[3], 10, 64)
|
||||||
|
return freeBytes, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageContentLabel(content string) string {
|
||||||
|
switch content {
|
||||||
|
case StorageContentLXC:
|
||||||
|
return "LXC containers"
|
||||||
|
case StorageContentKVM:
|
||||||
|
return "KVM disks"
|
||||||
|
case StorageContentImages:
|
||||||
|
return "image cache"
|
||||||
|
case StorageContentSnapshots:
|
||||||
|
return "snapshots"
|
||||||
|
case StorageContentBackups:
|
||||||
|
return "backups"
|
||||||
|
default:
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storagePoolAllows(pool StoragePool, content string) bool {
|
||||||
|
for _, item := range pool.ContentTypes {
|
||||||
|
if item == content {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Container) NormalizeNetworkAssignments() bool {
|
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 {
|
||||||
@@ -202,7 +583,7 @@ func (c *Container) NormalizeNetworkAssignments() bool {
|
|||||||
c.PublicIPv4s = filteredIPv4
|
c.PublicIPv4s = filteredIPv4
|
||||||
|
|
||||||
seenIPv6 := map[string]bool{}
|
seenIPv6 := map[string]bool{}
|
||||||
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1)
|
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses))
|
||||||
for _, item := range c.IPv6Addresses {
|
for _, item := range c.IPv6Addresses {
|
||||||
item.Address = strings.TrimSpace(item.Address)
|
item.Address = strings.TrimSpace(item.Address)
|
||||||
item.Interface = strings.TrimSpace(item.Interface)
|
item.Interface = strings.TrimSpace(item.Interface)
|
||||||
@@ -319,16 +700,18 @@ func DeleteApiKey(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SubUser struct {
|
type SubUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
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"`
|
||||||
Token string `json:"-"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
AccessCode string `json:"access_code"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
Token string `json:"-"`
|
||||||
TokenVersion int `json:"token_version"`
|
AccessCode string `json:"access_code"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
TokenVersion int `json:"token_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
@@ -361,6 +744,43 @@ type SSLConfig struct {
|
|||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
StorageContentLXC = "lxc"
|
||||||
|
StorageContentKVM = "kvm"
|
||||||
|
StorageContentImages = "images"
|
||||||
|
StorageContentSnapshots = "snapshots"
|
||||||
|
StorageContentBackups = "backups"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StoragePool struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
MountPoint string `json:"mount_point,omitempty"`
|
||||||
|
ContentTypes []string `json:"content_types"`
|
||||||
|
DefaultContents []string `json:"default_contents,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPrimaryStoragePool() StoragePool {
|
||||||
|
contents := []string{
|
||||||
|
StorageContentLXC,
|
||||||
|
StorageContentKVM,
|
||||||
|
StorageContentImages,
|
||||||
|
StorageContentSnapshots,
|
||||||
|
StorageContentBackups,
|
||||||
|
}
|
||||||
|
return StoragePool{
|
||||||
|
ID: "disk-root",
|
||||||
|
Name: "system (/)",
|
||||||
|
Path: "/var/lib/clicd",
|
||||||
|
MountPoint: "/",
|
||||||
|
ContentTypes: append([]string(nil), contents...),
|
||||||
|
DefaultContents: append([]string(nil), contents...),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ClicdConfig is the main configuration structure
|
// ClicdConfig is the main configuration structure
|
||||||
type ClicdConfig struct {
|
type ClicdConfig struct {
|
||||||
AdminUser string `json:"admin_user"`
|
AdminUser string `json:"admin_user"`
|
||||||
@@ -372,6 +792,10 @@ 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"`
|
||||||
|
LXCNATSubnet string `json:"lxc_nat_subnet"`
|
||||||
|
KVMNATSubnet string `json:"kvm_nat_subnet"`
|
||||||
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"`
|
||||||
@@ -379,21 +803,70 @@ type ClicdConfig struct {
|
|||||||
Tasks []SavedTask `json:"tasks"`
|
Tasks []SavedTask `json:"tasks"`
|
||||||
LoginLogs []SavedLoginLog `json:"login_logs"`
|
LoginLogs []SavedLoginLog `json:"login_logs"`
|
||||||
EnabledImages []string `json:"enabled_images"`
|
EnabledImages []string `json:"enabled_images"`
|
||||||
|
CustomKVMImages []CustomKVMImage `json:"custom_kvm_images"`
|
||||||
|
CustomLXCImages []CustomLXCImage `json:"custom_lxc_images"`
|
||||||
Snapshots []Snapshot `json:"snapshots"`
|
Snapshots []Snapshot `json:"snapshots"`
|
||||||
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
|
||||||
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
|
||||||
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
|
||||||
|
PanelAccessPolicy PanelAccessPolicy `json:"panel_access_policy"`
|
||||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||||
|
TaskConcurrency int `json:"task_concurrency"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
SSL SSLConfig `json:"ssl"`
|
SSL SSLConfig `json:"ssl"`
|
||||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||||
|
StoragePools []StoragePool `json:"storage_pools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
KVMProvisionerLinuxCloudInit = "linux-cloud-init"
|
||||||
|
KVMProvisionerWindows10 = "windows-10"
|
||||||
|
KVMProvisionerWindows11 = "windows-11"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomKVMImage is an administrator-defined KVM image source.
|
||||||
|
type CustomKVMImage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Distro string `json:"distro"`
|
||||||
|
Release string `json:"release"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Provisioner string `json:"provisioner"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomLXCImage is an administrator-defined LXC rootfs archive source.
|
||||||
|
type CustomLXCImage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Distro string `json:"distro"`
|
||||||
|
Release string `json:"release"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var configPath string
|
var configPath string
|
||||||
var AppConfig *ClicdConfig
|
var AppConfig *ClicdConfig
|
||||||
|
var allocationMu sync.Mutex
|
||||||
|
|
||||||
const DefaultSnapshotLimit = 3
|
const DefaultSnapshotLimit = 3
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultTaskConcurrency = 2
|
||||||
|
MaxTaskConcurrency = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultNATPortStart = 20000
|
||||||
|
DefaultNATPortEnd = 65535
|
||||||
|
)
|
||||||
|
|
||||||
func getConfigPath() string {
|
func getConfigPath() string {
|
||||||
if configPath != "" {
|
if configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
@@ -509,6 +982,10 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
NextContainerID: 1,
|
NextContainerID: 1,
|
||||||
NextVNCPort: 5900,
|
NextVNCPort: 5900,
|
||||||
NextSSHPort: 22000,
|
NextSSHPort: 22000,
|
||||||
|
NATPortStart: DefaultNATPortStart,
|
||||||
|
NATPortEnd: DefaultNATPortEnd,
|
||||||
|
LXCNATSubnet: configuredSubnetValue("", "CLICD_LXC_SUBNET", DefaultLXCNATSubnet),
|
||||||
|
KVMNATSubnet: configuredSubnetValue("", "CLICD_KVM_SUBNET", DefaultKVMNATSubnet),
|
||||||
SetupComplete: false,
|
SetupComplete: false,
|
||||||
SubUsers: []SubUser{},
|
SubUsers: []SubUser{},
|
||||||
AuditLogs: []AuditLog{},
|
AuditLogs: []AuditLog{},
|
||||||
@@ -518,6 +995,12 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
PublicIPv4Pool: []PublicIPv4Assignment{},
|
PublicIPv4Pool: []PublicIPv4Assignment{},
|
||||||
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
PublicIPv6Prefixes: []PublicIPv6Prefix{},
|
||||||
WebSSHAllowedOrigins: []string{},
|
WebSSHAllowedOrigins: []string{},
|
||||||
|
PanelAccessPolicy: PanelAccessPolicy{
|
||||||
|
AllowedSources: []string{},
|
||||||
|
TrustedProxies: []string{},
|
||||||
|
},
|
||||||
|
TaskConcurrency: DefaultTaskConcurrency,
|
||||||
|
StoragePools: []StoragePool{defaultPrimaryStoragePool()},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
@@ -552,10 +1035,20 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.NextSSHPort = 22000
|
AppConfig.NextSSHPort = 22000
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if normalizeNATPortRangeDefaults() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if normalizeNATNetworkDefaults() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.NextContainerID == 0 {
|
if AppConfig.NextContainerID == 0 {
|
||||||
AppConfig.NextContainerID = 1
|
AppConfig.NextContainerID = 1
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if normalized := NormalizeTaskConcurrency(AppConfig.TaskConcurrency); AppConfig.TaskConcurrency != normalized {
|
||||||
|
AppConfig.TaskConcurrency = normalized
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.DataDir == "" {
|
if AppConfig.DataDir == "" {
|
||||||
AppConfig.DataDir = dataDir
|
AppConfig.DataDir = dataDir
|
||||||
changed = true
|
changed = true
|
||||||
@@ -583,6 +1076,25 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.WebSSHAllowedOrigins = normalized
|
AppConfig.WebSSHAllowedOrigins = normalized
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if normalized, err := NormalizePanelAccessPolicy(AppConfig.PanelAccessPolicy); err == nil {
|
||||||
|
if !panelAccessPoliciesEqual(AppConfig.PanelAccessPolicy, normalized) {
|
||||||
|
AppConfig.PanelAccessPolicy = normalized
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AppConfig.PanelAccessPolicy = PanelAccessPolicy{
|
||||||
|
AllowedSources: []string{},
|
||||||
|
TrustedProxies: []string{},
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if len(AppConfig.StoragePools) == 0 {
|
||||||
|
AppConfig.StoragePools = []StoragePool{defaultPrimaryStoragePool()}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if normalizeStoragePools() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.SubUsers == nil {
|
if AppConfig.SubUsers == nil {
|
||||||
AppConfig.SubUsers = make([]SubUser, 0)
|
AppConfig.SubUsers = make([]SubUser, 0)
|
||||||
changed = true
|
changed = true
|
||||||
@@ -614,6 +1126,14 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
AppConfig.EnabledImages = make([]string, 0)
|
AppConfig.EnabledImages = make([]string, 0)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if AppConfig.CustomKVMImages == nil {
|
||||||
|
AppConfig.CustomKVMImages = make([]CustomKVMImage, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.CustomLXCImages == nil {
|
||||||
|
AppConfig.CustomLXCImages = make([]CustomLXCImage, 0)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if AppConfig.Language == "" {
|
if AppConfig.Language == "" {
|
||||||
AppConfig.Language = "zh"
|
AppConfig.Language = "zh"
|
||||||
changed = true
|
changed = true
|
||||||
@@ -628,6 +1148,16 @@ func normalizeConfigDefaults(dataDir string) bool {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NormalizeTaskConcurrency(value int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return DefaultTaskConcurrency
|
||||||
|
}
|
||||||
|
if value > MaxTaskConcurrency {
|
||||||
|
return MaxTaskConcurrency
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func NormalizeLanguage(language string) string {
|
func NormalizeLanguage(language string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||||
case "en", "en-us", "en_us", "english":
|
case "en", "en-us", "en_us", "english":
|
||||||
@@ -972,8 +1502,108 @@ func SaveConfig() error {
|
|||||||
return saveConfigToDB()
|
return saveConfigToDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListCustomKVMImages() []CustomKVMImage {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
if AppConfig == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]CustomKVMImage(nil), AppConfig.CustomKVMImages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddCustomKVMImage(image CustomKVMImage) error {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
for _, existing := range AppConfig.CustomKVMImages {
|
||||||
|
if existing.ID == image.ID {
|
||||||
|
return fmt.Errorf("custom KVM image %q already exists", image.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppConfig.CustomKVMImages = append(AppConfig.CustomKVMImages, image)
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
AppConfig.CustomKVMImages = AppConfig.CustomKVMImages[:len(AppConfig.CustomKVMImages)-1]
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveCustomKVMImage(id string) (bool, error) {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
filtered := make([]CustomKVMImage, 0, len(AppConfig.CustomKVMImages))
|
||||||
|
found := false
|
||||||
|
for _, image := range AppConfig.CustomKVMImages {
|
||||||
|
if image.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, image)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
previous := AppConfig.CustomKVMImages
|
||||||
|
AppConfig.CustomKVMImages = filtered
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
AppConfig.CustomKVMImages = previous
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListCustomLXCImages() []CustomLXCImage {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
if AppConfig == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]CustomLXCImage(nil), AppConfig.CustomLXCImages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddCustomLXCImage(image CustomLXCImage) error {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
for _, existing := range AppConfig.CustomLXCImages {
|
||||||
|
if existing.ID == image.ID {
|
||||||
|
return fmt.Errorf("custom LXC image %q already exists", image.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppConfig.CustomLXCImages = append(AppConfig.CustomLXCImages, image)
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
AppConfig.CustomLXCImages = AppConfig.CustomLXCImages[:len(AppConfig.CustomLXCImages)-1]
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveCustomLXCImage(id string) (bool, error) {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
filtered := make([]CustomLXCImage, 0, len(AppConfig.CustomLXCImages))
|
||||||
|
found := false
|
||||||
|
for _, image := range AppConfig.CustomLXCImages {
|
||||||
|
if image.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, image)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
previous := AppConfig.CustomLXCImages
|
||||||
|
AppConfig.CustomLXCImages = filtered
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
AppConfig.CustomLXCImages = previous
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AddContainer adds a container to the config
|
// AddContainer adds a container to the config
|
||||||
func AddContainer(c Container) {
|
func AddContainer(c Container) {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
if c.UUID == "" {
|
if c.UUID == "" {
|
||||||
c.UUID = NewContainerUUID()
|
c.UUID = NewContainerUUID()
|
||||||
}
|
}
|
||||||
@@ -985,6 +1615,8 @@ func AddContainer(c Container) {
|
|||||||
|
|
||||||
// AllocateContainerID allocates a new container ID
|
// AllocateContainerID allocates a new container ID
|
||||||
func AllocateContainerID() int {
|
func AllocateContainerID() int {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
id := AppConfig.NextContainerID
|
id := AppConfig.NextContainerID
|
||||||
AppConfig.NextContainerID++
|
AppConfig.NextContainerID++
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
@@ -1146,6 +1778,23 @@ func UpdateContainerStatus(id int, status string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UpdateContainerStatusAndRestore(id int, status string, restoreOnHostBoot bool) {
|
||||||
|
c := FindContainer(id)
|
||||||
|
if c != nil {
|
||||||
|
c.Status = status
|
||||||
|
c.RestoreOnHostBoot = restoreOnHostBoot
|
||||||
|
SaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetContainerRestoreOnHostBoot(id int, restore bool) {
|
||||||
|
c := FindContainer(id)
|
||||||
|
if c != nil {
|
||||||
|
c.RestoreOnHostBoot = restore
|
||||||
|
SaveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
|
func SetContainerPolicyBlock(id int, blocked bool, reason string) {
|
||||||
c := FindContainer(id)
|
c := FindContainer(id)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
@@ -1168,16 +1817,132 @@ 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) {
|
||||||
|
return AllocateSSHPortExcluding(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllocateSSHPortExcluding allocates a management port while reserving
|
||||||
|
// user-requested NAT host ports for the container being created.
|
||||||
|
func AllocateSSHPortExcluding(excluded []int) (int, error) {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
candidate, err := previewSSHPortExcluding(excluded)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
start, end := NATPortRange()
|
||||||
|
AppConfig.NextSSHPort = candidate + 1
|
||||||
|
if AppConfig.NextSSHPort > end {
|
||||||
|
AppConfig.NextSSHPort = start
|
||||||
}
|
}
|
||||||
AppConfig.NextSSHPort = port + 1
|
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
return port
|
return candidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewSSHPortExcluding returns the management port that the allocator would
|
||||||
|
// choose without advancing or persisting the allocation cursor.
|
||||||
|
func PreviewSSHPortExcluding(excluded []int) (int, error) {
|
||||||
|
allocationMu.Lock()
|
||||||
|
defer allocationMu.Unlock()
|
||||||
|
return previewSSHPortExcluding(excluded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewSSHPortExcluding(excluded []int) (int, error) {
|
||||||
|
used := collectAllHostPorts()
|
||||||
|
for _, port := range excluded {
|
||||||
|
if port > 0 {
|
||||||
|
used[port] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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,144 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultLXCNATSubnet = "10.0.3.0/24"
|
||||||
|
DefaultKVMNATSubnet = "192.168.122.0/24"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NATNetwork struct {
|
||||||
|
Subnet string `json:"subnet"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
Netmask string `json:"netmask"`
|
||||||
|
DHCPStart string `json:"dhcp_start"`
|
||||||
|
DHCPEnd string `json:"dhcp_end"`
|
||||||
|
DHCPMax int `json:"dhcp_max"`
|
||||||
|
PrefixBits int `json:"prefix_bits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseNATNetwork(raw string) (NATNetwork, error) {
|
||||||
|
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
|
||||||
|
if err != nil || !prefix.Addr().Is4() {
|
||||||
|
return NATNetwork{}, fmt.Errorf("NAT subnet must be a valid IPv4 CIDR")
|
||||||
|
}
|
||||||
|
prefix = prefix.Masked()
|
||||||
|
if prefix.Bits() < 16 || prefix.Bits() > 28 {
|
||||||
|
return NATNetwork{}, fmt.Errorf("NAT subnet prefix must be between /16 and /28")
|
||||||
|
}
|
||||||
|
if !isRFC1918Prefix(prefix) {
|
||||||
|
return NATNetwork{}, fmt.Errorf("NAT subnet must use an RFC1918 private IPv4 range")
|
||||||
|
}
|
||||||
|
|
||||||
|
network := ipv4Uint32(prefix.Addr())
|
||||||
|
hostBits := 32 - prefix.Bits()
|
||||||
|
broadcast := network | uint32((uint64(1)<<hostBits)-1)
|
||||||
|
gateway := uint32IPv4(network + 1)
|
||||||
|
dhcpStart := uint32IPv4(network + 2)
|
||||||
|
dhcpEnd := uint32IPv4(broadcast - 1)
|
||||||
|
return NATNetwork{
|
||||||
|
Subnet: prefix.String(),
|
||||||
|
Gateway: gateway.String(),
|
||||||
|
Netmask: netmaskString(prefix.Bits()),
|
||||||
|
DHCPStart: dhcpStart.String(),
|
||||||
|
DHCPEnd: dhcpEnd.String(),
|
||||||
|
DHCPMax: int(broadcast - network - 2),
|
||||||
|
PrefixBits: prefix.Bits(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LXCNATNetwork() NATNetwork {
|
||||||
|
return configuredNATNetwork(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func KVMNATNetwork() NATNetwork {
|
||||||
|
return configuredNATNetwork(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeNATNetworkDefaults() bool {
|
||||||
|
changed := false
|
||||||
|
lxcSubnet := configuredSubnetValue(AppConfig.LXCNATSubnet, "CLICD_LXC_SUBNET", DefaultLXCNATSubnet)
|
||||||
|
kvmSubnet := configuredSubnetValue(AppConfig.KVMNATSubnet, "CLICD_KVM_SUBNET", DefaultKVMNATSubnet)
|
||||||
|
if AppConfig.LXCNATSubnet != lxcSubnet {
|
||||||
|
AppConfig.LXCNATSubnet = lxcSubnet
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if AppConfig.KVMNATSubnet != kvmSubnet {
|
||||||
|
AppConfig.KVMNATSubnet = kvmSubnet
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func configuredNATNetwork(kvm bool) NATNetwork {
|
||||||
|
raw := DefaultLXCNATSubnet
|
||||||
|
if kvm {
|
||||||
|
raw = DefaultKVMNATSubnet
|
||||||
|
}
|
||||||
|
if AppConfig != nil {
|
||||||
|
if kvm && AppConfig.KVMNATSubnet != "" {
|
||||||
|
raw = AppConfig.KVMNATSubnet
|
||||||
|
}
|
||||||
|
if !kvm && AppConfig.LXCNATSubnet != "" {
|
||||||
|
raw = AppConfig.LXCNATSubnet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
network, err := ParseNATNetwork(raw)
|
||||||
|
if err == nil {
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
network, _ = ParseNATNetwork(map[bool]string{false: DefaultLXCNATSubnet, true: DefaultKVMNATSubnet}[kvm])
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
|
||||||
|
func configuredSubnetValue(current, envName, fallback string) string {
|
||||||
|
raw := strings.TrimSpace(current)
|
||||||
|
if envValue := strings.TrimSpace(os.Getenv(envName)); envValue != "" {
|
||||||
|
raw = envValue
|
||||||
|
}
|
||||||
|
if network, err := ParseNATNetwork(raw); err == nil {
|
||||||
|
return network.Subnet
|
||||||
|
}
|
||||||
|
network, _ := ParseNATNetwork(fallback)
|
||||||
|
return network.Subnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRFC1918Prefix(prefix netip.Prefix) bool {
|
||||||
|
privateRanges := []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("172.16.0.0/12"),
|
||||||
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
}
|
||||||
|
for _, privateRange := range privateRanges {
|
||||||
|
if privateRange.Contains(prefix.Addr()) {
|
||||||
|
last := uint32IPv4(ipv4Uint32(prefix.Addr()) | uint32((uint64(1)<<(32-prefix.Bits()))-1))
|
||||||
|
return privateRange.Contains(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipv4Uint32(addr netip.Addr) uint32 {
|
||||||
|
bytes := addr.As4()
|
||||||
|
return binary.BigEndian.Uint32(bytes[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint32IPv4(value uint32) netip.Addr {
|
||||||
|
var bytes [4]byte
|
||||||
|
binary.BigEndian.PutUint32(bytes[:], value)
|
||||||
|
return netip.AddrFrom4(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func netmaskString(bits int) string {
|
||||||
|
mask := uint32(0)
|
||||||
|
if bits > 0 {
|
||||||
|
mask = ^uint32(0) << (32 - bits)
|
||||||
|
}
|
||||||
|
return uint32IPv4(mask).String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseNATNetwork(t *testing.T) {
|
||||||
|
network, err := ParseNATNetwork("172.28.40.0/24")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNATNetwork returned error: %v", err)
|
||||||
|
}
|
||||||
|
if network.Subnet != "172.28.40.0/24" ||
|
||||||
|
network.Gateway != "172.28.40.1" ||
|
||||||
|
network.Netmask != "255.255.255.0" ||
|
||||||
|
network.DHCPStart != "172.28.40.2" ||
|
||||||
|
network.DHCPEnd != "172.28.40.254" ||
|
||||||
|
network.DHCPMax != 253 {
|
||||||
|
t.Fatalf("unexpected network values: %+v", network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNATNetworkMasksHostBits(t *testing.T) {
|
||||||
|
network, err := ParseNATNetwork("10.44.8.99/20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseNATNetwork returned error: %v", err)
|
||||||
|
}
|
||||||
|
if network.Subnet != "10.44.0.0/20" || network.Gateway != "10.44.0.1" || network.DHCPEnd != "10.44.15.254" {
|
||||||
|
t.Fatalf("unexpected masked network values: %+v", network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNATNetworkRejectsUnsafeRanges(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"203.0.113.0/24",
|
||||||
|
"10.0.0.0/15",
|
||||||
|
"10.0.0.0/29",
|
||||||
|
"not-a-subnet",
|
||||||
|
} {
|
||||||
|
if _, err := ParseNATNetwork(raw); err == nil {
|
||||||
|
t.Fatalf("ParseNATNetwork(%q) unexpectedly succeeded", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeNATNetworkDefaultsUsesEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("CLICD_LXC_SUBNET", "172.30.8.0/24")
|
||||||
|
t.Setenv("CLICD_KVM_SUBNET", "10.230.0.0/20")
|
||||||
|
previous := AppConfig
|
||||||
|
AppConfig = &ClicdConfig{}
|
||||||
|
t.Cleanup(func() { AppConfig = previous })
|
||||||
|
|
||||||
|
if !normalizeNATNetworkDefaults() {
|
||||||
|
t.Fatal("expected defaults to change")
|
||||||
|
}
|
||||||
|
if AppConfig.LXCNATSubnet != "172.30.8.0/24" || AppConfig.KVMNATSubnet != "10.230.0.0/20" {
|
||||||
|
t.Fatalf("unexpected configured subnets: LXC=%s KVM=%s", AppConfig.LXCNATSubnet, AppConfig.KVMNATSubnet)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllocateSSHPortExcludingRequestedMappings(t *testing.T) {
|
||||||
|
previous := AppConfig
|
||||||
|
t.Cleanup(func() { AppConfig = previous })
|
||||||
|
AppConfig = &ClicdConfig{
|
||||||
|
NATPortStart: 32000,
|
||||||
|
NATPortEnd: 32002,
|
||||||
|
NextSSHPort: 32000,
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := AllocateSSHPortExcluding([]int{32000, 32001})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if port != 32002 {
|
||||||
|
t.Fatalf("allocated port = %d, want 32002", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewSSHPortUsesRangeWithoutAdvancingCursor(t *testing.T) {
|
||||||
|
previous := AppConfig
|
||||||
|
t.Cleanup(func() { AppConfig = previous })
|
||||||
|
AppConfig = &ClicdConfig{
|
||||||
|
NATPortStart: 30000,
|
||||||
|
NATPortEnd: 35000,
|
||||||
|
NextSSHPort: 30000,
|
||||||
|
Containers: []Container{{
|
||||||
|
PortMappings: []PortMapping{{HostPort: 30000}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := PreviewSSHPortExcluding([]int{30001})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if port != 30002 {
|
||||||
|
t.Fatalf("preview port = %d, want 30002", port)
|
||||||
|
}
|
||||||
|
if AppConfig.NextSSHPort != 30000 {
|
||||||
|
t.Fatalf("preview advanced cursor to %d", AppConfig.NextSSHPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PanelAccessPolicy limits access to the complete web panel and API surface.
|
||||||
|
type PanelAccessPolicy struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AllowedSources []string `json:"allowed_sources"`
|
||||||
|
TrustedProxies []string `json:"trusted_proxies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardedClientHeaders contains proxy-provided client address headers.
|
||||||
|
type ForwardedClientHeaders struct {
|
||||||
|
ForwardedFor string
|
||||||
|
RealIP string
|
||||||
|
CFConnectingIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanelAccessDecision describes the address used by the access policy.
|
||||||
|
type PanelAccessDecision struct {
|
||||||
|
Allowed bool
|
||||||
|
DirectSource string
|
||||||
|
CurrentSource string
|
||||||
|
UsedForwarded bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizePanelAccessPolicy(policy PanelAccessPolicy) (PanelAccessPolicy, error) {
|
||||||
|
allowed, err := normalizeIPRanges(policy.AllowedSources, "allowed source")
|
||||||
|
if err != nil {
|
||||||
|
return PanelAccessPolicy{}, err
|
||||||
|
}
|
||||||
|
trusted, err := normalizeIPRanges(policy.TrustedProxies, "trusted proxy")
|
||||||
|
if err != nil {
|
||||||
|
return PanelAccessPolicy{}, err
|
||||||
|
}
|
||||||
|
if policy.Enabled && len(allowed) == 0 {
|
||||||
|
return PanelAccessPolicy{}, fmt.Errorf("at least one allowed IP address or CIDR is required")
|
||||||
|
}
|
||||||
|
return PanelAccessPolicy{
|
||||||
|
Enabled: policy.Enabled,
|
||||||
|
AllowedSources: allowed,
|
||||||
|
TrustedProxies: trusted,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIPRanges(values []string, label string) ([]string, error) {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, raw := range values {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized, err := normalizeIPRange(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid %s %q: %w", label, value, err)
|
||||||
|
}
|
||||||
|
if _, exists := seen[normalized]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
result = append(result, normalized)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIPRange(value string) (string, error) {
|
||||||
|
if strings.Contains(value, "/") {
|
||||||
|
prefix, err := netip.ParsePrefix(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if prefix.Addr().Zone() != "" {
|
||||||
|
return "", fmt.Errorf("IPv6 zones are not supported")
|
||||||
|
}
|
||||||
|
return prefix.Masked().String(), nil
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if addr.Zone() != "" {
|
||||||
|
return "", fmt.Errorf("IPv6 zones are not supported")
|
||||||
|
}
|
||||||
|
return addr.Unmap().String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func panelAccessPoliciesEqual(a, b PanelAccessPolicy) bool {
|
||||||
|
return a.Enabled == b.Enabled &&
|
||||||
|
stringSlicesEqual(a.AllowedSources, b.AllowedSources) &&
|
||||||
|
stringSlicesEqual(a.TrustedProxies, b.TrustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringSlicesEqual(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluatePanelAccess resolves the effective client address and applies policy.
|
||||||
|
// Forwarded headers are only considered when the TCP peer is trusted.
|
||||||
|
func EvaluatePanelAccess(policy PanelAccessPolicy, remoteAddr string, headers ForwardedClientHeaders) PanelAccessDecision {
|
||||||
|
direct, ok := parseRemoteIP(remoteAddr)
|
||||||
|
decision := PanelAccessDecision{}
|
||||||
|
if ok {
|
||||||
|
decision.DirectSource = direct.String()
|
||||||
|
decision.CurrentSource = direct.String()
|
||||||
|
}
|
||||||
|
if !policy.Enabled {
|
||||||
|
decision.Allowed = true
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
current := direct
|
||||||
|
if ipInRanges(direct, policy.TrustedProxies) {
|
||||||
|
if forwarded, forwardedOK := resolveForwardedIP(direct, policy.TrustedProxies, headers); forwardedOK {
|
||||||
|
current = forwarded
|
||||||
|
decision.CurrentSource = forwarded.String()
|
||||||
|
decision.UsedForwarded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A direct local connection remains an emergency recovery path. When a
|
||||||
|
// trusted local reverse proxy forwards a client address, that client is
|
||||||
|
// still checked normally.
|
||||||
|
if current.IsLoopback() && !decision.UsedForwarded {
|
||||||
|
decision.Allowed = true
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
decision.Allowed = ipInRanges(current, policy.AllowedSources)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRemoteIP(value string) (netip.Addr, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if host, _, err := net.SplitHostPort(value); err == nil {
|
||||||
|
value = host
|
||||||
|
}
|
||||||
|
value = strings.TrimPrefix(strings.TrimSuffix(value, "]"), "[")
|
||||||
|
addr, err := netip.ParseAddr(value)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, false
|
||||||
|
}
|
||||||
|
return addr.Unmap(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveForwardedIP(direct netip.Addr, trusted []string, headers ForwardedClientHeaders) (netip.Addr, bool) {
|
||||||
|
for _, raw := range []string{headers.CFConnectingIP, headers.RealIP} {
|
||||||
|
if addr, ok := parseRemoteIP(strings.TrimSpace(strings.Split(raw, ",")[0])); ok {
|
||||||
|
return addr, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(headers.ForwardedFor, ",")
|
||||||
|
current := direct
|
||||||
|
found := false
|
||||||
|
for i := len(parts) - 1; i >= 0 && ipInRanges(current, trusted); i-- {
|
||||||
|
addr, ok := parseRemoteIP(strings.TrimSpace(parts[i]))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = addr
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return current, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipInRanges(addr netip.Addr, ranges []string) bool {
|
||||||
|
addr = addr.Unmap()
|
||||||
|
for _, raw := range ranges {
|
||||||
|
if strings.Contains(raw, "/") {
|
||||||
|
prefix, err := netip.ParsePrefix(raw)
|
||||||
|
if err == nil && prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate, err := netip.ParseAddr(raw)
|
||||||
|
if err == nil && candidate.Unmap() == addr {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizePanelAccessPolicy(t *testing.T) {
|
||||||
|
policy, err := NormalizePanelAccessPolicy(PanelAccessPolicy{
|
||||||
|
Enabled: true,
|
||||||
|
AllowedSources: []string{" 192.0.2.8 ", "10.20.30.44/24", "192.0.2.8", "2001:db8::1"},
|
||||||
|
TrustedProxies: []string{"127.0.0.1", "2001:db8:1::/64"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizePanelAccessPolicy() error = %v", err)
|
||||||
|
}
|
||||||
|
if want := []string{"192.0.2.8", "10.20.30.0/24", "2001:db8::1"}; !reflect.DeepEqual(policy.AllowedSources, want) {
|
||||||
|
t.Fatalf("AllowedSources = %#v, want %#v", policy.AllowedSources, want)
|
||||||
|
}
|
||||||
|
if want := []string{"127.0.0.1", "2001:db8:1::/64"}; !reflect.DeepEqual(policy.TrustedProxies, want) {
|
||||||
|
t.Fatalf("TrustedProxies = %#v, want %#v", policy.TrustedProxies, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePanelAccessPolicyRejectsEmptyEnabledPolicy(t *testing.T) {
|
||||||
|
if _, err := NormalizePanelAccessPolicy(PanelAccessPolicy{Enabled: true}); err == nil {
|
||||||
|
t.Fatal("expected enabled empty policy to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluatePanelAccess(t *testing.T) {
|
||||||
|
base := PanelAccessPolicy{
|
||||||
|
Enabled: true,
|
||||||
|
AllowedSources: []string{"192.0.2.0/24", "2001:db8::/32"},
|
||||||
|
TrustedProxies: []string{"10.0.0.1", "127.0.0.1"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
policy PanelAccessPolicy
|
||||||
|
remote string
|
||||||
|
headers ForwardedClientHeaders
|
||||||
|
allowed bool
|
||||||
|
current string
|
||||||
|
usedForwarded bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled",
|
||||||
|
policy: PanelAccessPolicy{},
|
||||||
|
remote: "198.51.100.9:44321",
|
||||||
|
allowed: true,
|
||||||
|
current: "198.51.100.9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "direct CIDR match",
|
||||||
|
policy: base,
|
||||||
|
remote: "192.0.2.25:44321",
|
||||||
|
allowed: true,
|
||||||
|
current: "192.0.2.25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "direct denied",
|
||||||
|
policy: base,
|
||||||
|
remote: "198.51.100.9:44321",
|
||||||
|
allowed: false,
|
||||||
|
current: "198.51.100.9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "spoofed forwarding header ignored",
|
||||||
|
policy: base,
|
||||||
|
remote: "198.51.100.9:44321",
|
||||||
|
headers: ForwardedClientHeaders{
|
||||||
|
ForwardedFor: "192.0.2.10",
|
||||||
|
},
|
||||||
|
allowed: false,
|
||||||
|
current: "198.51.100.9",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted proxy forwards allowed source",
|
||||||
|
policy: base,
|
||||||
|
remote: "10.0.0.1:44321",
|
||||||
|
headers: ForwardedClientHeaders{
|
||||||
|
ForwardedFor: "192.0.2.10",
|
||||||
|
},
|
||||||
|
allowed: true,
|
||||||
|
current: "192.0.2.10",
|
||||||
|
usedForwarded: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted proxy forwards denied source",
|
||||||
|
policy: base,
|
||||||
|
remote: "10.0.0.1:44321",
|
||||||
|
headers: ForwardedClientHeaders{
|
||||||
|
RealIP: "198.51.100.20",
|
||||||
|
},
|
||||||
|
allowed: false,
|
||||||
|
current: "198.51.100.20",
|
||||||
|
usedForwarded: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "direct loopback recovery",
|
||||||
|
policy: base,
|
||||||
|
remote: "127.0.0.1:44321",
|
||||||
|
allowed: true,
|
||||||
|
current: "127.0.0.1",
|
||||||
|
usedForwarded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted loopback proxy is enforced",
|
||||||
|
policy: base,
|
||||||
|
remote: "127.0.0.1:44321",
|
||||||
|
headers: ForwardedClientHeaders{
|
||||||
|
ForwardedFor: "198.51.100.20",
|
||||||
|
},
|
||||||
|
allowed: false,
|
||||||
|
current: "198.51.100.20",
|
||||||
|
usedForwarded: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 source",
|
||||||
|
policy: base,
|
||||||
|
remote: "[2001:db8::88]:44321",
|
||||||
|
allowed: true,
|
||||||
|
current: "2001:db8::88",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trusted proxy chain",
|
||||||
|
policy: base,
|
||||||
|
remote: "10.0.0.1:44321",
|
||||||
|
headers: ForwardedClientHeaders{
|
||||||
|
ForwardedFor: "192.0.2.70, 10.0.0.1",
|
||||||
|
},
|
||||||
|
allowed: true,
|
||||||
|
current: "192.0.2.70",
|
||||||
|
usedForwarded: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := EvaluatePanelAccess(tt.policy, tt.remote, tt.headers)
|
||||||
|
if got.Allowed != tt.allowed || got.CurrentSource != tt.current || got.UsedForwarded != tt.usedForwarded {
|
||||||
|
t.Fatalf("EvaluatePanelAccess() = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeStoragePoolsReplacesPersistedCustomPath(t *testing.T) {
|
||||||
|
previousConfig := AppConfig
|
||||||
|
t.Cleanup(func() { AppConfig = previousConfig })
|
||||||
|
mountPoint := filepath.Join(t.TempDir(), "data")
|
||||||
|
|
||||||
|
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
|
||||||
|
ID: "data",
|
||||||
|
Name: "data",
|
||||||
|
Path: filepath.Join(t.TempDir(), "uncontrolled"),
|
||||||
|
MountPoint: mountPoint,
|
||||||
|
Enabled: true,
|
||||||
|
}}}
|
||||||
|
if !normalizeStoragePools() {
|
||||||
|
t.Fatal("expected custom path normalization to report a change")
|
||||||
|
}
|
||||||
|
want := managedStoragePoolPath(mountPoint)
|
||||||
|
if got := AppConfig.StoragePools[0].Path; got != want {
|
||||||
|
t.Fatalf("normalized path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectStoragePoolForContent(t *testing.T) {
|
||||||
|
previousConfig := AppConfig
|
||||||
|
previousProbe := probeStoragePoolFreeBytes
|
||||||
|
t.Cleanup(func() {
|
||||||
|
AppConfig = previousConfig
|
||||||
|
probeStoragePoolFreeBytes = previousProbe
|
||||||
|
})
|
||||||
|
|
||||||
|
AppConfig = &ClicdConfig{StoragePools: []StoragePool{
|
||||||
|
{
|
||||||
|
ID: "primary",
|
||||||
|
Path: "/primary",
|
||||||
|
ContentTypes: []string{StorageContentLXC},
|
||||||
|
DefaultContents: []string{StorageContentLXC},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "large",
|
||||||
|
Path: "/large",
|
||||||
|
ContentTypes: []string{StorageContentLXC},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "small",
|
||||||
|
Path: "/small",
|
||||||
|
ContentTypes: []string{StorageContentLXC},
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
free := map[string]int64{
|
||||||
|
"primary": 20 * 1024 * 1024 * 1024,
|
||||||
|
"large": 50 * 1024 * 1024 * 1024,
|
||||||
|
"small": 10 * 1024 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
probeStoragePoolFreeBytes = func(pool StoragePool) (int64, bool) {
|
||||||
|
value, ok := free[pool.ID]
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pool.ID != "primary" {
|
||||||
|
t.Fatalf("selected %q, want configured default primary", pool.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
free["primary"] = 128 * 1024 * 1024
|
||||||
|
pool, err = SelectStoragePoolForContent(StorageContentLXC, "", 5*1024*1024*1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pool.ID != "large" {
|
||||||
|
t.Fatalf("selected %q, want largest fallback pool", pool.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err = SelectStoragePoolForContent(StorageContentLXC, "small", 5*1024*1024*1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pool.ID != "small" {
|
||||||
|
t.Fatalf("selected %q, want requested pool", pool.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectStoragePoolRequiresEnabledContent(t *testing.T) {
|
||||||
|
previousConfig := AppConfig
|
||||||
|
previousProbe := probeStoragePoolFreeBytes
|
||||||
|
t.Cleanup(func() {
|
||||||
|
AppConfig = previousConfig
|
||||||
|
probeStoragePoolFreeBytes = previousProbe
|
||||||
|
})
|
||||||
|
|
||||||
|
AppConfig = &ClicdConfig{StoragePools: []StoragePool{{
|
||||||
|
ID: "primary",
|
||||||
|
Path: "/primary",
|
||||||
|
ContentTypes: []string{StorageContentLXC},
|
||||||
|
Enabled: true,
|
||||||
|
}}}
|
||||||
|
probeStoragePoolFreeBytes = func(StoragePool) (int64, bool) { return 100 * 1024 * 1024 * 1024, true }
|
||||||
|
|
||||||
|
if _, err := SelectStoragePoolForContent(StorageContentSnapshots, "", 0); err == nil {
|
||||||
|
t.Fatal("expected snapshots selection to fail when no pool enables snapshots")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,37 +20,47 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type savedTaskConfig struct {
|
type savedTaskConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Virtualization string `json:"virtualization,omitempty"`
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
StoragePoolID string `json:"storage_pool_id,omitempty"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
VCPU float64 `json:"vcpu"`
|
||||||
RAMMB int `json:"ram_mb"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
DiskGB int `json:"disk_gb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
NetworkBWMbps int `json:"network_bw_mbps"`
|
DiskGB int `json:"disk_gb"`
|
||||||
NetworkDownMbps int `json:"network_down_mbps"`
|
NetworkBWMbps int `json:"network_bw_mbps"`
|
||||||
NetworkUpMbps int `json:"network_up_mbps"`
|
NetworkDownMbps int `json:"network_down_mbps"`
|
||||||
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
NetworkUpMbps int `json:"network_up_mbps"`
|
||||||
TrafficMode string `json:"traffic_mode"`
|
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
|
||||||
TrafficInGB int `json:"traffic_in_gb"`
|
TrafficMode string `json:"traffic_mode"`
|
||||||
TrafficOutGB int `json:"traffic_out_gb"`
|
TrafficInGB int `json:"traffic_in_gb"`
|
||||||
IOSpeedMBps int `json:"io_speed_mbps"`
|
TrafficOutGB int `json:"traffic_out_gb"`
|
||||||
IOReadMBps int `json:"io_read_mbps"`
|
IOSpeedMBps int `json:"io_speed_mbps"`
|
||||||
IOWriteMBps int `json:"io_write_mbps"`
|
IOReadMBps int `json:"io_read_mbps"`
|
||||||
ExtraPorts []int `json:"extra_ports"`
|
IOWriteMBps int `json:"io_write_mbps"`
|
||||||
PortMappingCount int `json:"port_mapping_count"`
|
ExtraPorts []int `json:"extra_ports"`
|
||||||
AssignNAT *bool `json:"assign_nat,omitempty"`
|
NATPortMappings []PortMapping `json:"nat_port_mappings,omitempty"`
|
||||||
SnapshotLimit int `json:"snapshot_limit"`
|
ManagementPort int `json:"management_port,omitempty"`
|
||||||
AssignIPv4 bool `json:"assign_ipv4"`
|
PortMappingCount int `json:"port_mapping_count"`
|
||||||
IPv4Count int `json:"ipv4_count,omitempty"`
|
AssignNAT *bool `json:"assign_nat,omitempty"`
|
||||||
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
LANIPv4Mode string `json:"lan_ipv4_mode,omitempty"`
|
||||||
AssignIPv6 bool `json:"assign_ipv6"`
|
LANInterface string `json:"lan_interface,omitempty"`
|
||||||
IPv6Count int `json:"ipv6_count,omitempty"`
|
LANIPv4Address string `json:"lan_ipv4_address,omitempty"`
|
||||||
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
LANIPv4PrefixLen int `json:"lan_ipv4_prefix_len,omitempty"`
|
||||||
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
LANIPv4Gateway string `json:"lan_ipv4_gateway,omitempty"`
|
||||||
SSHPassword string `json:"ssh_password,omitempty"`
|
SnapshotLimit int `json:"snapshot_limit"`
|
||||||
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
AllowedImageIDs []string `json:"allowed_image_ids,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at"`
|
ImageLimitConfigured bool `json:"image_limit_configured,omitempty"`
|
||||||
|
AssignIPv4 bool `json:"assign_ipv4"`
|
||||||
|
IPv4Count int `json:"ipv4_count,omitempty"`
|
||||||
|
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
|
||||||
|
AssignIPv6 bool `json:"assign_ipv6"`
|
||||||
|
IPv6Count int `json:"ipv6_count,omitempty"`
|
||||||
|
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
|
||||||
|
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
|
||||||
|
SSHPassword string `json:"ssh_password,omitempty"`
|
||||||
|
SSHPublicKey string `json:"ssh_public_key,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
func parseSavedTaskConfig(raw string) savedTaskConfig {
|
||||||
@@ -183,6 +193,8 @@ func ensureSchema() error {
|
|||||||
lxc_name TEXT,
|
lxc_name TEXT,
|
||||||
kvm_name TEXT,
|
kvm_name TEXT,
|
||||||
disk_image TEXT,
|
disk_image TEXT,
|
||||||
|
storage_pool_id TEXT,
|
||||||
|
storage_path TEXT,
|
||||||
mac_address TEXT,
|
mac_address TEXT,
|
||||||
template TEXT,
|
template TEXT,
|
||||||
vcpu REAL,
|
vcpu REAL,
|
||||||
@@ -202,7 +214,13 @@ func ensureSchema() error {
|
|||||||
io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||||
io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||||
status TEXT,
|
status TEXT,
|
||||||
|
restore_on_host_boot INTEGER NOT NULL DEFAULT 0,
|
||||||
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 +240,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 +278,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,
|
||||||
@@ -336,8 +358,14 @@ func ensureSchema() error {
|
|||||||
cfg_io_speed_mbps INTEGER,
|
cfg_io_speed_mbps INTEGER,
|
||||||
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
|
||||||
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cfg_management_port 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 +376,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 (
|
||||||
@@ -356,6 +386,15 @@ func ensureSchema() error {
|
|||||||
port INTEGER NOT NULL,
|
port INTEGER NOT NULL,
|
||||||
PRIMARY KEY (task_id, position)
|
PRIMARY KEY (task_id, position)
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS task_nat_port_mappings (
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
host_port INTEGER NOT NULL,
|
||||||
|
container_port INTEGER NOT NULL,
|
||||||
|
protocol TEXT,
|
||||||
|
description TEXT,
|
||||||
|
PRIMARY KEY (task_id, position)
|
||||||
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS login_logs (
|
`CREATE TABLE IF NOT EXISTS login_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
time TEXT,
|
time TEXT,
|
||||||
@@ -406,18 +445,28 @@ func ensureSchemaMigrations() error {
|
|||||||
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"tasks", "cfg_io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
{"tasks", "cfg_io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"tasks", "cfg_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
{"tasks", "cfg_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"tasks", "cfg_management_port", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
{"tasks", "cfg_assign_ipv4", "INTEGER"},
|
||||||
{"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 +474,16 @@ 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", "restore_on_host_boot", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"containers", "storage_pool_id", "TEXT"},
|
||||||
|
{"containers", "storage_path", "TEXT"},
|
||||||
|
{"containers", "lan_ipv4_mode", "TEXT"},
|
||||||
|
{"containers", "lan_interface", "TEXT"},
|
||||||
|
{"containers", "lan_ipv4_address", "TEXT NOT NULL DEFAULT ''"},
|
||||||
|
{"containers", "lan_ipv4_prefix_len", "INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"containers", "lan_ipv4_gateway", "TEXT NOT NULL DEFAULT ''"},
|
||||||
} {
|
} {
|
||||||
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 +525,27 @@ func ensureSchemaMigrations() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE containers
|
||||||
|
SET lan_ipv4_mode = COALESCE(lan_ipv4_mode, ''),
|
||||||
|
lan_interface = COALESCE(lan_interface, ''),
|
||||||
|
lan_ipv4_address = COALESCE(lan_ipv4_address, ''),
|
||||||
|
lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0),
|
||||||
|
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE containers
|
||||||
|
SET storage_pool_id = COALESCE(storage_pool_id, ''),
|
||||||
|
storage_path = COALESCE(storage_path, '')`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE tasks
|
||||||
|
SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
|
||||||
|
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
|
||||||
|
cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''),
|
||||||
|
cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0),
|
||||||
|
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,8 +604,13 @@ 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"]),
|
||||||
|
LXCNATSubnet: meta["lxc_nat_subnet"],
|
||||||
|
KVMNATSubnet: meta["kvm_nat_subnet"],
|
||||||
SetupComplete: atob(meta["setup_complete"]),
|
SetupComplete: atob(meta["setup_complete"]),
|
||||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||||
|
TaskConcurrency: atoi(meta["task_concurrency"]),
|
||||||
Language: meta["language"],
|
Language: meta["language"],
|
||||||
}
|
}
|
||||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||||
@@ -543,6 +628,18 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
|||||||
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
|
||||||
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
|
||||||
}
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["panel_access_policy"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.PanelAccessPolicy)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["storage_pools"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.StoragePools)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["custom_kvm_images"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.CustomKVMImages)
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(meta["custom_lxc_images"]); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &cfg.CustomLXCImages)
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Containers, err = loadContainers(); err != nil {
|
if cfg.Containers, err = loadContainers(); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
@@ -595,6 +692,7 @@ func saveConfigToDB() error {
|
|||||||
"api_keys",
|
"api_keys",
|
||||||
"audit_logs",
|
"audit_logs",
|
||||||
"task_extra_ports",
|
"task_extra_ports",
|
||||||
|
"task_nat_port_mappings",
|
||||||
"tasks",
|
"tasks",
|
||||||
"login_logs",
|
"login_logs",
|
||||||
"enabled_images",
|
"enabled_images",
|
||||||
@@ -642,6 +740,10 @@ func saveMeta(tx *sql.Tx) error {
|
|||||||
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
|
||||||
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
|
||||||
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
|
||||||
|
panelAccessPolicyJSON, _ := json.Marshal(AppConfig.PanelAccessPolicy)
|
||||||
|
storagePoolsJSON, _ := json.Marshal(AppConfig.StoragePools)
|
||||||
|
customKVMImagesJSON, _ := json.Marshal(AppConfig.CustomKVMImages)
|
||||||
|
customLXCImagesJSON, _ := json.Marshal(AppConfig.CustomLXCImages)
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"admin_user": AppConfig.AdminUser,
|
"admin_user": AppConfig.AdminUser,
|
||||||
"admin_pass_hash": AppConfig.AdminPassHash,
|
"admin_pass_hash": AppConfig.AdminPassHash,
|
||||||
@@ -651,14 +753,23 @@ 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),
|
||||||
|
"lxc_nat_subnet": AppConfig.LXCNATSubnet,
|
||||||
|
"kvm_nat_subnet": AppConfig.KVMNATSubnet,
|
||||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||||
|
"task_concurrency": strconv.Itoa(AppConfig.TaskConcurrency),
|
||||||
"language": NormalizeLanguage(AppConfig.Language),
|
"language": NormalizeLanguage(AppConfig.Language),
|
||||||
"ssl": string(sslJSON),
|
"ssl": string(sslJSON),
|
||||||
"ssl_certificates": string(sslCertificatesJSON),
|
"ssl_certificates": string(sslCertificatesJSON),
|
||||||
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
"public_ipv4_pool": string(publicIPv4PoolJSON),
|
||||||
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
|
||||||
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
|
||||||
|
"panel_access_policy": string(panelAccessPolicyJSON),
|
||||||
|
"storage_pools": string(storagePoolsJSON),
|
||||||
|
"custom_kvm_images": string(customKVMImagesJSON),
|
||||||
|
"custom_lxc_images": string(customLXCImagesJSON),
|
||||||
"schema_version": "1",
|
"schema_version": "1",
|
||||||
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
}
|
}
|
||||||
@@ -673,30 +784,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, storage_pool_id, storage_path, 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, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||||
|
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
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.StoragePoolID, c.StoragePath, 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, boolInt(c.RestoreOnHostBoot), c.IP, c.LANIPv4Mode, c.LANInterface, c.LANIPv4Address, c.LANIPv4PrefixLen, c.LANIPv4Gateway,
|
||||||
|
c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
|
||||||
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
|
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 +838,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 +949,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_management_port, 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.ManagementPort, 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
|
||||||
}
|
}
|
||||||
@@ -855,6 +972,14 @@ func saveTasksDB(tx *sql.Tx) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for i, mapping := range cfg.NATPortMappings {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO task_nat_port_mappings(task_id, position, host_port, container_port, protocol, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
task.ID, i, mapping.HostPort, mapping.ContainerPort, mapping.Protocol, mapping.Description,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -890,17 +1015,18 @@ func saveSnapshots(tx *sql.Tx) error {
|
|||||||
|
|
||||||
func loadContainers() ([]Container, error) {
|
func loadContainers() ([]Container, error) {
|
||||||
rows, err := db.Query(`SELECT
|
rows, err := db.Query(`SELECT
|
||||||
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
|
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, storage_pool_id, storage_path, mac_address, template,
|
||||||
vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
|
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, restore_on_host_boot, ip, lan_ipv4_mode, lan_interface, lan_ipv4_address, lan_ipv4_prefix_len, lan_ipv4_gateway,
|
||||||
|
ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
|
||||||
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
|
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 +1036,48 @@ 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, restoreOnHostBoot int
|
||||||
var firewallDefaultAction string
|
var firewallDefaultAction string
|
||||||
var firewallRulesJSON sql.NullString
|
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||||
|
var storagePoolID, storagePath sql.NullString
|
||||||
|
var lanIPv4Mode, lanInterface sql.NullString
|
||||||
|
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
||||||
|
var lanIPv4PrefixLen sql.NullInt64
|
||||||
if err := rows.Scan(
|
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, &storagePoolID, &storagePath, &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, &restoreOnHostBoot, &c.IP, &lanIPv4Mode, &lanInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway,
|
||||||
|
&c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
|
||||||
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
&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.StoragePoolID = storagePoolID.String
|
||||||
|
c.StoragePath = storagePath.String
|
||||||
|
c.LANIPv4Mode = lanIPv4Mode.String
|
||||||
|
c.LANInterface = lanInterface.String
|
||||||
|
c.LANIPv4Address = lanIPv4Address.String
|
||||||
|
if lanIPv4PrefixLen.Valid {
|
||||||
|
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||||
|
}
|
||||||
|
c.LANIPv4Gateway = lanIPv4Gateway.String
|
||||||
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
c.SnapshotScheduleEnabled = scheduleEnabled != 0
|
||||||
|
c.RestoreOnHostBoot = restoreOnHostBoot != 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 +1173,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 +1181,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 +1279,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_management_port, 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 +1293,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.ManagementPort, &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 +1316,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 +1336,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)
|
||||||
@@ -1195,6 +1353,10 @@ func loadTasks() ([]SavedTask, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
configs[i].NATPortMappings, err = loadTaskNATPortMappings(result[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
result[i].Config = encodeSavedTaskConfig(configs[i])
|
result[i].Config = encodeSavedTaskConfig(configs[i])
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -1217,6 +1379,24 @@ func loadTaskExtraPorts(taskID string) ([]int, error) {
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadTaskNATPortMappings(taskID string) ([]PortMapping, error) {
|
||||||
|
rows, err := db.Query(`SELECT host_port, container_port, protocol, description
|
||||||
|
FROM task_nat_port_mappings WHERE task_id = ? ORDER BY position`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := []PortMapping{}
|
||||||
|
for rows.Next() {
|
||||||
|
var mapping PortMapping
|
||||||
|
if err := rows.Scan(&mapping.HostPort, &mapping.ContainerPort, &mapping.Protocol, &mapping.Description); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, mapping)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func loadLoginLogs() ([]SavedLoginLog, error) {
|
func loadLoginLogs() ([]SavedLoginLog, error) {
|
||||||
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
|
rows, err := db.Query(`SELECT time, username, ip, user_agent, success FROM login_logs ORDER BY id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -63,9 +63,37 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
|||||||
ContainerName: "ct2",
|
ContainerName: "ct2",
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: "2026-06-07 17:29:02",
|
CreatedAt: "2026-06-07 17:29:02",
|
||||||
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"assign_ipv6":true}`,
|
Config: `{"name":"ct2","template_id":"debian-12","vcpu":1,"ram_mb":512,"disk_gb":5,"extra_ports":[80,443],"nat_port_mappings":[{"host_port":30080,"container_port":80,"protocol":"tcp","description":"HTTP"}],"management_port":30022,"assign_ipv6":true}`,
|
||||||
}},
|
}},
|
||||||
EnabledImages: []string{"debian-12"},
|
EnabledImages: []string{"debian-12"},
|
||||||
|
CustomKVMImages: []CustomKVMImage{{
|
||||||
|
ID: "custom-kvm-test",
|
||||||
|
Name: "Test Cloud Image",
|
||||||
|
Description: "third-party image",
|
||||||
|
Distro: "ubuntu",
|
||||||
|
Release: "noble",
|
||||||
|
Arch: "amd64",
|
||||||
|
URL: "https://images.example.test/ubuntu.qcow2",
|
||||||
|
Provisioner: KVMProvisionerLinuxCloudInit,
|
||||||
|
SHA256: strings.Repeat("a", 64),
|
||||||
|
CreatedAt: "2026-07-26 10:00:00",
|
||||||
|
}},
|
||||||
|
CustomLXCImages: []CustomLXCImage{{
|
||||||
|
ID: "custom-lxc-test",
|
||||||
|
Name: "Test Rootfs",
|
||||||
|
Description: "third-party LXC image",
|
||||||
|
Distro: "alpine",
|
||||||
|
Release: "3.21",
|
||||||
|
Arch: "amd64",
|
||||||
|
URL: "https://images.example.test/alpine-rootfs.tar.xz",
|
||||||
|
SHA256: strings.Repeat("b", 64),
|
||||||
|
CreatedAt: "2026-07-26 10:00:00",
|
||||||
|
}},
|
||||||
|
PanelAccessPolicy: PanelAccessPolicy{
|
||||||
|
Enabled: true,
|
||||||
|
AllowedSources: []string{"192.0.2.0/24"},
|
||||||
|
TrustedProxies: []string{"127.0.0.1"},
|
||||||
|
},
|
||||||
Snapshots: []Snapshot{{
|
Snapshots: []Snapshot{{
|
||||||
ID: "snap-1",
|
ID: "snap-1",
|
||||||
ContainerID: 1,
|
ContainerID: 1,
|
||||||
@@ -93,11 +121,30 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
|||||||
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
|
if len(cfg.Tasks) != 1 || !strings.Contains(cfg.Tasks[0].Config, `"extra_ports":[80,443]`) {
|
||||||
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
|
t.Fatalf("task config was not restored from sqlite columns: %+v", cfg.Tasks)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(cfg.Tasks[0].Config, `"nat_port_mappings":[{"host_port":30080,"container_port":80`) {
|
||||||
|
t.Fatalf("task NAT mappings were not restored from sqlite: %+v", cfg.Tasks)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cfg.Tasks[0].Config, `"management_port":30022`) {
|
||||||
|
t.Fatalf("task management port was not restored from sqlite: %+v", cfg.Tasks)
|
||||||
|
}
|
||||||
|
if cfg.TaskConcurrency != DefaultTaskConcurrency {
|
||||||
|
t.Fatalf("legacy task concurrency = %d, want default %d", cfg.TaskConcurrency, DefaultTaskConcurrency)
|
||||||
|
}
|
||||||
|
if !cfg.PanelAccessPolicy.Enabled || len(cfg.PanelAccessPolicy.AllowedSources) != 1 {
|
||||||
|
t.Fatalf("legacy panel access policy was not migrated: %+v", cfg.PanelAccessPolicy)
|
||||||
|
}
|
||||||
|
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].ID != "custom-kvm-test" {
|
||||||
|
t.Fatalf("legacy custom KVM images were not migrated: %+v", cfg.CustomKVMImages)
|
||||||
|
}
|
||||||
|
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].ID != "custom-lxc-test" {
|
||||||
|
t.Fatalf("legacy custom LXC images were not migrated: %+v", cfg.CustomLXCImages)
|
||||||
|
}
|
||||||
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
|
if _, err := os.Stat(filepath.Join(dir, "config.db")); err != nil {
|
||||||
t.Fatalf("sqlite database was not created: %v", err)
|
t.Fatalf("sqlite database was not created: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.Containers[0].Status = "stopped"
|
cfg.Containers[0].Status = "stopped"
|
||||||
|
cfg.TaskConcurrency = 6
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -111,6 +158,18 @@ func TestSQLiteConfigMigratesLegacyJSONAndPersists(t *testing.T) {
|
|||||||
if got := cfg.Containers[0].Status; got != "stopped" {
|
if got := cfg.Containers[0].Status; got != "stopped" {
|
||||||
t.Fatalf("expected sqlite value to win after migration, got %q", got)
|
t.Fatalf("expected sqlite value to win after migration, got %q", got)
|
||||||
}
|
}
|
||||||
|
if got := cfg.TaskConcurrency; got != 6 {
|
||||||
|
t.Fatalf("persisted task concurrency = %d, want 6", got)
|
||||||
|
}
|
||||||
|
if !cfg.PanelAccessPolicy.Enabled || cfg.PanelAccessPolicy.AllowedSources[0] != "192.0.2.0/24" {
|
||||||
|
t.Fatalf("persisted panel access policy = %+v", cfg.PanelAccessPolicy)
|
||||||
|
}
|
||||||
|
if len(cfg.CustomKVMImages) != 1 || cfg.CustomKVMImages[0].SHA256 != strings.Repeat("a", 64) {
|
||||||
|
t.Fatalf("persisted custom KVM images = %+v", cfg.CustomKVMImages)
|
||||||
|
}
|
||||||
|
if len(cfg.CustomLXCImages) != 1 || cfg.CustomLXCImages[0].SHA256 != strings.Repeat("b", 64) {
|
||||||
|
t.Fatalf("persisted custom LXC images = %+v", cfg.CustomLXCImages)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetConfigStoreForTest(t *testing.T) {
|
func resetConfigStoreForTest(t *testing.T) {
|
||||||
|
|||||||
+477
-151
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,14 @@ package kvm
|
|||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
@@ -11,6 +18,84 @@ import (
|
|||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestImagePathUsesAllowlistedImageID(t *testing.T) {
|
||||||
|
for _, id := range []string{"", ".", "..", "../../etc/passwd", `..\\..\\windows`, "/absolute", "unknown-image"} {
|
||||||
|
if got := filepath.Base(ImagePath(id)); got != "__invalid_image_id__.qcow2" {
|
||||||
|
t.Fatalf("ImagePath(%q) basename = %q", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validID := GetImages()[0].ID
|
||||||
|
if got := filepath.Base(ImagePath(validID)); got != validID+".qcow2" {
|
||||||
|
t.Fatalf("ImagePath(%q) basename = %q", validID, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows11ImageDefinition(t *testing.T) {
|
||||||
|
image := FindImage("kvm-windows-11")
|
||||||
|
if image == nil {
|
||||||
|
t.Fatal("Windows 11 image is missing from the amd64 image list")
|
||||||
|
}
|
||||||
|
if image.Distro != "windows" || image.Release != "11" || image.Arch != "amd64" {
|
||||||
|
t.Fatalf("Windows 11 image metadata = %+v", image)
|
||||||
|
}
|
||||||
|
if !strings.Contains(image.URL, "microsoft.com/fwlink/") {
|
||||||
|
t.Fatalf("Windows 11 image does not use an official Microsoft URL: %s", image.URL)
|
||||||
|
}
|
||||||
|
if got := filepath.Base(ImagePath(image.ID)); got != "kvm-windows-11.iso" {
|
||||||
|
t.Fatalf("Windows 11 image basename = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindows11UnattendAddsCompatibilityChecksOnlyForWindows11(t *testing.T) {
|
||||||
|
windows11 := windowsAutounattendXML("win11-test", "Password123!", true)
|
||||||
|
windows10 := windowsAutounattendXML("win10-test", "Password123!", false)
|
||||||
|
|
||||||
|
for _, key := range []string{"BypassTPMCheck", "BypassSecureBootCheck", "BypassCPUCheck"} {
|
||||||
|
if !strings.Contains(windows11, key) {
|
||||||
|
t.Fatalf("Windows 11 unattend is missing %s", key)
|
||||||
|
}
|
||||||
|
if strings.Contains(windows10, key) {
|
||||||
|
t.Fatalf("Windows 10 unattend unexpectedly contains %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var document struct {
|
||||||
|
XMLName xml.Name
|
||||||
|
}
|
||||||
|
if err := xml.Unmarshal([]byte(windows11), &document); err != nil {
|
||||||
|
t.Fatalf("Windows 11 unattend XML is invalid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowsMinimumResources(t *testing.T) {
|
||||||
|
if cpu, ram, disk := windowsMinimumResources("kvm-windows-11"); cpu != 2 || ram != 4096 || disk != 64 {
|
||||||
|
t.Fatalf("Windows 11 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||||
|
}
|
||||||
|
if cpu, ram, disk := windowsMinimumResources("kvm-windows-10"); cpu != 1 || ram != 2048 || disk != 30 {
|
||||||
|
t.Fatalf("Windows 10 minimums = %v vCPU, %d MB, %d GB", cpu, ram, disk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibvirtNetworkActiveParsesCLocaleOutput(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
info string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "active", info: "Name: default\nActive: yes\n", want: true},
|
||||||
|
{name: "spacing and case", info: " Active : YES \r\n", want: true},
|
||||||
|
{name: "inactive", info: "Name: default\nActive: no\n", want: false},
|
||||||
|
{name: "missing field", info: "Name: default\nAutostart: yes\n", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := libvirtNetworkActive(tc.info); got != tc.want {
|
||||||
|
t.Fatalf("libvirtNetworkActive(%q) = %v, want %v", tc.info, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
|
func TestChpasswdStdinPreservesShellMetacharacters(t *testing.T) {
|
||||||
password := `pa'";$(touch /tmp/pwned); echo #\\word`
|
password := `pa'";$(touch /tmp/pwned); echo #\\word`
|
||||||
got, err := chpasswdStdin("root", password)
|
got, err := chpasswdStdin("root", password)
|
||||||
@@ -81,6 +166,77 @@ func TestVerifyKVMHostKeyCapturesAndRejectsMismatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetImagesIncludesHostArchitectureCustomImage(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{
|
||||||
|
CustomKVMImages: []config.CustomKVMImage{
|
||||||
|
{
|
||||||
|
ID: "custom-kvm-linux",
|
||||||
|
Name: "Custom Linux",
|
||||||
|
Distro: "ubuntu",
|
||||||
|
Release: "noble",
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
URL: "https://example.test/linux.qcow2",
|
||||||
|
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "custom-kvm-other-arch",
|
||||||
|
Name: "Other Architecture",
|
||||||
|
Distro: "ubuntu",
|
||||||
|
Release: "noble",
|
||||||
|
Arch: "not-" + runtime.GOARCH,
|
||||||
|
URL: "https://example.test/other.qcow2",
|
||||||
|
Provisioner: config.KVMProvisionerLinuxCloudInit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
image := FindImage("custom-kvm-linux")
|
||||||
|
if image == nil || !image.Custom || image.Provisioner != config.KVMProvisionerLinuxCloudInit {
|
||||||
|
t.Fatalf("custom image was not exposed correctly: %+v", image)
|
||||||
|
}
|
||||||
|
if FindImage("custom-kvm-other-arch") != nil {
|
||||||
|
t.Fatal("custom image for another architecture was exposed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomWindowsProvisionerControlsImageType(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{CustomKVMImages: []config.CustomKVMImage{{
|
||||||
|
ID: "custom-kvm-windows",
|
||||||
|
Name: "Custom Windows",
|
||||||
|
Distro: "windows",
|
||||||
|
Release: "11",
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
URL: "https://example.test/windows.iso",
|
||||||
|
Provisioner: config.KVMProvisionerWindows11,
|
||||||
|
}}}
|
||||||
|
|
||||||
|
if !IsWindowsImage("custom-kvm-windows") || !IsWindows11Image("custom-kvm-windows") {
|
||||||
|
t.Fatal("custom Windows 11 provisioner was not recognized")
|
||||||
|
}
|
||||||
|
if ext := filepath.Ext(ImagePath("custom-kvm-windows")); ext != ".iso" {
|
||||||
|
t.Fatalf("custom Windows image extension = %q, want .iso", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyFileSHA256(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "image")
|
||||||
|
content := []byte("clicd custom image")
|
||||||
|
if err := os.WriteFile(path, content, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(content)
|
||||||
|
if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil {
|
||||||
|
t.Fatalf("valid checksum failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := verifyFileSHA256(path, strings.Repeat("0", 64)); err == nil {
|
||||||
|
t.Fatal("invalid checksum unexpectedly passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
|
func testSSHPublicKey(t *testing.T) ssh.PublicKey {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
@@ -13,9 +17,40 @@ type Image struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Desktop string `json:"desktop,omitempty"`
|
Desktop string `json:"desktop,omitempty"`
|
||||||
|
Provisioner string `json:"provisioner,omitempty"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
Custom bool `json:"custom,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetImages() []Image {
|
func GetImages() []Image {
|
||||||
|
var images []Image
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
images = arm64Images()
|
||||||
|
default:
|
||||||
|
images = amd64Images()
|
||||||
|
}
|
||||||
|
for _, custom := range config.ListCustomKVMImages() {
|
||||||
|
if custom.Arch != runtime.GOARCH {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
images = append(images, Image{
|
||||||
|
ID: custom.ID,
|
||||||
|
Name: custom.Name,
|
||||||
|
Distro: custom.Distro,
|
||||||
|
Release: custom.Release,
|
||||||
|
Arch: custom.Arch,
|
||||||
|
Description: custom.Description,
|
||||||
|
URL: custom.URL,
|
||||||
|
Provisioner: custom.Provisioner,
|
||||||
|
SHA256: custom.SHA256,
|
||||||
|
Custom: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +71,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",
|
||||||
@@ -85,6 +133,12 @@ func GetImages() []Image {
|
|||||||
Description: "Rocky Linux 9 GenericCloud image for KVM",
|
Description: "Rocky Linux 9 GenericCloud image for KVM",
|
||||||
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
|
URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-windows-11", Name: "Windows 11 KVM",
|
||||||
|
Distro: "windows", Release: "11", Arch: "amd64",
|
||||||
|
Description: "Windows 11 Enterprise LTSC 2024 Evaluation",
|
||||||
|
URL: "https://go.microsoft.com/fwlink/?clcid=0x409&country=us&culture=en-us&linkid=2289029",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
||||||
Distro: "windows", Release: "10", Arch: "amd64",
|
Distro: "windows", Release: "10", Arch: "amd64",
|
||||||
@@ -94,6 +148,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 {
|
||||||
@@ -104,22 +211,56 @@ func FindImage(id string) *Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CacheDir() string {
|
func CacheDir() string {
|
||||||
|
if pool := config.PreferredStoragePoolForContent(config.StorageContentImages); pool != nil {
|
||||||
|
return filepath.Join(pool.Path, "images", "kvm")
|
||||||
|
}
|
||||||
return filepath.Join(BaseDir(), "images")
|
return filepath.Join(BaseDir(), "images")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ImagePath(id string) string {
|
func ImagePath(id string) string {
|
||||||
img := FindImage(id)
|
img := FindImage(id)
|
||||||
ext := ".qcow2"
|
ext := ".qcow2"
|
||||||
if img != nil && img.Distro == "windows" {
|
safeID := "__invalid_image_id__"
|
||||||
|
if img != nil {
|
||||||
|
safeID = img.ID
|
||||||
|
}
|
||||||
|
if img != nil && img.IsWindows() {
|
||||||
ext = ".iso"
|
ext = ".iso"
|
||||||
}
|
}
|
||||||
return filepath.Join(CacheDir(), id+ext)
|
fileName := safeID + ext
|
||||||
|
for _, pool := range config.StoragePoolsForContent(config.StorageContentImages) {
|
||||||
|
candidate := filepath.Join(pool.Path, "images", "kvm", fileName)
|
||||||
|
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legacy := filepath.Join("/var/lib/clicd/kvm/images", fileName)
|
||||||
|
if info, err := os.Stat(legacy); err == nil && !info.IsDir() {
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
return filepath.Join(CacheDir(), fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsWindowsImage returns true if the image distro is "windows".
|
func (image Image) IsWindows() bool {
|
||||||
|
return image.Provisioner == config.KVMProvisionerWindows10 ||
|
||||||
|
image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||||
|
(image.Provisioner == "" && image.Distro == "windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (image Image) IsWindows11() bool {
|
||||||
|
return image.Provisioner == config.KVMProvisionerWindows11 ||
|
||||||
|
(image.Provisioner == "" && image.Distro == "windows" && image.Release == "11")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWindowsImage returns true if the image uses Windows unattended installation.
|
||||||
func IsWindowsImage(id string) bool {
|
func IsWindowsImage(id string) bool {
|
||||||
img := FindImage(id)
|
img := FindImage(id)
|
||||||
return img != nil && img.Distro == "windows"
|
return img != nil && img.IsWindows()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsWindows11Image(id string) bool {
|
||||||
|
img := FindImage(id)
|
||||||
|
return img != nil && img.IsWindows11()
|
||||||
}
|
}
|
||||||
|
|
||||||
func virtioWinISOPath() string {
|
func virtioWinISOPath() string {
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package lxc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"clicd/internal/safehttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomImageDownloadProgress struct {
|
||||||
|
Stage string
|
||||||
|
DownloadedBytes int64
|
||||||
|
TotalBytes int64
|
||||||
|
Percent int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomImageDownloadProgressFunc func(CustomImageDownloadProgress)
|
||||||
|
|
||||||
|
func CustomImagePath(id string) string {
|
||||||
|
template := FindTemplate(id)
|
||||||
|
if template == nil || !template.Custom {
|
||||||
|
return filepath.Join("/var/cache/lxc/download/custom", "__invalid_image_id__", "rootfs.tar")
|
||||||
|
}
|
||||||
|
return filepath.Join("/var/cache/lxc/download/custom", template.ID, "rootfs.tar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func CustomImageDownloadedInfo(id string) (bool, int64) {
|
||||||
|
info, err := os.Stat(CustomImagePath(id))
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
return true, info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCustomImage(id string) error {
|
||||||
|
template := FindTemplate(id)
|
||||||
|
if template == nil || !template.Custom {
|
||||||
|
return fmt.Errorf("custom LXC image not found")
|
||||||
|
}
|
||||||
|
return os.RemoveAll(filepath.Dir(CustomImagePath(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadCustomImageWithProgress(ctx context.Context, template Template, progress CustomImageDownloadProgressFunc) error {
|
||||||
|
if !template.Custom {
|
||||||
|
return fmt.Errorf("template is not a custom LXC image")
|
||||||
|
}
|
||||||
|
target := CustomImagePath(template.ID)
|
||||||
|
if ok, _ := CustomImageDownloadedInfo(template.ID); ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := target + ".tmp"
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
if err := downloadCustomRootfs(ctx, template.URL, tmp, progress); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if template.SHA256 != "" {
|
||||||
|
if err := verifyCustomRootfsSHA256(tmp, template.SHA256); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress(CustomImageDownloadProgress{Stage: "validating", Percent: 100})
|
||||||
|
}
|
||||||
|
if err := ValidateCustomRootfsArchive(tmp); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, target); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chmod(target, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadCustomRootfs(ctx context.Context, sourceURL, target string, progress CustomImageDownloadProgressFunc) error {
|
||||||
|
response, err := safehttp.Get(ctx, sourceURL, "CLICD/1.0 LXC image downloader", 30*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("download failed: %s", response.Status)
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
total := response.ContentLength
|
||||||
|
buffer := make([]byte, 128*1024)
|
||||||
|
var downloaded int64
|
||||||
|
for {
|
||||||
|
count, readErr := response.Body.Read(buffer)
|
||||||
|
if count > 0 {
|
||||||
|
if _, err := file.Write(buffer[:count]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
downloaded += int64(count)
|
||||||
|
if progress != nil {
|
||||||
|
percent := 0
|
||||||
|
if total > 0 {
|
||||||
|
percent = int(downloaded * 100 / total)
|
||||||
|
if percent > 100 {
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress(CustomImageDownloadProgress{
|
||||||
|
Stage: "downloading",
|
||||||
|
DownloadedBytes: downloaded,
|
||||||
|
TotalBytes: total,
|
||||||
|
Percent: percent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if readErr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyCustomRootfsSHA256(filePath, expected string) error {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
actual := hex.EncodeToString(hash.Sum(nil))
|
||||||
|
if !strings.EqualFold(actual, strings.TrimSpace(expected)) {
|
||||||
|
return fmt.Errorf("SHA-256 mismatch: expected %s, got %s", expected, actual)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateCustomRootfsArchive(archivePath string) error {
|
||||||
|
command := exec.Command("tar", "-tf", archivePath)
|
||||||
|
stdout, err := command.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var stderr strings.Builder
|
||||||
|
command.Stderr = &stderr
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to inspect rootfs archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
entries := make([]string, 0, 4096)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if len(entries) >= 2_000_000 {
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
return fmt.Errorf("rootfs archive contains too many entries")
|
||||||
|
}
|
||||||
|
entries = append(entries, scanner.Text())
|
||||||
|
}
|
||||||
|
scanErr := scanner.Err()
|
||||||
|
waitErr := command.Wait()
|
||||||
|
if scanErr != nil {
|
||||||
|
return fmt.Errorf("failed to read rootfs archive: %v", scanErr)
|
||||||
|
}
|
||||||
|
if waitErr != nil {
|
||||||
|
return fmt.Errorf("invalid rootfs archive: %v, output: %s", waitErr, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return validateCustomRootfsEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCustomRootfsEntries(entries []string) error {
|
||||||
|
hasInit := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry = strings.TrimSpace(strings.ReplaceAll(entry, "\\", "/"))
|
||||||
|
entry = strings.TrimPrefix(entry, "./")
|
||||||
|
if entry == "" || entry == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(entry, "/") {
|
||||||
|
return fmt.Errorf("rootfs archive contains an absolute path: %s", entry)
|
||||||
|
}
|
||||||
|
clean := path.Clean(entry)
|
||||||
|
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||||
|
return fmt.Errorf("rootfs archive contains path traversal: %s", entry)
|
||||||
|
}
|
||||||
|
switch strings.TrimSuffix(clean, "/") {
|
||||||
|
case "sbin/init", "usr/lib/systemd/systemd", "lib/systemd/systemd", "bin/busybox", "bin/sh":
|
||||||
|
hasInit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return fmt.Errorf("rootfs archive is empty")
|
||||||
|
}
|
||||||
|
if !hasInit {
|
||||||
|
return fmt.Errorf("rootfs archive does not contain a supported init")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractCustomRootfs(templateID, destination string) error {
|
||||||
|
template := FindTemplate(templateID)
|
||||||
|
if template == nil || !template.Custom {
|
||||||
|
return fmt.Errorf("custom LXC image not found: %s", templateID)
|
||||||
|
}
|
||||||
|
archive := CustomImagePath(template.ID)
|
||||||
|
if ok, _ := CustomImageDownloadedInfo(template.ID); !ok {
|
||||||
|
return fmt.Errorf("custom LXC image is not downloaded: %s", templateID)
|
||||||
|
}
|
||||||
|
if err := ValidateCustomRootfsArchive(archive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(destination, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output, err := exec.Command("tar", "-xpf", archive, "-C", destination).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract custom LXC rootfs: %v, output: %s", err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
if err := secureExtractedRootfs(destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !rootfsHasInit(destination) {
|
||||||
|
return fmt.Errorf("extracted custom LXC rootfs is invalid: init not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureExtractedRootfs(root string) error {
|
||||||
|
root, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target, err := os.Readlink(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resolved string
|
||||||
|
if filepath.IsAbs(target) {
|
||||||
|
resolved = filepath.Join(root, strings.TrimLeft(filepath.ToSlash(target), "/"))
|
||||||
|
relative, err := filepath.Rel(filepath.Dir(filePath), resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Symlink(relative, filePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolved = filepath.Join(filepath.Dir(filePath), target)
|
||||||
|
}
|
||||||
|
relativeToRoot, err := filepath.Rel(root, filepath.Clean(resolved))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(os.PathSeparator)) {
|
||||||
|
return fmt.Errorf("rootfs symlink escapes the archive root: %s -> %s", filePath, target)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package lxc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetTemplatesIncludesHostArchitectureCustomLXCImage(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{CustomLXCImages: []config.CustomLXCImage{
|
||||||
|
{
|
||||||
|
ID: "custom-lxc-host", Name: "Host Rootfs", Distro: "alpine",
|
||||||
|
Release: "3.21", Arch: runtime.GOARCH, URL: "https://example.test/rootfs.tar.xz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "custom-lxc-other", Name: "Other Rootfs", Distro: "alpine",
|
||||||
|
Release: "3.21", Arch: "not-" + runtime.GOARCH, URL: "https://example.test/other.tar.xz",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
template := FindTemplate("custom-lxc-host")
|
||||||
|
if template == nil || !template.Custom || template.URL == "" {
|
||||||
|
t.Fatalf("custom LXC template was not exposed correctly: %+v", template)
|
||||||
|
}
|
||||||
|
if FindTemplate("custom-lxc-other") != nil {
|
||||||
|
t.Fatal("custom LXC template for another architecture was exposed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomImagePathUsesAllowlistedID(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{}
|
||||||
|
|
||||||
|
for _, id := range []string{"", ".", "..", "../../etc/passwd", "/absolute", "unknown"} {
|
||||||
|
got := filepath.ToSlash(CustomImagePath(id))
|
||||||
|
if filepath.Base(filepath.Dir(got)) != "__invalid_image_id__" {
|
||||||
|
t.Fatalf("CustomImagePath(%q) = %q", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateCustomRootfsEntries(t *testing.T) {
|
||||||
|
if err := validateCustomRootfsEntries([]string{"./etc/", "./bin/", "./bin/sh"}); err != nil {
|
||||||
|
t.Fatalf("valid rootfs entries failed: %v", err)
|
||||||
|
}
|
||||||
|
for _, entries := range [][]string{
|
||||||
|
{},
|
||||||
|
{"etc/passwd"},
|
||||||
|
{"/etc/passwd", "bin/sh"},
|
||||||
|
{"../../etc/passwd", "bin/sh"},
|
||||||
|
} {
|
||||||
|
if err := validateCustomRootfsEntries(entries); err == nil {
|
||||||
|
t.Fatalf("unsafe rootfs entries unexpectedly passed: %#v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("container not found: %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldAssignments := append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
|
||||||
|
oldPrimary := c.IPv6
|
||||||
|
oldPrimaryPrefixLen := c.IPv6PrefixLen
|
||||||
|
oldPrimaryInterface := c.IPv6Interface
|
||||||
|
|
||||||
|
assignments := []config.IPv6Assignment{}
|
||||||
|
if auto || len(requested) > 0 {
|
||||||
|
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assignments = allocated
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, assignment := range oldAssignments {
|
||||||
|
uplink := assignment.Interface
|
||||||
|
if uplink == "" {
|
||||||
|
uplink = oldPrimaryInterface
|
||||||
|
}
|
||||||
|
removeHostIPv6Routing(assignment.Address, uplink)
|
||||||
|
}
|
||||||
|
if len(oldAssignments) == 0 && oldPrimary != "" {
|
||||||
|
removeHostIPv6Routing(oldPrimary, oldPrimaryInterface)
|
||||||
|
oldAssignments = append(oldAssignments, config.IPv6Assignment{Address: oldPrimary, PrefixLen: oldPrimaryPrefixLen, Interface: oldPrimaryInterface})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.IPv6 = ""
|
||||||
|
c.IPv6PrefixLen = 0
|
||||||
|
c.IPv6Interface = ""
|
||||||
|
c.IPv6Addresses = assignments
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
|
config.SaveConfig()
|
||||||
|
|
||||||
|
if err := m.applyIPv6Config(c.LxcName(), c.IPv6AddressStrings()...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
|
||||||
|
if _, err := os.Stat(rootfsPath); err == nil {
|
||||||
|
if len(c.IPv6Addresses) == 0 {
|
||||||
|
if err := removeContainerIPv6Init(rootfsPath); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to remove IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||||
|
}
|
||||||
|
} else if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status, _ := m.GetContainerStatus(c.LxcName())
|
||||||
|
if status == "running" {
|
||||||
|
m.removeGuestIPv6Addresses(c.LxcName(), oldAssignments)
|
||||||
|
}
|
||||||
|
if len(c.IPv6Addresses) > 0 {
|
||||||
|
if err := m.ApplyIPv6(id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if status == "running" {
|
||||||
|
m.removeGuestIPv6DefaultRoute(c.LxcName())
|
||||||
|
if err := ApplyFirewallRules(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 removal for %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
|
func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
|
||||||
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
||||||
data, err := os.ReadFile(configFile)
|
data, err := os.ReadFile(configFile)
|
||||||
@@ -1519,7 +1591,7 @@ func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
|
|||||||
return fmt.Errorf("failed to read container config: %v", err)
|
return fmt.Errorf("failed to read container config: %v", err)
|
||||||
}
|
}
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := strings.Split(string(data), "\n")
|
||||||
next := make([]string, 0, len(lines)+4)
|
next := make([]string, 0, len(lines))
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if strings.Contains(trimmed, "# clicd managed: public IPv6") ||
|
if strings.Contains(trimmed, "# clicd managed: public IPv6") ||
|
||||||
@@ -1702,6 +1774,25 @@ exit 0
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeContainerIPv6Init(rootfsPath string) error {
|
||||||
|
paths := []string{
|
||||||
|
filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init"),
|
||||||
|
filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service"),
|
||||||
|
filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants", "clicd-ipv6.service"),
|
||||||
|
filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6"),
|
||||||
|
filepath.Join(rootfsPath, "etc", "runlevels", "default", "clicd-ipv6"),
|
||||||
|
}
|
||||||
|
for _, level := range []string{"2", "3", "4", "5"} {
|
||||||
|
paths = append(paths, filepath.Join(rootfsPath, "etc", "rc"+level+".d", "S99clicd-ipv6"))
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func installContainerIPv6Systemd(rootfsPath string) error {
|
func installContainerIPv6Systemd(rootfsPath string) error {
|
||||||
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
||||||
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
||||||
@@ -1870,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) removeGuestIPv6Addresses(lxcName string, assignments []config.IPv6Assignment) {
|
||||||
|
addrs := ipv6AssignmentAddresses(assignments)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quoted := shellQuotedIPv6List(addrs)
|
||||||
|
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
||||||
|
fmt.Sprintf("for ip in %s; do ip -6 addr del \"$ip/128\" dev eth0 2>/dev/null || true; done", quoted)).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) removeGuestIPv6DefaultRoute(lxcName string) {
|
||||||
|
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
|
||||||
|
fmt.Sprintf("ip -6 route del default via %s dev eth0 2>/dev/null || true", shellQuote(ipv6GatewayLinkLocal))).Run()
|
||||||
|
}
|
||||||
|
|
||||||
func ensureIPv6NAT66(ipv6, uplink string) {
|
func ensureIPv6NAT66(ipv6, uplink string) {
|
||||||
if ipv6 == "" || uplink == "" {
|
if ipv6 == "" || uplink == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
+959
-144
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
||||||
@@ -27,6 +29,335 @@ func TestRootfsCommandAddsSeparatorForAllowedCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCreateNATMappingsSupportsDifferentHostAndContainerPorts(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||||
|
|
||||||
|
cfg := ContainerConfig{
|
||||||
|
PortMappingCount: 2,
|
||||||
|
NATPortMappings: []config.PortMapping{{
|
||||||
|
HostPort: 30080,
|
||||||
|
ContainerPort: 80,
|
||||||
|
Protocol: "TCP",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.PortMappingCount != 2 || len(cfg.NATPortMappings) != 1 {
|
||||||
|
t.Fatalf("normalized config = %+v", cfg)
|
||||||
|
}
|
||||||
|
mapping := cfg.NATPortMappings[0]
|
||||||
|
if mapping.HostPort != 30080 || mapping.ContainerPort != 80 || mapping.Protocol != "tcp" {
|
||||||
|
t.Fatalf("normalized mapping = %+v", mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
container := &config.Container{
|
||||||
|
ID: -1,
|
||||||
|
PortMappings: []config.PortMapping{{
|
||||||
|
HostPort: 22000,
|
||||||
|
ContainerPort: 22,
|
||||||
|
Protocol: "tcp",
|
||||||
|
Description: "SSH",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
mappings, err := SetupCreatePortMappings(container, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mappings) != 2 || mappings[1].HostPort != 30080 || mappings[1].ContainerPort != 80 {
|
||||||
|
t.Fatalf("created mappings = %+v", mappings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCreateNATMappingsKeepsLegacyExtraPortsCompatible(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||||
|
|
||||||
|
cfg := ContainerConfig{ExtraPorts: []int{30080, 30443}}
|
||||||
|
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cfg.ExtraPorts) != 0 || len(cfg.NATPortMappings) != 2 {
|
||||||
|
t.Fatalf("legacy ports were not converted: %+v", cfg)
|
||||||
|
}
|
||||||
|
for _, mapping := range cfg.NATPortMappings {
|
||||||
|
if mapping.HostPort != mapping.ContainerPort {
|
||||||
|
t.Fatalf("legacy mapping changed semantics: %+v", mapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCreateNATMappingsRejectsDuplicateHostPort(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||||
|
|
||||||
|
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{
|
||||||
|
{HostPort: 30080, ContainerPort: 80, Protocol: "tcp"},
|
||||||
|
{HostPort: 30080, ContainerPort: 8080, Protocol: "tcp"},
|
||||||
|
}}
|
||||||
|
if err := cfg.NormalizeCreateNATMappings(); err == nil {
|
||||||
|
t.Fatal("duplicate host port was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCreateNATMappingsRejectsManagementPortConflict(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{NATPortStart: 20000, NATPortEnd: 65535}
|
||||||
|
|
||||||
|
cfg := ContainerConfig{
|
||||||
|
ManagementPort: 30022,
|
||||||
|
NATPortMappings: []config.PortMapping{{
|
||||||
|
HostPort: 30022,
|
||||||
|
ContainerPort: 8080,
|
||||||
|
Protocol: "tcp",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := cfg.NormalizeCreateNATMappings(); err == nil || !strings.Contains(err.Error(), "management_port") {
|
||||||
|
t.Fatalf("management port conflict returned %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaggedRuleLineNumbersReturnsMatchingRulesDescending(t *testing.T) {
|
||||||
|
output := []byte(`Chain PREROUTING (policy ACCEPT)
|
||||||
|
num target prot opt source destination
|
||||||
|
2 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30080 /* clicd-c12-any-30080 */
|
||||||
|
7 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30081 /* clicd-c13-any-30081 */
|
||||||
|
11 DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:30082 /* clicd-c12-any-30082 */
|
||||||
|
`)
|
||||||
|
got := taggedRuleLineNumbers(output, "clicd-c12-")
|
||||||
|
want := []int{11, 2}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("taggedRuleLineNumbers() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortMappingConntrackDeleteArgs(t *testing.T) {
|
||||||
|
got := portMappingConntrackDeleteArgs(config.PortMapping{
|
||||||
|
HostIP: "203.0.113.10",
|
||||||
|
HostPort: 32022,
|
||||||
|
Protocol: "TCP",
|
||||||
|
})
|
||||||
|
want := []string{"-D", "-p", "tcp", "--dport", "32022", "--dst", "203.0.113.10"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("portMappingConntrackDeleteArgs() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := portMappingConntrackDeleteArgs(config.PortMapping{HostPort: 32022, Protocol: "icmp"}); got != nil {
|
||||||
|
t.Fatalf("unsupported protocol returned args: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSSHPortMappingKeepsIdentityAndSynchronizesSSHPort(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{
|
||||||
|
NATPortStart: 30000,
|
||||||
|
NATPortEnd: 65535,
|
||||||
|
Containers: []config.Container{{
|
||||||
|
ID: 12,
|
||||||
|
Name: "ct-test",
|
||||||
|
Status: "stopped",
|
||||||
|
SSHPort: 30022,
|
||||||
|
PortMappings: []config.PortMapping{{
|
||||||
|
HostPort: 30022,
|
||||||
|
ContainerPort: 22,
|
||||||
|
Protocol: "tcp",
|
||||||
|
Description: "SSH",
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := NewManager()
|
||||||
|
mappings, err := manager.UpdatePortMapping(12, 0, config.PortMapping{
|
||||||
|
HostPort: 31022,
|
||||||
|
ContainerPort: 22,
|
||||||
|
Protocol: "tcp",
|
||||||
|
Description: "renamed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(mappings) != 1 || mappings[0].Description != "SSH" {
|
||||||
|
t.Fatalf("updated mappings = %+v", mappings)
|
||||||
|
}
|
||||||
|
container := config.FindContainer(12)
|
||||||
|
if container == nil || container.SSHPort != 31022 {
|
||||||
|
t.Fatalf("container after SSH update = %+v", container)
|
||||||
|
}
|
||||||
|
if _, err := manager.DeletePortMapping(12, 0); err == nil {
|
||||||
|
t.Fatal("updated SSH mapping became deletable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveCreateNATPortsProtectsConcurrentTasks(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{
|
||||||
|
NATPortStart: 20000,
|
||||||
|
NATPortEnd: 65535,
|
||||||
|
NextSSHPort: 22000,
|
||||||
|
}
|
||||||
|
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
createNATReservations = map[uint64][]config.PortMapping{}
|
||||||
|
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
createNATReservations = map[uint64][]config.PortMapping{}
|
||||||
|
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := ContainerConfig{NATPortMappings: []config.PortMapping{{
|
||||||
|
HostPort: 22000,
|
||||||
|
ContainerPort: 80,
|
||||||
|
Protocol: "tcp",
|
||||||
|
}}}
|
||||||
|
if err := cfg.NormalizeCreateNATMappings(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
managementPort, release, err := ReserveCreateNATPorts(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if managementPort == 22000 {
|
||||||
|
t.Fatal("management port collided with the requested custom host port")
|
||||||
|
}
|
||||||
|
if _, _, err := ReserveCreateNATPorts(cfg); err == nil {
|
||||||
|
t.Fatal("concurrent task reserved an already reserved custom host port")
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
if _, releaseAgain, err := ReserveCreateNATPorts(cfg); err != nil {
|
||||||
|
t.Fatalf("released custom host port remained reserved: %v", err)
|
||||||
|
} else {
|
||||||
|
releaseAgain()
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit := ContainerConfig{ManagementPort: 30022}
|
||||||
|
if err := explicit.NormalizeCreateNATMappings(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if port, releaseExplicit, err := ReserveCreateNATPorts(explicit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else {
|
||||||
|
defer releaseExplicit()
|
||||||
|
if port != explicit.ManagementPort {
|
||||||
|
t.Fatalf("reserved management port = %d, want %d", port, explicit.ManagementPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveBatchCreateNATPortsPlansAllAutomaticPorts(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{
|
||||||
|
NATPortStart: 30000,
|
||||||
|
NATPortEnd: 30010,
|
||||||
|
NextSSHPort: 30001,
|
||||||
|
}
|
||||||
|
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
createNATReservations = map[uint64][]config.PortMapping{}
|
||||||
|
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
createNATReservations = map[uint64][]config.PortMapping{}
|
||||||
|
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
configs := []ContainerConfig{
|
||||||
|
{Name: "batch-1", PortMappingCount: 2},
|
||||||
|
{Name: "batch-2", PortMappingCount: 2},
|
||||||
|
}
|
||||||
|
for i := range configs {
|
||||||
|
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
planned, err := ReserveBatchCreateNATPorts(configs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
used := map[int]string{}
|
||||||
|
for _, cfg := range planned {
|
||||||
|
if cfg.ManagementPort == 0 {
|
||||||
|
t.Fatalf("%s has no planned management port", cfg.Name)
|
||||||
|
}
|
||||||
|
if len(cfg.NATPortMappings) != 1 {
|
||||||
|
t.Fatalf("%s automatic mappings = %d, want 1", cfg.Name, len(cfg.NATPortMappings))
|
||||||
|
}
|
||||||
|
for _, port := range []int{cfg.ManagementPort, cfg.NATPortMappings[0].HostPort} {
|
||||||
|
if owner := used[port]; owner != "" {
|
||||||
|
t.Fatalf("planned port %d is shared by %s and %s", port, owner, cfg.Name)
|
||||||
|
}
|
||||||
|
used[port] = cfg.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cfg := range planned {
|
||||||
|
port, release, err := ReserveCreateNATPorts(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s could not claim its queued reservation: %v", cfg.Name, err)
|
||||||
|
}
|
||||||
|
if port != cfg.ManagementPort {
|
||||||
|
t.Fatalf("%s claimed management port %d, want %d", cfg.Name, port, cfg.ManagementPort)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
if len(queuedCreateNATReservations) != 0 {
|
||||||
|
t.Fatalf("queued reservations remain after claim: %v", queuedCreateNATReservations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveBatchCreateNATPortsRejectsWholeConflictingBatch(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
t.Cleanup(func() { config.AppConfig = previous })
|
||||||
|
config.AppConfig = &config.ClicdConfig{
|
||||||
|
NATPortStart: 30000,
|
||||||
|
NATPortEnd: 30010,
|
||||||
|
NextSSHPort: 30001,
|
||||||
|
}
|
||||||
|
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
createNATReservations = map[uint64][]config.PortMapping{}
|
||||||
|
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
createNATReservations = map[uint64][]config.PortMapping{}
|
||||||
|
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
configs := []ContainerConfig{
|
||||||
|
{Name: "batch-1", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 80, Protocol: "tcp"}}},
|
||||||
|
{Name: "batch-2", NATPortMappings: []config.PortMapping{{HostPort: 30005, ContainerPort: 8080, Protocol: "tcp"}}},
|
||||||
|
}
|
||||||
|
for i := range configs {
|
||||||
|
if err := configs[i].NormalizeCreateNATMappings(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ReserveBatchCreateNATPorts(configs); err == nil {
|
||||||
|
t.Fatal("conflicting batch was accepted")
|
||||||
|
}
|
||||||
|
if len(queuedCreateNATReservations) != 0 {
|
||||||
|
t.Fatalf("conflicting batch left partial reservations: %v", queuedCreateNATReservations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
func TestRootfsCommandRejectsUnmanagedCommand(t *testing.T) {
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
rootfs := filepath.Join(base, "ct-1", "rootfs")
|
||||||
@@ -88,3 +419,96 @@ 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 TestRootfsHasSSHD(t *testing.T) {
|
||||||
|
rootfs := t.TempDir()
|
||||||
|
if rootfsHasSSHD(rootfs) {
|
||||||
|
t.Fatal("empty rootfs unexpectedly reports sshd")
|
||||||
|
}
|
||||||
|
sshd := filepath.Join(rootfs, "usr", "sbin", "sshd")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(sshd), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(sshd, []byte("#!/bin/sh\n"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !rootfsHasSSHD(rootfs) {
|
||||||
|
t.Fatal("executable sshd was not detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameFilesystemPathResolvesContainerStorageSymlink(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
storageContainer := filepath.Join(base, "storage", "ct-1")
|
||||||
|
rootfs := filepath.Join(storageContainer, "rootfs")
|
||||||
|
if err := os.MkdirAll(rootfs, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lxcPath := filepath.Join(base, "lxc")
|
||||||
|
if err := os.MkdirAll(lxcPath, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
containerLink := filepath.Join(lxcPath, "ct-1")
|
||||||
|
if err := os.Symlink(storageContainer, containerLink); err != nil {
|
||||||
|
t.Skipf("directory symlinks are unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkedRootfs := filepath.Join(containerLink, "rootfs")
|
||||||
|
if !sameFilesystemPath(rootfs, linkedRootfs) {
|
||||||
|
t.Fatalf("sameFilesystemPath(%q, %q) = false, want true", rootfs, linkedRootfs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendMissingSeccompRulesAddsFutexMitigationOnce(t *testing.T) {
|
||||||
|
base := "2\ndenylist\n[all]\nopen_by_handle_at errno 1\n"
|
||||||
|
|
||||||
|
once := appendMissingSeccompRules(base, cve202643499FutexSeccompRules)
|
||||||
|
twice := appendMissingSeccompRules(once, cve202643499FutexSeccompRules)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"futex errno 1 [1,0x6,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||||
|
"futex errno 1 [1,0xb,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||||
|
"futex errno 1 [1,0xc,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||||
|
"futex errno 1 [1,0xd,SCMP_CMP_MASKED_EQ,0x7f]",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(once, want) {
|
||||||
|
t.Fatalf("missing seccomp rule %q in\n%s", want, once)
|
||||||
|
}
|
||||||
|
if strings.Count(twice, want) != 1 {
|
||||||
|
t.Fatalf("rule %q duplicated in\n%s", want, twice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+538
-19
@@ -1,15 +1,26 @@
|
|||||||
package lxc
|
package lxc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
createNATReservationMu sync.Mutex
|
||||||
|
createNATReservationNextID uint64
|
||||||
|
createNATReservations = map[uint64][]config.PortMapping{}
|
||||||
|
queuedCreateNATReservations = map[string][]config.PortMapping{}
|
||||||
|
)
|
||||||
|
|
||||||
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
|
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
|
||||||
func (m *Manager) ApplyPortMappings(id int) error {
|
func (m *Manager) ApplyPortMappings(id int) error {
|
||||||
c := config.FindContainer(id)
|
c := config.FindContainer(id)
|
||||||
@@ -22,14 +33,16 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
bridge := "lxcbr0"
|
bridge := "lxcbr0"
|
||||||
subnet := "10.0.3.0/24"
|
subnet := config.LXCNATNetwork().Subnet
|
||||||
if c.IsKVM() {
|
if c.IsKVM() {
|
||||||
bridge = "virbr0"
|
bridge = "virbr0"
|
||||||
subnet = "192.168.122.0/24"
|
subnet = config.KVMNATNetwork().Subnet
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureForwardRules(bridge)
|
EnsureForwardRules(bridge)
|
||||||
m.CleanPortMappings(id)
|
if err := m.CleanPortMappings(id); err != nil {
|
||||||
|
return fmt.Errorf("clean existing port mappings for container %d: %w", id, err)
|
||||||
|
}
|
||||||
deleteBridgeMasquerade(subnet)
|
deleteBridgeMasquerade(subnet)
|
||||||
|
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
@@ -236,9 +249,13 @@ func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
|||||||
|
|
||||||
func EnsureAllRunningPortMappings() {
|
func EnsureAllRunningPortMappings() {
|
||||||
m := NewManager()
|
m := NewManager()
|
||||||
|
m.cleanOrphanedPortMappings()
|
||||||
for i := range config.AppConfig.Containers {
|
for i := range config.AppConfig.Containers {
|
||||||
c := &config.AppConfig.Containers[i]
|
c := &config.AppConfig.Containers[i]
|
||||||
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
|
||||||
|
if err := m.CleanPortMappings(c.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to clean inactive port mappings for %s: %v\n", c.Name, err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := m.ApplyPortMappings(c.ID); err != nil {
|
if err := m.ApplyPortMappings(c.ID); err != nil {
|
||||||
@@ -247,11 +264,39 @@ func EnsureAllRunningPortMappings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var taggedContainerIDPattern = regexp.MustCompile(`clicd-c([0-9]+)-`)
|
||||||
|
|
||||||
|
func (m *Manager) cleanOrphanedPortMappings() {
|
||||||
|
output, err := exec.Command("iptables-save").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configured := make(map[int]bool, len(config.AppConfig.Containers))
|
||||||
|
for i := range config.AppConfig.Containers {
|
||||||
|
configured[config.AppConfig.Containers[i].ID] = true
|
||||||
|
}
|
||||||
|
seen := map[int]bool{}
|
||||||
|
for _, match := range taggedContainerIDPattern.FindAllSubmatch(output, -1) {
|
||||||
|
if len(match) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(string(match[1]))
|
||||||
|
if err != nil || configured[id] || seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
if err := m.CleanPortMappings(id); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to clean orphaned port mappings for container %d: %v\n", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||||
func EnsureForwardRules(bridge string) {
|
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,18 +314,137 @@ 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)
|
marker := "clicd-" + clicdTag(id) + "-"
|
||||||
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
|
var cleanupErrors []error
|
||||||
cmd := exec.Command("sh", "-c",
|
for _, target := range []struct {
|
||||||
fmt.Sprintf("iptables -t nat -L %s -n --line-numbers 2>/dev/null | grep 'clicd-%s-' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D %s $num; done", chain, tag, chain))
|
table string
|
||||||
cmd.Run()
|
chain string
|
||||||
|
}{
|
||||||
|
{table: "nat", chain: "PREROUTING"},
|
||||||
|
{table: "nat", chain: "POSTROUTING"},
|
||||||
|
{chain: "FORWARD"},
|
||||||
|
} {
|
||||||
|
if err := deleteTaggedIPTablesRules(target.table, target.chain, marker); err != nil {
|
||||||
|
cleanupErrors = append(cleanupErrors, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cmd := exec.Command("sh", "-c",
|
if c := config.FindContainer(id); c != nil {
|
||||||
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
|
for _, mapping := range c.PortMappings {
|
||||||
cmd.Run()
|
clearPortMappingConntrack(mapping)
|
||||||
return nil
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(cleanupErrors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTaggedIPTablesRules(table, chain, marker string) error {
|
||||||
|
listArgs := []string{"-w", "5"}
|
||||||
|
if table != "" {
|
||||||
|
listArgs = append(listArgs, "-t", table)
|
||||||
|
}
|
||||||
|
listArgs = append(listArgs, "-L", chain, "-n", "--line-numbers")
|
||||||
|
output, err := exec.Command("iptables", listArgs...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list iptables %s/%s: %w: %s", tableName(table), chain, err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteErrors []error
|
||||||
|
for _, lineNumber := range taggedRuleLineNumbers(output, marker) {
|
||||||
|
deleteArgs := []string{"-w", "5"}
|
||||||
|
if table != "" {
|
||||||
|
deleteArgs = append(deleteArgs, "-t", table)
|
||||||
|
}
|
||||||
|
deleteArgs = append(deleteArgs, "-D", chain, strconv.Itoa(lineNumber))
|
||||||
|
if output, err := exec.Command("iptables", deleteArgs...).CombinedOutput(); err != nil {
|
||||||
|
deleteErrors = append(deleteErrors, fmt.Errorf(
|
||||||
|
"delete iptables %s/%s rule %d: %w: %s",
|
||||||
|
tableName(table), chain, lineNumber, err, strings.TrimSpace(string(output)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(deleteErrors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func taggedRuleLineNumbers(output []byte, marker string) []int {
|
||||||
|
lineNumbers := make([]int, 0)
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
if !strings.Contains(line, marker) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineNumber, err := strconv.Atoi(fields[0])
|
||||||
|
if err == nil && lineNumber > 0 {
|
||||||
|
lineNumbers = append(lineNumbers, lineNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Sort(sort.Reverse(sort.IntSlice(lineNumbers)))
|
||||||
|
return lineNumbers
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableName(table string) string {
|
||||||
|
if table == "" {
|
||||||
|
return "filter"
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearPortMappingConntrack(mapping config.PortMapping) {
|
||||||
|
args := portMappingConntrackDeleteArgs(mapping)
|
||||||
|
if len(args) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// conntrack exits non-zero when no matching flow exists; that is already clean.
|
||||||
|
_ = exec.Command("conntrack", args...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func portMappingConntrackDeleteArgs(mapping config.PortMapping) []string {
|
||||||
|
protocol := strings.ToLower(strings.TrimSpace(mapping.Protocol))
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mapping.HostPort < 1 || mapping.HostPort > 65535 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"-D",
|
||||||
|
"-p", protocol,
|
||||||
|
"--dport", strconv.Itoa(mapping.HostPort),
|
||||||
|
}
|
||||||
|
if hostIP := strings.TrimSpace(mapping.HostIP); hostIP != "" {
|
||||||
|
args = append(args, "--dst", hostIP)
|
||||||
|
}
|
||||||
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupDefaultPortMappings creates default port mappings
|
// SetupDefaultPortMappings creates default port mappings
|
||||||
@@ -333,14 +497,19 @@ func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([
|
|||||||
if index < 0 || index >= len(c.PortMappings) {
|
if index < 0 || index >= len(c.PortMappings) {
|
||||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||||
}
|
}
|
||||||
|
existing := c.PortMappings[index]
|
||||||
normalized, err := normalizePortMapping(c, index, pm)
|
normalized, err := normalizePortMapping(c, index, pm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(existing.Description, "SSH") {
|
||||||
|
normalized.Description = "SSH"
|
||||||
|
}
|
||||||
c.PortMappings[index] = normalized
|
c.PortMappings[index] = normalized
|
||||||
if err := persistAndReloadMappings(m, c); err != nil {
|
if err := persistAndReloadMappings(m, c); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
clearPortMappingConntrack(existing)
|
||||||
return c.PortMappings, nil
|
return c.PortMappings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,17 +522,20 @@ func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, er
|
|||||||
if index < 0 || index >= len(c.PortMappings) {
|
if index < 0 || index >= len(c.PortMappings) {
|
||||||
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
return nil, fmt.Errorf("invalid port mapping index: %d", index)
|
||||||
}
|
}
|
||||||
if c.PortMappings[index].Description == "SSH" {
|
removed := c.PortMappings[index]
|
||||||
|
if strings.EqualFold(removed.Description, "SSH") {
|
||||||
return nil, fmt.Errorf("SSH default mapping cannot be deleted")
|
return nil, fmt.Errorf("SSH default mapping cannot be deleted")
|
||||||
}
|
}
|
||||||
c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...)
|
c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...)
|
||||||
if err := persistAndReloadMappings(m, c); err != nil {
|
if err := persistAndReloadMappings(m, c); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
clearPortMappingConntrack(removed)
|
||||||
return c.PortMappings, nil
|
return c.PortMappings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||||
|
syncContainerSSHPort(c)
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
if c.Status == "running" && c.IP != "" {
|
if c.Status == "running" && c.IP != "" {
|
||||||
return m.ApplyPortMappings(c.ID)
|
return m.ApplyPortMappings(c.ID)
|
||||||
@@ -371,6 +543,76 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func syncContainerSSHPort(c *config.Container) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, mapping := range c.PortMappings {
|
||||||
|
if strings.EqualFold(mapping.Description, "SSH") {
|
||||||
|
c.SSHPort = mapping.HostPort
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("container not found: %d", id)
|
||||||
|
}
|
||||||
|
if c.UsesLANIPv4() {
|
||||||
|
return nil, fmt.Errorf("public IPv4 cannot be assigned while LAN IPv4 mode is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments := []config.PublicIPv4Assignment{}
|
||||||
|
if auto || len(requested) > 0 {
|
||||||
|
allocated, err := AllocatePublicIPv4Assignments(id, requested, count, auto)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assignments = allocated
|
||||||
|
}
|
||||||
|
|
||||||
|
c.PublicIPv4s = assignments
|
||||||
|
reconcilePortMappingHostIPs(c)
|
||||||
|
c.NormalizeNetworkAssignments()
|
||||||
|
config.SaveConfig()
|
||||||
|
|
||||||
|
_ = m.CleanPortMappings(id)
|
||||||
|
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
|
||||||
|
if c.Status == "running" && c.IP != "" {
|
||||||
|
if err := m.ApplyPortMappings(id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcilePortMappingHostIPs(c *config.Container) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assigned := map[string]bool{}
|
||||||
|
for _, item := range c.PublicIPv4s {
|
||||||
|
if addr := strings.TrimSpace(item.Address); addr != "" {
|
||||||
|
assigned[addr] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replacement := ""
|
||||||
|
if len(assigned) == 1 {
|
||||||
|
for addr := range assigned {
|
||||||
|
replacement = addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range c.PortMappings {
|
||||||
|
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
|
||||||
|
if hostIP == "" || assigned[hostIP] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.PortMappings[i].HostIP = replacement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
|
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
|
||||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||||
return pm, fmt.Errorf("container port must be 1-65535")
|
return pm, fmt.Errorf("container port must be 1-65535")
|
||||||
@@ -395,6 +637,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 {
|
||||||
@@ -419,6 +665,283 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
return pm, nil
|
return pm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetupCreatePortMappings appends validated custom or automatically allocated
|
||||||
|
// mappings to a container's management port mapping.
|
||||||
|
func SetupCreatePortMappings(c *config.Container, cfg ContainerConfig) ([]config.PortMapping, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("container is required")
|
||||||
|
}
|
||||||
|
requested := append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||||
|
if len(requested) == 0 && cfg.PortMappingCount > 1 {
|
||||||
|
for _, port := range allocateDefaultEqualPorts(c, cfg.PortMappingCount-1) {
|
||||||
|
requested = append(requested, config.PortMapping{
|
||||||
|
ContainerPort: port,
|
||||||
|
HostPort: port,
|
||||||
|
Protocol: "tcp",
|
||||||
|
Description: fmt.Sprintf("Port-%d", port),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, mapping := range requested {
|
||||||
|
pm, err := normalizePortMapping(c, -1, mapping)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.PortMappings = append(c.PortMappings, pm)
|
||||||
|
}
|
||||||
|
return c.PortMappings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReserveCreateNATPorts keeps concurrent create tasks from selecting each
|
||||||
|
// other's custom or management ports before their containers are persisted.
|
||||||
|
func ReserveCreateNATPorts(cfg ContainerConfig) (int, func(), error) {
|
||||||
|
if !cfg.WantsNAT() {
|
||||||
|
return 0, func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
defer createNATReservationMu.Unlock()
|
||||||
|
|
||||||
|
owner := createNATReservationOwner(cfg.Name)
|
||||||
|
requestedReservations := createNATReservationMappings(cfg, cfg.ManagementPort)
|
||||||
|
if queued, ok := queuedCreateNATReservations[owner]; ok {
|
||||||
|
if !sameCreateNATReservations(queued, requestedReservations) {
|
||||||
|
return 0, nil, fmt.Errorf("queued NAT port plan for %s no longer matches the create task", cfg.Name)
|
||||||
|
}
|
||||||
|
delete(queuedCreateNATReservations, owner)
|
||||||
|
return activateCreateNATReservationLocked(cfg.ManagementPort, queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateCreateNATPortAvailability(cfg); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
if err := validateCreateNATReservationsAvailableLocked(requestedReservations, owner); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := cfg.RequestedNATHostPorts()
|
||||||
|
excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...)
|
||||||
|
managementPort := cfg.ManagementPort
|
||||||
|
if managementPort == 0 {
|
||||||
|
var err error
|
||||||
|
managementPort, err = config.AllocateSSHPortExcluding(excluded)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reservations := createNATReservationMappings(cfg, managementPort)
|
||||||
|
return activateCreateNATReservationLocked(managementPort, reservations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReserveBatchCreateNATPorts resolves every automatic NAT port and reserves
|
||||||
|
// the complete batch before any create task is enqueued.
|
||||||
|
func ReserveBatchCreateNATPorts(configs []ContainerConfig) ([]ContainerConfig, error) {
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
defer createNATReservationMu.Unlock()
|
||||||
|
|
||||||
|
planned := append([]ContainerConfig(nil), configs...)
|
||||||
|
addedOwners := make([]string, 0, len(planned))
|
||||||
|
rollback := func() {
|
||||||
|
for _, owner := range addedOwners {
|
||||||
|
delete(queuedCreateNATReservations, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range planned {
|
||||||
|
cfg := &planned[i]
|
||||||
|
cfg.NATPortMappings = append([]config.PortMapping(nil), cfg.NATPortMappings...)
|
||||||
|
if !cfg.WantsNAT() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
owner := createNATReservationOwner(cfg.Name)
|
||||||
|
if owner == "" {
|
||||||
|
rollback()
|
||||||
|
return nil, fmt.Errorf("container name is required for NAT port reservation")
|
||||||
|
}
|
||||||
|
if _, exists := queuedCreateNATReservations[owner]; exists {
|
||||||
|
rollback()
|
||||||
|
return nil, fmt.Errorf("container creation already has reserved NAT ports: %s", cfg.Name)
|
||||||
|
}
|
||||||
|
if err := ValidateCreateNATPortAvailability(*cfg); err != nil {
|
||||||
|
rollback()
|
||||||
|
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicit := createNATReservationMappings(*cfg, cfg.ManagementPort)
|
||||||
|
if err := validateCreateNATReservationsAvailableLocked(explicit, owner); err != nil {
|
||||||
|
rollback()
|
||||||
|
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := cfg.RequestedNATHostPorts()
|
||||||
|
excluded = append(excluded, allReservedCreateNATHostPortsLocked(owner)...)
|
||||||
|
if cfg.ManagementPort == 0 {
|
||||||
|
port, err := config.AllocateSSHPortExcluding(excluded)
|
||||||
|
if err != nil {
|
||||||
|
rollback()
|
||||||
|
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||||
|
}
|
||||||
|
cfg.ManagementPort = port
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.NATPortMappings) == 0 && cfg.PortMappingCount > 1 {
|
||||||
|
generated, err := planDefaultCreateNATMappingsLocked(*cfg, cfg.PortMappingCount-1, owner)
|
||||||
|
if err != nil {
|
||||||
|
rollback()
|
||||||
|
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||||
|
}
|
||||||
|
cfg.NATPortMappings = generated
|
||||||
|
cfg.PortMappingCount = len(generated) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
reservations := createNATReservationMappings(*cfg, cfg.ManagementPort)
|
||||||
|
if err := validateCreateNATReservationsAvailableLocked(reservations, owner); err != nil {
|
||||||
|
rollback()
|
||||||
|
return nil, fmt.Errorf("%s: %w", cfg.Name, err)
|
||||||
|
}
|
||||||
|
queuedCreateNATReservations[owner] = reservations
|
||||||
|
addedOwners = append(addedOwners, owner)
|
||||||
|
}
|
||||||
|
return planned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReleaseQueuedCreateNATPorts(name string) {
|
||||||
|
owner := createNATReservationOwner(name)
|
||||||
|
if owner == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
delete(queuedCreateNATReservations, owner)
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateCreateNATReservationLocked(managementPort int, reservations []config.PortMapping) (int, func(), error) {
|
||||||
|
createNATReservationNextID++
|
||||||
|
reservationID := createNATReservationNextID
|
||||||
|
createNATReservations[reservationID] = append([]config.PortMapping(nil), reservations...)
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
release := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
createNATReservationMu.Lock()
|
||||||
|
delete(createNATReservations, reservationID)
|
||||||
|
createNATReservationMu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return managementPort, release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNATReservationOwner(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNATReservationMappings(cfg ContainerConfig, managementPort int) []config.PortMapping {
|
||||||
|
reservations := make([]config.PortMapping, 0, len(cfg.NATPortMappings))
|
||||||
|
if managementPort > 0 {
|
||||||
|
reservations = append(reservations, config.PortMapping{HostPort: managementPort, Protocol: "tcp"})
|
||||||
|
}
|
||||||
|
reservations = append(reservations, cfg.NATPortMappings...)
|
||||||
|
return reservations
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCreateNATReservationsAvailableLocked(requested []config.PortMapping, exceptOwner string) error {
|
||||||
|
for _, candidate := range requested {
|
||||||
|
for _, reservations := range createNATReservations {
|
||||||
|
if conflictingCreateNATReservation(candidate, reservations) {
|
||||||
|
return fmt.Errorf("NAT host port %d/%s is reserved by another create task", candidate.HostPort, candidate.Protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner, reservations := range queuedCreateNATReservations {
|
||||||
|
if owner == exceptOwner {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if conflictingCreateNATReservation(candidate, reservations) {
|
||||||
|
return fmt.Errorf("NAT host port %d/%s is reserved by queued create task %s", candidate.HostPort, candidate.Protocol, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func conflictingCreateNATReservation(candidate config.PortMapping, reservations []config.PortMapping) bool {
|
||||||
|
for _, reserved := range reservations {
|
||||||
|
if candidate.HostPort == reserved.HostPort && protocolsOverlap(candidate.Protocol, reserved.Protocol) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func allReservedCreateNATHostPortsLocked(exceptOwner string) []int {
|
||||||
|
ports := make([]int, 0)
|
||||||
|
for _, reservations := range createNATReservations {
|
||||||
|
for _, reserved := range reservations {
|
||||||
|
ports = append(ports, reserved.HostPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for owner, reservations := range queuedCreateNATReservations {
|
||||||
|
if owner == exceptOwner {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, reserved := range reservations {
|
||||||
|
ports = append(ports, reserved.HostPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports
|
||||||
|
}
|
||||||
|
|
||||||
|
func planDefaultCreateNATMappingsLocked(cfg ContainerConfig, count int, owner string) ([]config.PortMapping, error) {
|
||||||
|
if count <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
unavailable := map[int]bool{cfg.ManagementPort: true}
|
||||||
|
for _, port := range allReservedCreateNATHostPortsLocked(owner) {
|
||||||
|
unavailable[port] = true
|
||||||
|
}
|
||||||
|
for _, mapping := range cfg.NATPortMappings {
|
||||||
|
unavailable[mapping.HostPort] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := &config.Container{ID: -1}
|
||||||
|
start, end := config.NATPortRange()
|
||||||
|
mappings := make([]config.PortMapping, 0, count)
|
||||||
|
for port := start; port <= end && len(mappings) < count; port++ {
|
||||||
|
if unavailable[port] || !HostPortAvailable(candidate, "", port, "tcp") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unavailable[port] = true
|
||||||
|
mappings = append(mappings, config.PortMapping{
|
||||||
|
HostPort: port,
|
||||||
|
ContainerPort: port,
|
||||||
|
Protocol: "tcp",
|
||||||
|
Description: fmt.Sprintf("Port-%d", port),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(mappings) != count {
|
||||||
|
return nil, fmt.Errorf("not enough free NAT4 host ports for %d automatic mappings", count)
|
||||||
|
}
|
||||||
|
return mappings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCreateNATReservations(left, right []config.PortMapping) bool {
|
||||||
|
if len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
counts := make(map[string]int, len(left))
|
||||||
|
for _, mapping := range left {
|
||||||
|
counts[fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol))]++
|
||||||
|
}
|
||||||
|
for _, mapping := range right {
|
||||||
|
key := fmt.Sprintf("%d/%s", mapping.HostPort, strings.ToLower(mapping.Protocol))
|
||||||
|
if counts[key] == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
counts[key]--
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
||||||
if count <= 0 {
|
if count <= 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -444,16 +967,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
var snapshotMu sync.Mutex
|
var snapshotMu sync.Mutex
|
||||||
|
|
||||||
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotateLimit int, storagePoolID ...string) (config.Snapshot, error) {
|
||||||
snapshotMu.Lock()
|
snapshotMu.Lock()
|
||||||
defer snapshotMu.Unlock()
|
defer snapshotMu.Unlock()
|
||||||
|
|
||||||
@@ -42,12 +42,21 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
|
|||||||
if _, err := os.Stat(containerDir); err != nil {
|
if _, err := os.Stat(containerDir); err != nil {
|
||||||
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
return config.Snapshot{}, fmt.Errorf("container storage not found: %v", err)
|
||||||
}
|
}
|
||||||
|
pool, err := config.SelectStoragePoolForContent(
|
||||||
|
config.StorageContentSnapshots,
|
||||||
|
firstString(storagePoolID),
|
||||||
|
dirSizeBytes(containerDir),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return config.Snapshot{}, err
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||||
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
||||||
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
|
baseDir := filepath.Join(pool.Path, "snapshots")
|
||||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
snapshotDir := filepath.Join(baseDir, strconv.Itoa(id), snapshotID)
|
||||||
|
if err := safePathUnder(snapshotDir, baseDir); err != nil {
|
||||||
return config.Snapshot{}, err
|
return config.Snapshot{}, err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
if err := os.MkdirAll(snapshotDir, 0700); err != nil {
|
||||||
@@ -100,7 +109,7 @@ func (m *Manager) DeleteSnapshot(id string) error {
|
|||||||
|
|
||||||
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
func (m *Manager) deleteSnapshotLocked(snapshot config.Snapshot) error {
|
||||||
if snapshot.Path != "" {
|
if snapshot.Path != "" {
|
||||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(snapshot.Path); err != nil {
|
if err := os.RemoveAll(snapshot.Path); err != nil {
|
||||||
@@ -122,7 +131,7 @@ func (m *Manager) RestoreSnapshot(id string) error {
|
|||||||
if snapshot.Path == "" {
|
if snapshot.Path == "" {
|
||||||
return fmt.Errorf("snapshot path is empty")
|
return fmt.Errorf("snapshot path is empty")
|
||||||
}
|
}
|
||||||
if err := safePathUnder(snapshot.Path, snapshotBaseDir()); err != nil {
|
if err := safeSnapshotPath(snapshot.Path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(snapshot.Path); err != nil {
|
if _, err := os.Stat(snapshot.Path); err != nil {
|
||||||
@@ -295,7 +304,33 @@ func (m *Manager) prepareContainerForColdCopy(id int, lxcName string, containerD
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapshotBaseDir() string {
|
func snapshotBaseDir() string {
|
||||||
return filepath.Join(config.AppConfig.DataDir, "snapshots")
|
return snapshotBaseDirForPool("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotBaseDirForPool(poolID string) string {
|
||||||
|
if pool, err := config.SelectStoragePoolForContent(config.StorageContentSnapshots, poolID, 0); err == nil {
|
||||||
|
return filepath.Join(pool.Path, "snapshots")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeSnapshotPath(path string) error {
|
||||||
|
if err := safePathUnder(path, filepath.Join(config.AppConfig.DataDir, "snapshots")); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, pool := range config.StoragePoolsForContent(config.StorageContentSnapshots) {
|
||||||
|
if err := safePathUnder(path, filepath.Join(pool.Path, "snapshots")); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unsafe snapshot path: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(values[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyTree(src string, dst string) error {
|
func copyTree(src string, dst string) error {
|
||||||
@@ -313,6 +348,9 @@ func copyTree(src string, dst string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dirSizeBytes(path string) int64 {
|
func dirSizeBytes(path string) int64 {
|
||||||
|
if resolved, err := filepath.EvalSymlinks(path); err == nil {
|
||||||
|
path = resolved
|
||||||
|
}
|
||||||
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
out, err := exec.Command("du", "-s", "-B1", path).Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package lxc
|
package lxc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
// 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"`
|
||||||
@@ -9,57 +15,92 @@ type Template struct {
|
|||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
Variant string `json:"variant"`
|
Variant string `json:"variant"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
|
Custom bool `json:"custom,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
return []Template{
|
arch := defaultTemplateArch()
|
||||||
|
templates := []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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
for _, custom := range config.ListCustomLXCImages() {
|
||||||
|
if custom.Arch != arch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
templates = append(templates, Template{
|
||||||
|
ID: custom.ID,
|
||||||
|
Name: custom.Name,
|
||||||
|
Distro: custom.Distro,
|
||||||
|
Release: custom.Release,
|
||||||
|
Arch: custom.Arch,
|
||||||
|
Description: custom.Description,
|
||||||
|
URL: custom.URL,
|
||||||
|
SHA256: custom.SHA256,
|
||||||
|
Custom: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return templates
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package safehttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRedirects = 10
|
||||||
|
|
||||||
|
var blockedPrefixes = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("0.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
|
netip.MustParsePrefix("127.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("169.254.0.0/16"),
|
||||||
|
netip.MustParsePrefix("172.16.0.0/12"),
|
||||||
|
netip.MustParsePrefix("192.0.0.0/24"),
|
||||||
|
netip.MustParsePrefix("192.0.2.0/24"),
|
||||||
|
netip.MustParsePrefix("192.88.99.0/24"),
|
||||||
|
netip.MustParsePrefix("192.168.0.0/16"),
|
||||||
|
netip.MustParsePrefix("198.18.0.0/15"),
|
||||||
|
netip.MustParsePrefix("198.51.100.0/24"),
|
||||||
|
netip.MustParsePrefix("203.0.113.0/24"),
|
||||||
|
netip.MustParsePrefix("224.0.0.0/4"),
|
||||||
|
netip.MustParsePrefix("240.0.0.0/4"),
|
||||||
|
netip.MustParsePrefix("::/128"),
|
||||||
|
netip.MustParsePrefix("::1/128"),
|
||||||
|
netip.MustParsePrefix("64:ff9b::/96"),
|
||||||
|
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||||
|
netip.MustParsePrefix("100::/64"),
|
||||||
|
netip.MustParsePrefix("2001::/32"),
|
||||||
|
netip.MustParsePrefix("2001:2::/48"),
|
||||||
|
netip.MustParsePrefix("2001:db8::/32"),
|
||||||
|
netip.MustParsePrefix("2001:20::/28"),
|
||||||
|
netip.MustParsePrefix("2002::/16"),
|
||||||
|
netip.MustParsePrefix("fc00::/7"),
|
||||||
|
netip.MustParsePrefix("fec0::/10"),
|
||||||
|
netip.MustParsePrefix("fe80::/10"),
|
||||||
|
netip.MustParsePrefix("ff00::/8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURL performs the URL checks that do not require DNS. Host addresses
|
||||||
|
// are checked again after resolution and immediately before every connection.
|
||||||
|
func ValidateURL(rawURL string) (*url.URL, error) {
|
||||||
|
if len(rawURL) == 0 || len(rawURL) > 4096 {
|
||||||
|
return nil, fmt.Errorf("download URL must be between 1 and 4096 characters")
|
||||||
|
}
|
||||||
|
parsed, err := url.ParseRequestURI(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid download URL: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return nil, fmt.Errorf("download URL must use HTTP or HTTPS")
|
||||||
|
}
|
||||||
|
if parsed.Host == "" || parsed.Hostname() == "" {
|
||||||
|
return nil, fmt.Errorf("download URL must include a host")
|
||||||
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return nil, fmt.Errorf("download URL must not include credentials")
|
||||||
|
}
|
||||||
|
if parsed.Fragment != "" {
|
||||||
|
return nil, fmt.Errorf("download URL must not include a fragment")
|
||||||
|
}
|
||||||
|
if port := parsed.Port(); port != "" {
|
||||||
|
value, err := strconv.Atoi(port)
|
||||||
|
if err != nil || value < 1 || value > 65535 {
|
||||||
|
return nil, fmt.Errorf("download URL contains an invalid port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addr, err := netip.ParseAddr(parsed.Hostname()); err == nil && !isPublicAddress(addr) {
|
||||||
|
return nil, fmt.Errorf("download URL resolves to a non-public address")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a resource only when every resolved destination is public.
|
||||||
|
func Get(ctx context.Context, rawURL, userAgent string, timeout time.Duration) (*http.Response, error) {
|
||||||
|
parsed, err := ValidateURL(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateHost(ctx, net.DefaultResolver, parsed.Hostname()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("User-Agent", userAgent)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
Transport: publicTransport(net.DefaultResolver),
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= maxRedirects {
|
||||||
|
return fmt.Errorf("too many redirects")
|
||||||
|
}
|
||||||
|
redirect, err := ValidateURL(req.URL.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateHost(req.Context(), net.DefaultResolver, redirect.Hostname()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(via) > 0 {
|
||||||
|
req.Header.Set("User-Agent", via[0].Header.Get("User-Agent"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// All URL components, redirects, DNS answers and dial destinations are
|
||||||
|
// constrained above and in publicTransport.
|
||||||
|
// lgtm[go/request-forgery]
|
||||||
|
return client.Do(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicTransport(resolver *net.Resolver) *http.Transport {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}
|
||||||
|
return &http.Transport{
|
||||||
|
Proxy: nil,
|
||||||
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid download destination: %v", err)
|
||||||
|
}
|
||||||
|
addresses, err := resolvePublicHost(ctx, resolver, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, addr := range addresses {
|
||||||
|
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("host has no usable public addresses")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
},
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
TLSHandshakeTimeout: 30 * time.Second,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateHost(ctx context.Context, resolver *net.Resolver, host string) error {
|
||||||
|
_, err := resolvePublicHost(ctx, resolver, host)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePublicHost(ctx context.Context, resolver *net.Resolver, host string) ([]netip.Addr, error) {
|
||||||
|
host = strings.TrimSpace(strings.TrimSuffix(host, "."))
|
||||||
|
if host == "" {
|
||||||
|
return nil, fmt.Errorf("download URL host is empty")
|
||||||
|
}
|
||||||
|
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
|
||||||
|
return nil, fmt.Errorf("download URL host is not public")
|
||||||
|
}
|
||||||
|
|
||||||
|
if addr, err := netip.ParseAddr(host); err == nil {
|
||||||
|
addr = addr.Unmap()
|
||||||
|
if !isPublicAddress(addr) {
|
||||||
|
return nil, fmt.Errorf("download URL resolves to a non-public address")
|
||||||
|
}
|
||||||
|
return []netip.Addr{addr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses, err := resolver.LookupNetIP(ctx, "ip", host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve download host: %v", err)
|
||||||
|
}
|
||||||
|
if len(addresses) == 0 {
|
||||||
|
return nil, fmt.Errorf("download host has no IP addresses")
|
||||||
|
}
|
||||||
|
result := make([]netip.Addr, 0, len(addresses))
|
||||||
|
for _, address := range addresses {
|
||||||
|
address = address.Unmap()
|
||||||
|
if !isPublicAddress(address) {
|
||||||
|
return nil, fmt.Errorf("download host resolves to a non-public address")
|
||||||
|
}
|
||||||
|
result = append(result, address)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPublicAddress(address netip.Addr) bool {
|
||||||
|
if !address.IsValid() || address.Zone() != "" || !address.IsGlobalUnicast() || address.IsPrivate() ||
|
||||||
|
address.IsLoopback() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() ||
|
||||||
|
address.IsMulticast() || address.IsUnspecified() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
address = address.Unmap()
|
||||||
|
for _, prefix := range blockedPrefixes {
|
||||||
|
if prefix.Contains(address) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package safehttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateURLRejectsUnsafeDestinations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"http://user:pass@example.com/image",
|
||||||
|
"http://127.0.0.1/image",
|
||||||
|
"http://[::1]/image",
|
||||||
|
"http://169.254.169.254/latest/meta-data",
|
||||||
|
"http://10.0.0.1/image",
|
||||||
|
"http://192.168.1.10/image",
|
||||||
|
"http://100.64.0.1/image",
|
||||||
|
"http://example.com:99999/image",
|
||||||
|
} {
|
||||||
|
if _, err := ValidateURL(rawURL); err == nil {
|
||||||
|
t.Fatalf("ValidateURL(%q) succeeded, want rejection", rawURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateURLAcceptsPublicHTTPURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
parsed, err := ValidateURL("https://example.com/images/rootfs.tar.xz?variant=default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateURL returned error: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.Hostname() != "example.com" {
|
||||||
|
t.Fatalf("hostname = %q, want example.com", parsed.Hostname())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPublicAddress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := map[string]bool{
|
||||||
|
"8.8.8.8": true,
|
||||||
|
"1.1.1.1": true,
|
||||||
|
"2606:4700:4700::1111": true,
|
||||||
|
"127.0.0.1": false,
|
||||||
|
"10.0.0.1": false,
|
||||||
|
"100.64.0.1": false,
|
||||||
|
"169.254.169.254": false,
|
||||||
|
"192.0.2.1": false,
|
||||||
|
"198.18.0.1": false,
|
||||||
|
"::1": false,
|
||||||
|
"64:ff9b::127.0.0.1": false,
|
||||||
|
"2002:7f00:1::1": false,
|
||||||
|
"fc00::1": false,
|
||||||
|
"fec0::1": false,
|
||||||
|
"fe80::1": false,
|
||||||
|
"2001:db8::1": false,
|
||||||
|
}
|
||||||
|
for raw, expected := range tests {
|
||||||
|
if actual := isPublicAddress(netip.MustParseAddr(raw)); actual != expected {
|
||||||
|
t.Errorf("isPublicAddress(%s) = %v, want %v", raw, actual, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRejectsLoopbackBeforeRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := Get(ctx, "http://127.0.0.1:1/image", "test", time.Second); err == nil {
|
||||||
|
t.Fatal("Get accepted a loopback destination")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func panelAccessMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
decision := config.EvaluatePanelAccess(
|
||||||
|
config.AppConfig.PanelAccessPolicy,
|
||||||
|
r.RemoteAddr,
|
||||||
|
config.ForwardedClientHeaders{
|
||||||
|
ForwardedFor: r.Header.Get("X-Forwarded-For"),
|
||||||
|
RealIP: r.Header.Get("X-Real-IP"),
|
||||||
|
CFConnectingIP: r.Header.Get("CF-Connecting-IP"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if decision.Allowed {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"success": false,
|
||||||
|
"message": "Access denied by panel source policy",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Access denied by panel source policy", http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPanelAccessMiddleware(t *testing.T) {
|
||||||
|
previous := config.AppConfig
|
||||||
|
config.AppConfig = &config.ClicdConfig{
|
||||||
|
PanelAccessPolicy: config.PanelAccessPolicy{
|
||||||
|
Enabled: true,
|
||||||
|
AllowedSources: []string{"192.0.2.0/24"},
|
||||||
|
TrustedProxies: []string{"10.0.0.1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.AppConfig = previous
|
||||||
|
})
|
||||||
|
|
||||||
|
handler := panelAccessMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
allowed := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||||
|
allowed.RemoteAddr = "192.0.2.8:50000"
|
||||||
|
allowedRecorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(allowedRecorder, allowed)
|
||||||
|
if allowedRecorder.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("allowed status = %d", allowedRecorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
denied := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||||
|
denied.RemoteAddr = "198.51.100.8:50000"
|
||||||
|
deniedRecorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(deniedRecorder, denied)
|
||||||
|
if deniedRecorder.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("denied status = %d", deniedRecorder.Code)
|
||||||
|
}
|
||||||
|
if got := deniedRecorder.Header().Get("Content-Type"); got != "application/json" {
|
||||||
|
t.Fatalf("denied content type = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,11 +49,13 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
|
||||||
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||||
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||||
|
mux.HandleFunc("/api/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
|
||||||
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||||
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||||
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||||
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||||
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
|
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
|
||||||
|
mux.HandleFunc("/api/images/custom", corsMiddleware(api.AdminMiddleware(api.HandleCustomKVMImages)))
|
||||||
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
|
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
|
||||||
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
|
mux.HandleFunc("/api/images/cancel", corsMiddleware(api.AdminMiddleware(api.HandleImageCancel)))
|
||||||
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
|
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
|
||||||
@@ -61,13 +63,16 @@ 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)))
|
||||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||||
|
mux.HandleFunc("/api/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
|
||||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
||||||
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
||||||
|
mux.HandleFunc("/api/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
|
||||||
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
||||||
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
|
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
|
||||||
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
||||||
@@ -98,19 +103,23 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||||
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
mux.HandleFunc("/api/v1/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
|
||||||
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
mux.HandleFunc("/api/v1/images", corsMiddleware(api.AuthMiddleware(api.HandleImages)))
|
||||||
|
mux.HandleFunc("/api/v1/images/custom", corsMiddleware(api.AuthMiddleware(api.HandleCustomKVMImages)))
|
||||||
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
mux.HandleFunc("/api/v1/images/download", corsMiddleware(api.AuthMiddleware(api.HandleImageDownload)))
|
||||||
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
mux.HandleFunc("/api/v1/images/cancel", corsMiddleware(api.AuthMiddleware(api.HandleImageCancel)))
|
||||||
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
mux.HandleFunc("/api/v1/images/delete", corsMiddleware(api.AuthMiddleware(api.HandleImageDelete)))
|
||||||
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)))
|
||||||
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
|
||||||
|
mux.HandleFunc("/api/v1/storage", corsMiddleware(api.AdminMiddleware(api.HandleStorage)))
|
||||||
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
|
||||||
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
|
||||||
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
mux.HandleFunc("/api/v1/tasks/", corsMiddleware(api.AuthMiddleware(api.HandleTaskDelete)))
|
||||||
|
mux.HandleFunc("/api/v1/task-queue/settings", corsMiddleware(api.AdminMiddleware(api.HandleTaskQueueSettings)))
|
||||||
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
mux.HandleFunc("/api/v1/batch-create", corsMiddleware(api.AuthMiddleware(api.HandleBatchCreate)))
|
||||||
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
mux.HandleFunc("/api/v1/batch-action", corsMiddleware(api.AuthMiddleware(api.HandleBatchAction)))
|
||||||
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
mux.HandleFunc("/api/v1/sub-user/create", corsMiddleware(api.AuthMiddleware(api.HandleSubUserCreate)))
|
||||||
@@ -120,6 +129,7 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs)))
|
||||||
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
|
||||||
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
mux.HandleFunc("/api/v1/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
|
||||||
|
mux.HandleFunc("/api/v1/access-policy", corsMiddleware(api.AdminMiddleware(api.HandlePanelAccessPolicy)))
|
||||||
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts))))
|
||||||
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck))))
|
||||||
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
|
||||||
@@ -174,6 +184,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)
|
||||||
@@ -184,7 +196,7 @@ func Run() error {
|
|||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: panelAccessMiddleware(mux),
|
||||||
}
|
}
|
||||||
|
|
||||||
if sslEnabled() {
|
if sslEnabled() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.19"
|
Version = "1.1.28"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"clicd/internal/api"
|
"clicd/internal/api"
|
||||||
"clicd/internal/cli"
|
"clicd/internal/cli"
|
||||||
@@ -16,12 +19,15 @@ import (
|
|||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var shutdownCaptureOnce sync.Once
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
|
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
|
||||||
|
|
||||||
isServerMode := false
|
isServerMode := false
|
||||||
isCliMode := false
|
isCliMode := false
|
||||||
noWebAutostart := false
|
noWebAutostart := false
|
||||||
|
isAccessPolicyCommand := len(os.Args) > 1 && os.Args[1] == "access-policy"
|
||||||
for _, arg := range os.Args[1:] {
|
for _, arg := range os.Args[1:] {
|
||||||
if arg == "server" || arg == "-s" || arg == "--server" {
|
if arg == "server" || arg == "-s" || arg == "--server" {
|
||||||
isServerMode = true
|
isServerMode = true
|
||||||
@@ -43,8 +49,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
_ = cfg
|
_ = cfg
|
||||||
|
|
||||||
|
if isAccessPolicyCommand {
|
||||||
|
if err := cli.RunAccessPolicyCommand(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Access policy error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if isServerMode || (!isTerminal && !isCliMode) {
|
if isServerMode || (!isTerminal && !isCliMode) {
|
||||||
|
installShutdownStateCapture()
|
||||||
|
|
||||||
// Restore persisted state
|
// Restore persisted state
|
||||||
|
api.ConfigureTaskQueue(cfg.TaskConcurrency)
|
||||||
api.RestoreTasks()
|
api.RestoreTasks()
|
||||||
api.RestoreLoginLogs()
|
api.RestoreLoginLogs()
|
||||||
|
|
||||||
@@ -75,6 +92,7 @@ func main() {
|
|||||||
|
|
||||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||||
config.CleanStaleContainers()
|
config.CleanStaleContainers()
|
||||||
|
api.StartHostBootRestore()
|
||||||
lxc.EnsureAllRunningPortMappings()
|
lxc.EnsureAllRunningPortMappings()
|
||||||
|
|
||||||
// Pre-warm SSH for containers already running after host boot or service restart.
|
// Pre-warm SSH for containers already running after host boot or service restart.
|
||||||
@@ -97,6 +115,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installShutdownStateCapture() {
|
||||||
|
signals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig := <-signals
|
||||||
|
fmt.Fprintf(os.Stderr, "Received %s, capturing workload restore state...\n", sig)
|
||||||
|
shutdownCaptureOnce.Do(api.CaptureRuntimeRestoreState)
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func isWebPanelSystemdRunning() bool {
|
func isWebPanelSystemdRunning() bool {
|
||||||
cmd := exec.Command("systemctl", "is-active", "clicd")
|
cmd := exec.Command("systemctl", "is-active", "clicd")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+216
-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,122 @@ 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. |
|
||||||
|
|
||||||
|
## Panel Access Source Policy
|
||||||
|
|
||||||
|
Use `GET /api/v1/access-policy` to read the panel source allowlist and `PUT /api/v1/access-policy` to update it. Both endpoints require `admin:access`. The policy covers panel pages, login endpoints, and every API.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"allowed_sources": [
|
||||||
|
"203.0.113.10",
|
||||||
|
"192.168.1.0/24",
|
||||||
|
"2001:db8::/32"
|
||||||
|
],
|
||||||
|
"trusted_proxies": [
|
||||||
|
"127.0.0.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Both lists accept IPv4, IPv6, and CIDR values. The backend only uses `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` when the direct peer matches `trusted_proxies`, so untrusted clients cannot bypass the policy by spoofing those headers. An enabled policy requires at least one allowed source, and the API rejects changes that exclude the current administrator source. Direct loopback access remains available as a CLI/SSH recovery path.
|
||||||
|
|
||||||
## Python Example
|
## Python Example
|
||||||
|
|
||||||
Fetch containers:
|
Fetch containers:
|
||||||
@@ -140,6 +266,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 +278,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 +301,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 +321,9 @@ 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/custom` | Add a third-party LXC/KVM image source |
|
||||||
|
| DELETE | `/api/v1/images/custom` | Remove a third-party LXC/KVM image source and cache |
|
||||||
| 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 +336,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 +394,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 +480,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 +496,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 +569,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 +612,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 +633,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 +713,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 +765,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 +860,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,
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ systemctl restart clicd
|
|||||||
journalctl -u clicd -n 100 --no-pager
|
journalctl -u clicd -n 100 --no-pager
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Panel Access Allowlist CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show the current policy
|
||||||
|
clicd access-policy show
|
||||||
|
|
||||||
|
# Allow selected addresses and networks; add reverse proxies when needed
|
||||||
|
clicd access-policy set \
|
||||||
|
--allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \
|
||||||
|
--trusted-proxy "127.0.0.1"
|
||||||
|
|
||||||
|
# Disable source restrictions
|
||||||
|
clicd access-policy disable
|
||||||
|
```
|
||||||
|
|
||||||
|
The same controls are available from the "Panel access allowlist" item in `clicd cli`. Both paths persist the setting and restart the running panel service automatically.
|
||||||
|
|
||||||
## Security Recommendations
|
## Security Recommendations
|
||||||
|
|
||||||
- Do not expose the web panel directly to untrusted networks.
|
- Do not expose the web panel directly to untrusted networks.
|
||||||
|
|||||||
@@ -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,9 @@ 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 installer asks for separate LXC and KVM NAT private subnets. Press Enter to scan host routes, interfaces, bridges, and libvirt networks and select non-overlapping RFC1918 `/24` networks, or enter a CIDR such as `172.28.40.0/24`. For unattended installation, set `CLICD_LXC_SUBNET` and `CLICD_KVM_SUBNET`.
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
|||||||
+215
-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,122 @@ 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 只能访问指定容器。 |
|
||||||
|
|
||||||
|
## 面板访问来源策略
|
||||||
|
|
||||||
|
`GET /api/v1/access-policy` 读取面板访问白名单,`PUT /api/v1/access-policy` 更新策略。两者均需要 `admin:access` 权限。策略覆盖面板页面、登录入口和全部 API。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"allowed_sources": [
|
||||||
|
"203.0.113.10",
|
||||||
|
"192.168.1.0/24",
|
||||||
|
"2001:db8::/32"
|
||||||
|
],
|
||||||
|
"trusted_proxies": [
|
||||||
|
"127.0.0.1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`allowed_sources` 和 `trusted_proxies` 均支持 IPv4、IPv6 及 CIDR。只有直接连接来源命中 `trusted_proxies` 时,后端才会使用 `X-Forwarded-For`、`X-Real-IP` 或 `CF-Connecting-IP`;其他客户端伪造这些请求头不会绕过白名单。启用策略时至少要配置一个允许来源,且接口会拒绝排除当前管理来源的配置。本机回环直连保留为 CLI/SSH 故障恢复通道。
|
||||||
|
|
||||||
## Python 示例
|
## Python 示例
|
||||||
|
|
||||||
获取容器列表:
|
获取容器列表:
|
||||||
@@ -140,6 +266,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 +278,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 +301,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 +321,9 @@ 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/custom` | 添加第三方 LXC/KVM 镜像源 |
|
||||||
|
| DELETE | `/api/v1/images/custom` | 移除第三方 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 +336,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 +394,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 +480,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 +496,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 +569,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 +612,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 +633,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 +713,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 +768,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 +860,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,
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ systemctl restart clicd
|
|||||||
journalctl -u clicd -n 100 --no-pager
|
journalctl -u clicd -n 100 --no-pager
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 面板访问白名单 CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看当前策略
|
||||||
|
clicd access-policy show
|
||||||
|
|
||||||
|
# 仅允许指定 IP/网段;反向代理地址按需填写
|
||||||
|
clicd access-policy set \
|
||||||
|
--allow "203.0.113.10,192.168.1.0/24,2001:db8::/32" \
|
||||||
|
--trusted-proxy "127.0.0.1"
|
||||||
|
|
||||||
|
# 关闭白名单限制
|
||||||
|
clicd access-policy disable
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以运行 `clicd cli`,在交互菜单中选择“面板访问白名单”。直接命令和交互菜单都会保存配置,并在服务运行时自动重启面板。
|
||||||
|
|
||||||
## 安全建议
|
## 安全建议
|
||||||
|
|
||||||
- 不要把 Web 面板直接暴露给不可信来源。
|
- 不要把 Web 面板直接暴露给不可信来源。
|
||||||
|
|||||||
@@ -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,9 @@ 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`。
|
安装器会分别询问 LXC 与 KVM 的 NAT 私网网段。直接回车时,脚本会扫描宿主机路由、网卡、网桥和 libvirt 网络,自动选择未冲突的 RFC1918 `/24` 网段;也可以输入 `172.28.40.0/24` 这类 CIDR。非交互安装可设置 `CLICD_LXC_SUBNET` 和 `CLICD_KVM_SUBNET`。
|
||||||
|
|
||||||
|
脚本当前默认使用 `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
+117
-117
@@ -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": {
|
||||||
@@ -2065,9 +2065,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2123,9 +2123,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2143,7 +2143,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -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": {
|
||||||
|
|||||||
+3
-1
@@ -11,6 +11,8 @@
|
|||||||
"vitepress": "^1.6.4"
|
"vitepress": "^1.6.4"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "6.4.2"
|
"vite": "6.4.3",
|
||||||
|
"esbuild": "0.28.1",
|
||||||
|
"postcss": "8.5.23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+61
-101
@@ -1,28 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.28",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novnc/novnc": "1.5.0",
|
"@novnc/novnc": "1.5.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.18.0",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "19.2.8",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "19.2.8",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router": "8.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.23",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
@@ -480,15 +480,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
|
||||||
"version": "1.23.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
|
||||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
@@ -809,32 +800,24 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
|
||||||
"version": "15.7.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.30",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
@@ -957,9 +940,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.17.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
@@ -1152,6 +1135,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-es": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -1372,16 +1361,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"
|
||||||
@@ -1625,6 +1614,7 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
@@ -1934,18 +1924,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/loose-envify": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"loose-envify": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -2038,9 +2016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2144,9 +2122,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2164,7 +2142,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2337,28 +2315,24 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"scheduler": "^0.27.0"
|
||||||
"scheduler": "^0.23.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
@@ -2372,35 +2346,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.4",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
|
||||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
"integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.3"
|
"cookie-es": "^3.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=22.22.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8"
|
"react": ">=19.2.7",
|
||||||
}
|
"react-dom": ">=19.2.7"
|
||||||
},
|
|
||||||
"node_modules/react-router-dom": {
|
|
||||||
"version": "6.30.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
|
||||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@remix-run/router": "1.23.3",
|
|
||||||
"react-router": "6.30.4"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"peerDependenciesMeta": {
|
||||||
"node": ">=14.0.0"
|
"react-dom": {
|
||||||
},
|
"optional": true
|
||||||
"peerDependencies": {
|
}
|
||||||
"react": ">=16.8",
|
|
||||||
"react-dom": ">=16.8"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
@@ -2525,13 +2488,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.19",
|
"version": "1.1.28",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -12,18 +12,18 @@
|
|||||||
"@novnc/novnc": "1.5.0",
|
"@novnc/novnc": "1.5.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.18.0",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"react": "^18.3.1",
|
"react": "19.2.8",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "19.2.8",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-router": "8.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.23",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router'
|
||||||
import { useAuth } from './contexts/AuthContext'
|
import { useAuth } from './contexts/AuthContext'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
@@ -13,6 +13,7 @@ import Settings from './pages/Settings'
|
|||||||
import ImageManagement from './pages/ImageManagement'
|
import ImageManagement from './pages/ImageManagement'
|
||||||
import Snapshots from './pages/Snapshots'
|
import Snapshots from './pages/Snapshots'
|
||||||
import Routing from './pages/Routing'
|
import Routing from './pages/Routing'
|
||||||
|
import Storage from './pages/Storage'
|
||||||
import SubUserManagement from './pages/SubUserManagement'
|
import SubUserManagement from './pages/SubUserManagement'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ function App() {
|
|||||||
<Route path="security" element={<Security />} />
|
<Route path="security" element={<Security />} />
|
||||||
<Route path="snapshots" element={<Snapshots />} />
|
<Route path="snapshots" element={<Snapshots />} />
|
||||||
<Route path="routing" element={<Routing />} />
|
<Route path="routing" element={<Routing />} />
|
||||||
|
<Route path="storage" element={<Storage />} />
|
||||||
<Route path="audit-logs" element={<AuditLogs />} />
|
<Route path="audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="api-integration" element={<ApiIntegration />} />
|
<Route path="api-integration" element={<ApiIntegration />} />
|
||||||
<Route path="host-report" element={<HostReport />} />
|
<Route path="host-report" element={<HostReport />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { shouldTranslateText, translateText } from '../utils/i18n'
|
import { shouldTranslateText, translateText } from '../utils/i18n'
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { useDialog } from './Dialog'
|
||||||
|
|
||||||
export default function BrowserDialogTranslator() {
|
export default function BrowserDialogTranslator() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const { alert: showAlert } = useDialog()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const originalAlert = window.alert
|
const originalAlert = window.alert
|
||||||
const originalConfirm = window.confirm
|
const originalConfirm = window.confirm
|
||||||
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
|
window.alert = (message?: unknown) => { void showAlert('提示', String(message ?? '')) }
|
||||||
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||||
return () => {
|
return () => {
|
||||||
window.alert = originalAlert
|
window.alert = originalAlert
|
||||||
window.confirm = originalConfirm
|
window.confirm = originalConfirm
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [showAlert, t])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,23 @@
|
|||||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'
|
||||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
import { AlertTriangle, CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
|
||||||
type DialogType = 'confirm' | 'alert'
|
|
||||||
|
|
||||||
interface DialogState {
|
interface DialogState {
|
||||||
open: boolean
|
open: boolean
|
||||||
type: DialogType
|
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
resolve?: (value: boolean) => void
|
resolve?: (value: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToastTone = 'success' | 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
tone: ToastTone
|
||||||
|
}
|
||||||
|
|
||||||
interface DialogContextType {
|
interface DialogContextType {
|
||||||
confirm: (title: string, message: string) => Promise<boolean>
|
confirm: (title: string, message: string) => Promise<boolean>
|
||||||
alert: (title: string, message: string) => Promise<void>
|
alert: (title: string, message: string) => Promise<void>
|
||||||
@@ -19,67 +25,107 @@ interface DialogContextType {
|
|||||||
|
|
||||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
const toastStyles = {
|
||||||
|
success: { icon: CheckCircle2, iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-300', borderClass: 'border-emerald-200 dark:border-emerald-800' },
|
||||||
|
error: { icon: CircleAlert, iconClass: 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-300', borderClass: 'border-red-200 dark:border-red-800' },
|
||||||
|
warning: { icon: AlertTriangle, iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300', borderClass: 'border-amber-200 dark:border-amber-800' },
|
||||||
|
info: { icon: Info, iconClass: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300', borderClass: 'border-gray-200 dark:border-gray-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastTone(title: string): ToastTone {
|
||||||
|
if (/失败|错误|异常|不可用|failed|error/i.test(title)) return 'error'
|
||||||
|
if (/提示|警告|未配置|格式|配额|封禁|warning/i.test(title)) return 'warning'
|
||||||
|
if (/完成|成功|已保存|success/i.test(title)) return 'success'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
const [dialog, setDialog] = useState<DialogState>({ open: false, title: '', message: '' })
|
||||||
|
const [toasts, setToasts] = useState<ToastState[]>([])
|
||||||
|
const toastID = useRef(0)
|
||||||
|
const toastTimers = useRef(new Map<number, number>())
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
const confirm = useCallback((title: string, message: string) => {
|
const confirm = useCallback((title: string, message: string) => {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
setDialog({ open: true, type: 'confirm', title, message, resolve })
|
setDialog({ open: true, title, message, resolve })
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const dismissToast = useCallback((id: number) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id))
|
||||||
|
const timer = toastTimers.current.get(id)
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer)
|
||||||
|
toastTimers.current.delete(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const alert = useCallback((title: string, message: string) => {
|
const alert = useCallback((title: string, message: string) => {
|
||||||
return new Promise<void>((resolve) => {
|
const id = ++toastID.current
|
||||||
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
|
setToasts((current) => [...current, { id, title, message, tone: toastTone(title) }].slice(-4))
|
||||||
})
|
const timer = window.setTimeout(() => dismissToast(id), 4200)
|
||||||
|
toastTimers.current.set(id, timer)
|
||||||
|
return Promise.resolve()
|
||||||
|
}, [dismissToast])
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
toastTimers.current.forEach((timer) => window.clearTimeout(timer))
|
||||||
|
toastTimers.current.clear()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const close = (result: boolean) => {
|
const close = (result: boolean) => {
|
||||||
dialog.resolve?.(result)
|
dialog.resolve?.(result)
|
||||||
setDialog({ open: false, type: 'alert', title: '', message: '' })
|
setDialog({ open: false, title: '', message: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={{ confirm, alert }}>
|
<DialogContext.Provider value={{ confirm, alert }}>
|
||||||
{children}
|
{children}
|
||||||
{dialog.open && (
|
<div className="pointer-events-none fixed right-4 top-4 z-[120] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-2" aria-live="polite" aria-atomic="true">
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
|
{toasts.map((toast) => {
|
||||||
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
|
const style = toastStyles[toast.tone]
|
||||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
|
const ToastIcon = style.icon
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
return (
|
||||||
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
|
<div key={toast.id} className={`pointer-events-auto rounded-lg border bg-white shadow-lg dark:bg-gray-900 dark:shadow-black/40 ${style.borderClass}`} role="status">
|
||||||
}`}>
|
<div className="flex items-start gap-3 p-3.5">
|
||||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
<div className={`mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${style.iconClass}`}>
|
||||||
</div>
|
<ToastIcon className="h-4 w-4" />
|
||||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
</div>
|
||||||
{dialog.type === 'alert' && (
|
<div className="min-w-0 flex-1">
|
||||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">{t(toast.title)}</div>
|
||||||
<X className="w-4 h-4" />
|
<div className="mt-0.5 break-words text-sm leading-5 text-gray-600 dark:text-gray-300">{t(toast.message)}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => dismissToast(toast.id)} className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-black dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-white" title={t('关闭')}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{dialog.open && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70">
|
||||||
|
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div className="flex items-center gap-3 border-b border-gray-100 px-5 py-4 dark:border-gray-700">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<h3 className="flex-1 text-sm font-semibold text-black dark:text-white">{t(dialog.title)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
|
<p className="text-sm text-gray-600 dark:text-gray-300">{t(dialog.message)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50 px-5 py-3 dark:border-gray-700 dark:bg-gray-800">
|
||||||
{dialog.type === 'confirm' && (
|
<button
|
||||||
<button
|
onClick={() => close(false)}
|
||||||
onClick={() => close(false)}
|
className="rounded-md px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-200 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
>
|
||||||
>
|
{t('取消')}
|
||||||
{t('取消')}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => close(true)}
|
onClick={() => close(true)}
|
||||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${
|
className="rounded-md bg-black px-4 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
|
||||||
dialog.type === 'confirm'
|
|
||||||
? 'bg-black text-white hover:bg-gray-800'
|
|
||||||
: 'bg-black text-white hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
{t('确认')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import AutoTranslate from './AutoTranslate'
|
import AutoTranslate from './AutoTranslate'
|
||||||
@@ -12,7 +12,7 @@ export default function Layout() {
|
|||||||
<AutoTranslate />
|
<AutoTranslate />
|
||||||
<BrowserDialogTranslator />
|
<BrowserDialogTranslator />
|
||||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
<main className={`min-w-0 flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
</div>
|
key={item.label}
|
||||||
|
color={item.color || chartPalette[index % chartPalette.length]}
|
||||||
|
label={item.label}
|
||||||
|
stats={getSeriesStats(item, item.current)}
|
||||||
|
formatValue={chart.formatValue}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-right sm:shrink-0">
|
||||||
|
<Stat label="当前" value={chart.formatValue(primaryStats.current)} />
|
||||||
|
<Stat label="平均" value={chart.formatValue(primaryStats.avg)} />
|
||||||
|
<Stat label="峰值" value={chart.formatValue(primaryStats.peak)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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' : ''
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Code2,
|
Code2,
|
||||||
Cpu,
|
Cpu,
|
||||||
Camera,
|
Camera,
|
||||||
|
HardDrive,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -83,6 +84,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
|
|
||||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||||
|
const isStoragePage = location.pathname.startsWith('/storage')
|
||||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||||
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
|
||||||
const isHostReportPage = location.pathname.startsWith('/host-report')
|
const isHostReportPage = location.pathname.startsWith('/host-report')
|
||||||
@@ -201,6 +203,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!collapsed && <span>路由管理</span>}
|
{!collapsed && <span>路由管理</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/storage')}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
isStoragePage
|
||||||
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HardDrive className="w-4 h-4" />
|
||||||
|
{!collapsed && <span>存储管理</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/audit-logs')}
|
onClick={() => navigate('/audit-logs')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router'
|
||||||
import api, { login as apiLogin, checkAuth, LoginResponse } from '../services/api'
|
import api, { login as apiLogin, checkAuth, LoginResponse } from '../services/api'
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ body {
|
|||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
@@ -83,6 +84,8 @@ body {
|
|||||||
/* Shadow */
|
/* Shadow */
|
||||||
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
||||||
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
||||||
|
.dark .shadow-lg,
|
||||||
|
.dark .shadow-xl { box-shadow: 0 12px 28px rgba(0,0,0,0.45) !important; }
|
||||||
|
|
||||||
/* bg-black buttons in dark mode -> light */
|
/* bg-black buttons in dark mode -> light */
|
||||||
.dark .bg-black { background-color: #f9fafb !important; }
|
.dark .bg-black { background-color: #f9fafb !important; }
|
||||||
@@ -121,6 +124,7 @@ body {
|
|||||||
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||||
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||||
.dark .bg-amber-100 { background-color: #78350f !important; }
|
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||||
|
.dark .bg-indigo-50 { background-color: #1e1b4b !important; }
|
||||||
|
|
||||||
/* Status badge text */
|
/* Status badge text */
|
||||||
.dark .text-green-700 { color: #6ee7b7 !important; }
|
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||||
@@ -129,6 +133,14 @@ body {
|
|||||||
.dark .text-amber-600 { color: #fcd34d !important; }
|
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||||
.dark .text-amber-700 { color: #fcd34d !important; }
|
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||||
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
||||||
|
.dark .text-emerald-600 { color: #6ee7b7 !important; }
|
||||||
|
.dark .text-amber-800 { color: #fde68a !important; }
|
||||||
|
.dark .text-indigo-700 { color: #a5b4fc !important; }
|
||||||
|
|
||||||
|
/* Colored notification borders */
|
||||||
|
.dark .border-emerald-200 { border-color: #065f46 !important; }
|
||||||
|
.dark .border-red-200 { border-color: #991b1b !important; }
|
||||||
|
.dark .border-amber-200 { border-color: #92400e !important; }
|
||||||
|
|
||||||
/* Focus ring */
|
/* Focus ring */
|
||||||
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||||
@@ -137,6 +149,37 @@ body {
|
|||||||
/* Accent */
|
/* Accent */
|
||||||
.dark .accent-black { accent-color: #f9fafb !important; }
|
.dark .accent-black { accent-color: #f9fafb !important; }
|
||||||
|
|
||||||
|
/* Native form controls */
|
||||||
|
.dark input,
|
||||||
|
.dark select,
|
||||||
|
.dark textarea { color-scheme: dark; }
|
||||||
|
|
||||||
|
/* Explicit dark variants take precedence over the compatibility overrides above. */
|
||||||
|
.dark .dark\:bg-white { background-color: #f9fafb !important; }
|
||||||
|
.dark .dark\:bg-gray-950 { background-color: #030712 !important; }
|
||||||
|
.dark .dark\:bg-gray-900 { background-color: #111827 !important; }
|
||||||
|
.dark .dark\:bg-gray-800 { background-color: #1f2937 !important; }
|
||||||
|
.dark .dark\:bg-gray-700 { background-color: #374151 !important; }
|
||||||
|
.dark .dark\:bg-emerald-950 { background-color: #022c22 !important; }
|
||||||
|
.dark .dark\:bg-red-950 { background-color: #450a0a !important; }
|
||||||
|
.dark .dark\:bg-amber-950 { background-color: #451a03 !important; }
|
||||||
|
.dark .dark\:text-white { color: #f9fafb !important; }
|
||||||
|
.dark .dark\:text-black { color: #111827 !important; }
|
||||||
|
.dark .dark\:text-gray-300 { color: #d1d5db !important; }
|
||||||
|
.dark .dark\:text-gray-400 { color: #9ca3af !important; }
|
||||||
|
.dark .dark\:text-gray-500 { color: #6b7280 !important; }
|
||||||
|
.dark .dark\:text-emerald-300 { color: #6ee7b7 !important; }
|
||||||
|
.dark .dark\:text-red-300 { color: #fca5a5 !important; }
|
||||||
|
.dark .dark\:text-amber-300 { color: #fcd34d !important; }
|
||||||
|
.dark .dark\:border-gray-700 { border-color: #374151 !important; }
|
||||||
|
.dark .dark\:border-emerald-800 { border-color: #065f46 !important; }
|
||||||
|
.dark .dark\:border-red-800 { border-color: #991b1b !important; }
|
||||||
|
.dark .dark\:border-amber-800 { border-color: #92400e !important; }
|
||||||
|
.dark .dark\:hover\:bg-gray-800:hover { background-color: #1f2937 !important; color: inherit !important; }
|
||||||
|
.dark .dark\:hover\:bg-gray-700:hover { background-color: #374151 !important; color: inherit !important; }
|
||||||
|
.dark .dark\:hover\:bg-gray-200:hover { background-color: #e5e7eb !important; color: #111827 !important; }
|
||||||
|
.dark .dark\:hover\:text-white:hover { color: #f9fafb !important; }
|
||||||
|
|
||||||
/* Spinner */
|
/* Spinner */
|
||||||
.dark .border-black { border-color: #f9fafb !important; }
|
.dark .border-black { border-color: #f9fafb !important; }
|
||||||
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||||
@@ -157,4 +200,6 @@ body {
|
|||||||
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
|
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
|
||||||
.dark .peer-checked\:bg-black:checked + *,
|
.dark .peer-checked\:bg-black:checked + *,
|
||||||
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
|
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
|
||||||
|
.dark .access-policy-switch .access-policy-switch-thumb { background-color: #e5e7eb !important; }
|
||||||
|
.dark .access-policy-switch[aria-checked="true"] .access-policy-switch-thumb { background-color: #111827 !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const scopeGroups = [
|
|||||||
['container:delete', '删除容器'],
|
['container:delete', '删除容器'],
|
||||||
['container:resize', '资源/到期'],
|
['container:resize', '资源/到期'],
|
||||||
['container:traffic', '流量管理'],
|
['container:traffic', '流量管理'],
|
||||||
['container:network', '端口映射'],
|
['container:network', '网络与端口映射'],
|
||||||
['container:password', '重置密码'],
|
['container:password', '重置密码'],
|
||||||
['ipv6:assign', '分配 IPv6'],
|
['ipv6:assign', '分配 IPv6'],
|
||||||
],
|
],
|
||||||
@@ -140,6 +140,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
['GET', '/api/v1/dashboard', '控制面板统计'],
|
['GET', '/api/v1/dashboard', '控制面板统计'],
|
||||||
['GET', '/api/v1/host-info', '主机资源'],
|
['GET', '/api/v1/host-info', '主机资源'],
|
||||||
|
['GET', '/api/v1/host-history', '宿主机历史指标(后台每 30 秒采集)'],
|
||||||
|
['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 段'],
|
||||||
@@ -161,6 +163,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
|||||||
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
|
['POST', '/api/v1/containers/{id}/reinstall', '重装'],
|
||||||
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
|
['DELETE', '/api/v1/containers/{id}/delete', '删除'],
|
||||||
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
|
['GET', '/api/v1/containers/{id}/usage', '资源用量'],
|
||||||
|
['GET', '/api/v1/containers/{id}/history', '容器历史指标(后台每 30 秒采集)'],
|
||||||
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
|
['GET', '/api/v1/containers/{id}/traffic', '流量统计'],
|
||||||
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
|
['POST', '/api/v1/containers/{id}/traffic-reset', '重置流量'],
|
||||||
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
|
['PUT', '/api/v1/containers/{id}/traffic-limit', '调整流量限制'],
|
||||||
@@ -168,6 +171,8 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
|||||||
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
|
['PUT', '/api/v1/containers/{id}/expiry', '调整到期时间'],
|
||||||
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
|
['POST', '/api/v1/containers/{id}/reset-password', '重置 SSH 密码'],
|
||||||
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
|
['POST', '/api/v1/containers/{id}/ipv6', '分配 IPv6'],
|
||||||
|
['PUT', '/api/v1/containers/{id}/public-ipv4', '更新独立公网 IPv4 地址'],
|
||||||
|
['PUT', '/api/v1/containers/{id}/ipv6-addresses', '更新独立 IPv6 地址'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -193,6 +198,9 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
['GET', '/api/v1/templates', '模板列表'],
|
['GET', '/api/v1/templates', '模板列表'],
|
||||||
['GET', '/api/v1/images', '镜像管理列表'],
|
['GET', '/api/v1/images', '镜像管理列表'],
|
||||||
|
['GET', '/api/v1/images/enabled?type=lxc&container={id}', '可用于创建或重装的已启用镜像'],
|
||||||
|
['POST', '/api/v1/images/custom', '添加第三方 LXC/KVM 镜像源'],
|
||||||
|
['DELETE', '/api/v1/images/custom', '移除第三方 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', '删除镜像缓存'],
|
||||||
@@ -211,6 +219,23 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
|
|||||||
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
|
['POST', '/api/v1/vnc-ticket', '创建 WebVNC 票据'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '主机与设置',
|
||||||
|
endpoints: [
|
||||||
|
['GET', '/api/v1/storage', '已挂载磁盘、存储池和空间占用'],
|
||||||
|
['PUT', '/api/v1/storage', '更新各磁盘的存储用途和默认盘'],
|
||||||
|
['GET', '/api/v1/task-queue/settings', '任务队列并发状态'],
|
||||||
|
['PUT', '/api/v1/task-queue/settings', '调整任务并发数量'],
|
||||||
|
['GET', '/api/v1/ssl', 'SSL 配置和证书状态'],
|
||||||
|
['PUT', '/api/v1/ssl', '更新 SSL 配置'],
|
||||||
|
['GET', '/api/v1/webssh-origins', 'WebSSH/VNC Origin 白名单'],
|
||||||
|
['PUT', '/api/v1/webssh-origins', '更新 WebSSH/VNC Origin 白名单'],
|
||||||
|
['GET', '/api/v1/access-policy', '面板访问来源策略'],
|
||||||
|
['PUT', '/api/v1/access-policy', '更新面板访问来源策略'],
|
||||||
|
['GET', '/api/v1/language', '面板语言'],
|
||||||
|
['PUT', '/api/v1/language', '更新面板语言'],
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '账号与日志',
|
title: '账号与日志',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
@@ -728,6 +753,7 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
name: 'demo-lxc-01',
|
name: 'demo-lxc-01',
|
||||||
virtualization: 'lxc',
|
virtualization: 'lxc',
|
||||||
template_id: 'debian-bookworm',
|
template_id: 'debian-bookworm',
|
||||||
|
storage_pool_id: 'disk-root',
|
||||||
vcpu: 1,
|
vcpu: 1,
|
||||||
ram_mb: 512,
|
ram_mb: 512,
|
||||||
disk_gb: 10,
|
disk_gb: 10,
|
||||||
@@ -741,10 +767,26 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
io_speed_mbps: 0,
|
io_speed_mbps: 0,
|
||||||
io_read_mbps: 80,
|
io_read_mbps: 80,
|
||||||
io_write_mbps: 30,
|
io_write_mbps: 30,
|
||||||
extra_ports: [8080],
|
extra_ports: [],
|
||||||
|
nat_port_mappings: [
|
||||||
|
{
|
||||||
|
host_port: 30080,
|
||||||
|
container_port: 80,
|
||||||
|
protocol: 'tcp',
|
||||||
|
description: 'HTTP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
management_port: 30022,
|
||||||
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,
|
||||||
|
allowed_image_ids: ['debian-bookworm'],
|
||||||
|
image_limit_configured: true,
|
||||||
assign_ipv4: false,
|
assign_ipv4: false,
|
||||||
ipv4_count: 1,
|
ipv4_count: 1,
|
||||||
public_ipv4s: [],
|
public_ipv4s: [],
|
||||||
@@ -780,6 +822,14 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
},
|
},
|
||||||
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
|
||||||
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
|
||||||
|
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||||
|
mode: 'random',
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||||
|
mode: 'custom',
|
||||||
|
addresses: ['2001:db8:100::1005'],
|
||||||
|
},
|
||||||
'POST /api/v1/containers/{id}/port-mappings': {
|
'POST /api/v1/containers/{id}/port-mappings': {
|
||||||
container_port: 8080,
|
container_port: 8080,
|
||||||
host_port: 61320,
|
host_port: 61320,
|
||||||
@@ -792,16 +842,59 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
protocol: 'tcp',
|
protocol: 'tcp',
|
||||||
description: 'HTTP',
|
description: 'HTTP',
|
||||||
},
|
},
|
||||||
|
'POST /api/v1/containers/{id}/snapshots': { storage_pool_id: 'disk-root' },
|
||||||
'POST /api/v1/containers/{id}/snapshots/schedule': {
|
'POST /api/v1/containers/{id}/snapshots/schedule': {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
interval_hours: 24,
|
interval_hours: 24,
|
||||||
time: '03:00',
|
time: '03:00',
|
||||||
},
|
},
|
||||||
'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 },
|
'PUT /api/v1/containers/{id}/snapshots/quota': { snapshot_limit: 2 },
|
||||||
|
'POST /api/v1/images/custom': {
|
||||||
|
type: 'kvm',
|
||||||
|
name: 'Custom Ubuntu Cloud',
|
||||||
|
description: 'Private mirror image',
|
||||||
|
distro: 'ubuntu',
|
||||||
|
release: 'noble',
|
||||||
|
arch: 'amd64',
|
||||||
|
url: 'https://images.example.com/ubuntu-noble.qcow2',
|
||||||
|
provisioner: 'linux-cloud-init',
|
||||||
|
sha256: '',
|
||||||
|
},
|
||||||
|
'DELETE /api/v1/images/custom': { id: 'custom-kvm-a1b2c3d4e5' },
|
||||||
'POST /api/v1/images/download': { template_id: 'debian-bookworm' },
|
'POST /api/v1/images/download': { template_id: 'debian-bookworm' },
|
||||||
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
|
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
|
||||||
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
|
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
|
||||||
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
|
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
|
||||||
|
'PUT /api/v1/storage': {
|
||||||
|
pools: [
|
||||||
|
{
|
||||||
|
id: 'disk-root',
|
||||||
|
name: 'system (/)',
|
||||||
|
path: '/var/lib/clicd',
|
||||||
|
mount_point: '/',
|
||||||
|
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||||
|
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'PUT /api/v1/task-queue/settings': { concurrency: 4 },
|
||||||
|
'PUT /api/v1/ssl': {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'letsencrypt',
|
||||||
|
target: 'panel.example.com',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
apply_now: false,
|
||||||
|
},
|
||||||
|
'PUT /api/v1/webssh-origins': {
|
||||||
|
origins: ['https://panel.example.com'],
|
||||||
|
},
|
||||||
|
'PUT /api/v1/access-policy': {
|
||||||
|
enabled: true,
|
||||||
|
allowed_sources: ['203.0.113.10', '192.168.1.0/24', '2001:db8::/32'],
|
||||||
|
trusted_proxies: ['127.0.0.1'],
|
||||||
|
},
|
||||||
|
'PUT /api/v1/language': { language: 'zh' },
|
||||||
'PUT /api/v1/routing': {
|
'PUT /api/v1/routing': {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -850,7 +943,16 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
|
|||||||
ram_mb: 512,
|
ram_mb: 512,
|
||||||
disk_gb: 10,
|
disk_gb: 10,
|
||||||
assign_nat: true,
|
assign_nat: true,
|
||||||
|
management_port: 30022,
|
||||||
port_mapping_count: 2,
|
port_mapping_count: 2,
|
||||||
|
nat_port_mappings: [
|
||||||
|
{
|
||||||
|
host_port: 30080,
|
||||||
|
container_port: 80,
|
||||||
|
protocol: 'tcp',
|
||||||
|
description: 'HTTP',
|
||||||
|
},
|
||||||
|
],
|
||||||
snapshot_limit: 1,
|
snapshot_limit: 1,
|
||||||
assign_ipv4: false,
|
assign_ipv4: false,
|
||||||
ipv4_count: 1,
|
ipv4_count: 1,
|
||||||
@@ -910,10 +1012,47 @@ const responseSamples: Record<string, unknown> = {
|
|||||||
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
|
load: { load1: 0.01, load5: 0.03, load15: 0.01 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'GET /api/v1/host-history': {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
ts: 1784642400000,
|
||||||
|
cpu: 8.4,
|
||||||
|
memory: 21.3,
|
||||||
|
network: 12288,
|
||||||
|
network_rx: 10240,
|
||||||
|
network_tx: 2048,
|
||||||
|
disk_io: 1052672,
|
||||||
|
disk_read: 4096,
|
||||||
|
disk_write: 1048576,
|
||||||
|
disk_usage_pct: 18.8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'GET /api/v1/host-report': {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
generated_at: '2026-07-21 14:00:00',
|
||||||
|
hostname: 'ubuntu',
|
||||||
|
os: 'Ubuntu 22.04.5 LTS',
|
||||||
|
kernel: 'Linux 6.8.0-1054-oracle aarch64 GNU/Linux',
|
||||||
|
cpu: { model: 'Neoverse-N1', cores: 4, threads: 4, architecture: 'arm64', virtualization: true },
|
||||||
|
memory: { total_mb: 11980, used_mb: 2100, free_mb: 9880, modules: [] },
|
||||||
|
runtime: { lxc_available: true, kvm_available: false, support_mode: 'lxc_only' },
|
||||||
|
public_ipv4: [{ address: '203.0.113.10', interface: 'eth0' }],
|
||||||
|
ipv6_prefixes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
'GET /api/v1/routing': {
|
'GET /api/v1/routing': {
|
||||||
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 },
|
||||||
|
nat4_next_port: 22005,
|
||||||
|
nat4_networks: {
|
||||||
|
lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1', netmask: '255.255.255.0', dhcp_start: '10.0.3.2', dhcp_end: '10.0.3.254', dhcp_max: 253, prefix_bits: 24 },
|
||||||
|
kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1', netmask: '255.255.255.0', dhcp_start: '192.168.122.2', dhcp_end: '192.168.122.254', dhcp_max: 253, prefix_bits: 24 },
|
||||||
|
},
|
||||||
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 +1066,13 @@ 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 },
|
||||||
|
nat4_next_port: 22005,
|
||||||
|
nat4_networks: {
|
||||||
|
lxc: { subnet: '10.0.3.0/24', gateway: '10.0.3.1' },
|
||||||
|
kvm: { subnet: '192.168.122.0/24', gateway: '192.168.122.1' },
|
||||||
|
},
|
||||||
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' }],
|
||||||
@@ -1013,6 +1159,12 @@ const responseSamples: Record<string, unknown> = {
|
|||||||
load15: 0.01,
|
load15: 0.01,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'GET /api/v1/containers/{id}/history': {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{ ts: 1784642400000, cpu: 1.2, memory: 5.6, network: 4096, network_rx: 3072, network_tx: 1024, disk_io: 8192, disk_read: 2048, disk_write: 6144 },
|
||||||
|
],
|
||||||
|
},
|
||||||
'GET /api/v1/containers/{id}/traffic': {
|
'GET /api/v1/containers/{id}/traffic': {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -1033,6 +1185,16 @@ const responseSamples: Record<string, unknown> = {
|
|||||||
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
|
'PUT /api/v1/containers/{id}/expiry': { success: true, message: 'Expiry updated' },
|
||||||
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
|
'POST /api/v1/containers/{id}/reset-password': { success: true, message: 'SSH password reset successfully', data: { password: '***' } },
|
||||||
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
|
'POST /api/v1/containers/{id}/ipv6': { success: true, message: 'IPv6 assigned', data: { id: 5, name: 'example-vm', ipv6: '2001:db8:100::1005' } },
|
||||||
|
'PUT /api/v1/containers/{id}/public-ipv4': {
|
||||||
|
success: true,
|
||||||
|
message: 'Public IPv4 assignments updated',
|
||||||
|
data: { id: 5, name: 'example-vm', public_ipv4s: ['203.0.113.10'] },
|
||||||
|
},
|
||||||
|
'PUT /api/v1/containers/{id}/ipv6-addresses': {
|
||||||
|
success: true,
|
||||||
|
message: 'IPv6 assignments updated',
|
||||||
|
data: { id: 5, name: 'example-vm', ipv6_addresses: ['2001:db8:100::1005'] },
|
||||||
|
},
|
||||||
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
|
'GET /api/v1/containers/{id}/random-port': { success: true, data: { port: 61320 } },
|
||||||
'POST /api/v1/containers/{id}/port-mappings': {
|
'POST /api/v1/containers/{id}/port-mappings': {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1097,10 +1259,65 @@ const responseSamples: Record<string, unknown> = {
|
|||||||
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
|
{ id: 'ubuntu-noble', name: 'Ubuntu 24.04', type: 'lxc', downloaded: true, enabled: true, downloading: false, progress: 0, size_bytes: 135005452 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
'GET /api/v1/images/enabled?type=lxc&container={id}': {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{ id: 'debian-bookworm', name: 'Debian 12', distro: 'debian', release: 'bookworm', arch: 'amd64', type: 'lxc', downloaded: true, enabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'POST /api/v1/images/custom': {
|
||||||
|
success: true,
|
||||||
|
message: 'Custom image added',
|
||||||
|
data: { id: 'custom-kvm-a1b2c3d4e5', name: 'Custom Ubuntu Cloud' },
|
||||||
|
},
|
||||||
|
'DELETE /api/v1/images/custom': { success: true, message: 'Custom image removed' },
|
||||||
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
|
'POST /api/v1/images/download': { success: true, message: 'Already downloaded' },
|
||||||
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
|
'POST /api/v1/images/cancel': { success: true, message: 'Cancel requested' },
|
||||||
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
|
'DELETE /api/v1/images/delete': { success: true, message: 'Deleted' },
|
||||||
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
|
'PUT /api/v1/images/toggle': { success: true, message: 'OK' },
|
||||||
|
'GET /api/v1/storage': {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
pools: [
|
||||||
|
{
|
||||||
|
id: 'disk-root',
|
||||||
|
name: 'system (/)',
|
||||||
|
path: '/var/lib/clicd',
|
||||||
|
mount_point: '/',
|
||||||
|
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||||
|
default_contents: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||||
|
enabled: true,
|
||||||
|
available: true,
|
||||||
|
free_bytes: 54653493248,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
disks: [
|
||||||
|
{ name: 'sda2', path: '/dev/sda2', fstype: 'ext4', mount_point: '/', size_bytes: 67331063808, used_bytes: 12677570560, free_bytes: 54653493248 },
|
||||||
|
],
|
||||||
|
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'PUT /api/v1/storage': {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
pools: [{ id: 'disk-root', path: '/var/lib/clicd', mount_point: '/', content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'], enabled: true, available: true }],
|
||||||
|
disks: [],
|
||||||
|
content_types: ['lxc', 'kvm', 'images', 'snapshots', 'backups'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'GET /api/v1/task-queue/settings': { success: true, data: { concurrency: 4, active: 1, pending: 2 } },
|
||||||
|
'PUT /api/v1/task-queue/settings': { success: true, message: '任务队列设置已保存', data: { concurrency: 4, active: 1, pending: 2 } },
|
||||||
|
'GET /api/v1/ssl': {
|
||||||
|
success: true,
|
||||||
|
data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', email: 'admin@example.com', detected_host: 'panel.example.com', certificate: { subject: 'panel.example.com', issuer: "Let's Encrypt", dns_names: ['panel.example.com'], ip_names: [], valid: true } },
|
||||||
|
},
|
||||||
|
'PUT /api/v1/ssl': { success: true, message: 'SSL settings saved', data: { enabled: true, mode: 'letsencrypt', target: 'panel.example.com', needs_restart: true } },
|
||||||
|
'GET /api/v1/webssh-origins': { success: true, data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
|
||||||
|
'PUT /api/v1/webssh-origins': { success: true, message: 'Origin allowlist saved', data: { origins: ['https://panel.example.com'], current_origin: 'https://panel.example.com' } },
|
||||||
|
'GET /api/v1/access-policy': { success: true, data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } },
|
||||||
|
'PUT /api/v1/access-policy': { success: true, message: 'Panel access policy saved', data: { enabled: true, allowed_sources: ['203.0.113.10', '192.168.1.0/24'], trusted_proxies: ['127.0.0.1'], current_source: '203.0.113.10', direct_source: '127.0.0.1', using_forwarded: true } },
|
||||||
|
'GET /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||||
|
'PUT /api/v1/language': { success: true, data: { language: 'zh' } },
|
||||||
'GET /api/v1/security/alerts': { success: true, data: [] },
|
'GET /api/v1/security/alerts': { success: true, data: [] },
|
||||||
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
|
'POST /api/v1/security/check': { success: true, message: 'Security check completed' },
|
||||||
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
|
'GET /api/v1/security/logs?container={name}': { success: true, data: [] },
|
||||||
@@ -1178,13 +1395,18 @@ function endpointNoteFor(key: string) {
|
|||||||
const notes: string[] = []
|
const notes: string[] = []
|
||||||
if (key === 'POST /api/v1/containers') {
|
if (key === 'POST /api/v1/containers') {
|
||||||
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
|
||||||
|
notes.push('Set management_port to choose the public/source port for SSH (target 22) or Windows RDP (target 3389). Omit it or pass 0 for automatic allocation.')
|
||||||
|
notes.push('For other custom NAT rules, use nat_port_mappings with host_port (public/source port), container_port (target port), and protocol=tcp|udp. extra_ports remains accepted for compatibility and maps each port to the same port inside the container.')
|
||||||
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
|
||||||
|
notes.push('storage_pool_id selects an enabled disk for the runtime. For an LXC with an independent LAN address, set lan_ipv4_mode=dhcp or static and set assign_nat=false; static mode also requires lan_ipv4_address, lan_ipv4_prefix_len, and lan_ipv4_gateway.')
|
||||||
|
notes.push('allowed_image_ids and image_limit_configured define which downloaded images the container owner may use for reinstall. Include the initial template ID when it should remain reinstallable.')
|
||||||
}
|
}
|
||||||
if (key === 'POST /api/v1/containers/{id}/reinstall') {
|
if (key === 'POST /api/v1/containers/{id}/reinstall') {
|
||||||
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
|
notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
|
||||||
}
|
}
|
||||||
if (key === 'POST /api/v1/batch-create') {
|
if (key === 'POST /api/v1/batch-create') {
|
||||||
notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.')
|
notes.push('Each containers[] item in batch creation supports the same storage, network, image allowlist, and SSH authentication fields as POST /api/v1/containers.')
|
||||||
|
notes.push('Custom management_port and NAT host_port values must be unique across the batch. The panel places each later source-port group after the previous container\'s highest public port while keeping every target container_port unchanged; direct API clients should submit the expanded values explicitly.')
|
||||||
}
|
}
|
||||||
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
|
||||||
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
|
||||||
@@ -1196,11 +1418,38 @@ 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.')
|
||||||
}
|
}
|
||||||
|
if (key === 'GET /api/v1/host-history' || key === 'GET /api/v1/containers/{id}/history') {
|
||||||
|
notes.push('Metrics are collected in the background every 30 seconds, even when the statistics page is closed.')
|
||||||
|
}
|
||||||
|
if (key === 'PUT /api/v1/containers/{id}/public-ipv4' || key === 'PUT /api/v1/containers/{id}/ipv6-addresses') {
|
||||||
|
notes.push('mode accepts random, custom, or clear. random uses count, custom uses addresses, and clear removes all assignments of that address family.')
|
||||||
|
}
|
||||||
|
if (key === 'GET /api/v1/images/enabled?type=lxc&container={id}') {
|
||||||
|
notes.push('type accepts lxc or kvm. Supplying container applies that container image allowlist; omit container when listing images for a new container.')
|
||||||
|
}
|
||||||
|
if (key === 'POST /api/v1/containers/{id}/snapshots') {
|
||||||
|
notes.push('storage_pool_id is optional. The selected pool must be enabled for snapshots; otherwise the server chooses an available snapshot pool by free space and default priority.')
|
||||||
|
}
|
||||||
|
if (key === 'PUT /api/v1/storage') {
|
||||||
|
notes.push('Start from GET /api/v1/storage and submit mounted disks returned by the server. Paths and mount points are server-managed and custom paths are rejected. content_types enables a disk for each workload; only one pool may be the default for each type.')
|
||||||
|
}
|
||||||
|
if (key.includes('/api/v1/storage') || key.includes('/task-queue/settings') || key.includes('/api/v1/ssl') || key.includes('/webssh-origins') || key.includes('/access-policy')) {
|
||||||
|
notes.push('This endpoint requires an API key with admin:access.')
|
||||||
|
}
|
||||||
|
if (key === 'PUT /api/v1/access-policy') {
|
||||||
|
notes.push('allowed_sources and trusted_proxies accept IPv4, IPv6, or CIDR values. Forwarded client headers are ignored unless the direct peer matches trusted_proxies. The server rejects an enabled policy that excludes the current source.')
|
||||||
|
}
|
||||||
|
if (key === 'PUT /api/v1/task-queue/settings') {
|
||||||
|
notes.push('concurrency must be between 1 and 16. Tasks targeting the same container are still serialized.')
|
||||||
|
}
|
||||||
|
if (key === 'PUT /api/v1/ssl') {
|
||||||
|
notes.push('mode accepts disabled, letsencrypt, self_signed, or uploaded. uploaded mode uses cert_pem and key_pem. apply_now requests a service restart after saving.')
|
||||||
|
}
|
||||||
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
|
if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
|
||||||
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
|
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
|
||||||
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
|
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
assignIPv6,
|
assignIPv6,
|
||||||
APIResponse,
|
APIResponse,
|
||||||
Container,
|
Container,
|
||||||
|
ContainerMetricPoint as ContainerMetricSample,
|
||||||
ContainerUsage,
|
ContainerUsage,
|
||||||
createSubUser,
|
createSubUser,
|
||||||
createContainerSnapshot,
|
createContainerSnapshot,
|
||||||
@@ -39,16 +40,21 @@ import {
|
|||||||
deleteContainerSnapshot,
|
deleteContainerSnapshot,
|
||||||
deletePortMapping,
|
deletePortMapping,
|
||||||
getContainer,
|
getContainer,
|
||||||
|
getContainerHistory,
|
||||||
getContainerSnapshots,
|
getContainerSnapshots,
|
||||||
getContainerUsage,
|
getContainerUsage,
|
||||||
getHostInfo,
|
getHostInfo,
|
||||||
|
getStorageInfo,
|
||||||
getTrafficInfo,
|
getTrafficInfo,
|
||||||
HostInfo,
|
HostInfo,
|
||||||
TrafficInfo,
|
TrafficInfo,
|
||||||
getEnabledImages,
|
getEnabledImages,
|
||||||
getFirewall,
|
getFirewall,
|
||||||
PortMapping,
|
PortMapping,
|
||||||
|
PublicIPv4Info,
|
||||||
FirewallRule,
|
FirewallRule,
|
||||||
|
updatePublicIPv4Assignments,
|
||||||
|
updateIPv6Assignments,
|
||||||
reinstallContainer,
|
reinstallContainer,
|
||||||
resetSSHPassword,
|
resetSSHPassword,
|
||||||
restartContainer,
|
restartContainer,
|
||||||
@@ -56,6 +62,7 @@ import {
|
|||||||
stopContainer,
|
stopContainer,
|
||||||
Snapshot,
|
Snapshot,
|
||||||
SnapshotSchedule,
|
SnapshotSchedule,
|
||||||
|
StorageInfo,
|
||||||
Template,
|
Template,
|
||||||
updateContainerExpiry,
|
updateContainerExpiry,
|
||||||
updateFirewall,
|
updateFirewall,
|
||||||
@@ -70,6 +77,7 @@ import {
|
|||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
import { useDialog } from '../components/Dialog'
|
import { useDialog } from '../components/Dialog'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import WebSSHViewer from '../components/WebSSHViewer'
|
import WebSSHViewer from '../components/WebSSHViewer'
|
||||||
import WebVNCViewer from '../components/WebVNCViewer'
|
import WebVNCViewer from '../components/WebVNCViewer'
|
||||||
import { RingStat } from '../components/RingStats'
|
import { RingStat } from '../components/RingStats'
|
||||||
@@ -89,8 +97,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
|
||||||
@@ -101,6 +113,8 @@ type MappingDraft = {
|
|||||||
protocol: string
|
protocol: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPAssignMode = 'clear' | 'random' | 'custom'
|
||||||
|
|
||||||
const emptyDraft: MappingDraft = {
|
const emptyDraft: MappingDraft = {
|
||||||
index: null,
|
index: null,
|
||||||
description: '',
|
description: '',
|
||||||
@@ -116,6 +130,7 @@ export default function ContainerDetail() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { isSubUser } = useAuth()
|
const { isSubUser } = useAuth()
|
||||||
|
const { t } = useLanguage()
|
||||||
const [container, setContainer] = useState<Container | null>(null)
|
const [container, setContainer] = useState<Container | null>(null)
|
||||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||||
const [usage, setUsage] = useState<ContainerUsage | null>(null)
|
const [usage, setUsage] = useState<ContainerUsage | null>(null)
|
||||||
@@ -129,6 +144,14 @@ export default function ContainerDetail() {
|
|||||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||||
const [vncFullscreen, setVncFullscreen] = useState(false)
|
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||||
const [showNat, setShowNat] = useState(false)
|
const [showNat, setShowNat] = useState(false)
|
||||||
|
const [showIPAssign, setShowIPAssign] = useState(false)
|
||||||
|
const [savingIPAssign, setSavingIPAssign] = useState(false)
|
||||||
|
const [ipv4AssignMode, setIPv4AssignMode] = useState<IPAssignMode>('clear')
|
||||||
|
const [ipv4AssignCount, setIPv4AssignCount] = useState(1)
|
||||||
|
const [ipv4Selected, setIPv4Selected] = useState<string[]>([])
|
||||||
|
const [ipv6AssignMode, setIPv6AssignMode] = useState<IPAssignMode>('clear')
|
||||||
|
const [ipv6AssignCount, setIPv6AssignCount] = useState(1)
|
||||||
|
const [ipv6DraftText, setIPv6DraftText] = useState('')
|
||||||
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
const [showMappingEditor, setShowMappingEditor] = useState(false)
|
||||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||||
const [editExpiry, setEditExpiry] = useState('')
|
const [editExpiry, setEditExpiry] = useState('')
|
||||||
@@ -163,6 +186,9 @@ export default function ContainerDetail() {
|
|||||||
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
const [editingSnapshotQuota, setEditingSnapshotQuota] = useState(false)
|
||||||
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
const [snapshotSchedule, setSnapshotSchedule] = useState<SnapshotSchedule | null>(null)
|
||||||
const [snapshotBusy, setSnapshotBusy] = useState('')
|
const [snapshotBusy, setSnapshotBusy] = useState('')
|
||||||
|
const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null)
|
||||||
|
const [storageLoading, setStorageLoading] = useState(!isSubUser)
|
||||||
|
const [snapshotStoragePoolID, setSnapshotStoragePoolID] = useState('')
|
||||||
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
const [showSnapshotSchedule, setShowSnapshotSchedule] = useState(false)
|
||||||
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
const [snapshotScheduleDraft, setSnapshotScheduleDraft] = useState({ intervalHours: 24, time: '03:00' })
|
||||||
const [showFirewall, setShowFirewall] = useState(false)
|
const [showFirewall, setShowFirewall] = useState(false)
|
||||||
@@ -205,33 +231,36 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}, [containerIdentifier, container?.snapshot_limit])
|
}, [containerIdentifier, container?.snapshot_limit])
|
||||||
|
|
||||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
const fetchStorage = useCallback(async () => {
|
||||||
if (!containerIdentifier || !currentContainer) return
|
if (isSubUser) {
|
||||||
|
setStorageLoading(false)
|
||||||
const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
|
return
|
||||||
? nextUsage.memory_total_bytes
|
|
||||||
: currentContainer.ram_mb * 1024 * 1024
|
|
||||||
const memoryPct = memoryTotalBytes > 0
|
|
||||||
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
|
||||||
: 0
|
|
||||||
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
|
||||||
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
|
|
||||||
|
|
||||||
const point: MetricPoint = {
|
|
||||||
ts: Date.now(),
|
|
||||||
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
|
||||||
memory: clamp(memoryPct),
|
|
||||||
network: networkBps,
|
|
||||||
diskIO: diskIOBps,
|
|
||||||
}
|
}
|
||||||
|
setStorageLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getStorageInfo()
|
||||||
|
setStorageInfo(res.data.data || null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch storage:', err)
|
||||||
|
setStorageInfo(null)
|
||||||
|
} finally {
|
||||||
|
setStorageLoading(false)
|
||||||
|
}
|
||||||
|
}, [isSubUser])
|
||||||
|
|
||||||
setHistory((prev) => {
|
const fetchMetricHistory = useCallback(async () => {
|
||||||
const cutoff = Date.now() - statsRanges['1w']
|
if (!containerIdentifier) return
|
||||||
const next = [...prev.filter((item) => item.ts >= cutoff), point]
|
try {
|
||||||
localStorage.setItem(historyKey(currentContainer.uuid || containerIdentifier), JSON.stringify(next))
|
const res = await getContainerHistory(containerIdentifier)
|
||||||
return next
|
const points = (res.data.data || []).map(normalizeContainerMetricSample)
|
||||||
})
|
if (points.length > 0) {
|
||||||
}, [containerIdentifier])
|
setHistory(points)
|
||||||
|
localStorage.setItem(historyKey(container?.uuid || containerIdentifier), JSON.stringify(points))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch metric history:', err)
|
||||||
|
}
|
||||||
|
}, [containerIdentifier, container?.uuid])
|
||||||
|
|
||||||
const fetchUsage = useCallback(async () => {
|
const fetchUsage = useCallback(async () => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
@@ -239,12 +268,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
|
||||||
@@ -287,8 +315,17 @@ export default function ContainerDetail() {
|
|||||||
}, [fetchUsage])
|
}, [fetchUsage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSnapshots) fetchSnapshots()
|
fetchMetricHistory()
|
||||||
}, [showSnapshots, fetchSnapshots])
|
const timer = window.setInterval(fetchMetricHistory, 30000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [fetchMetricHistory])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSnapshots) {
|
||||||
|
fetchSnapshots()
|
||||||
|
fetchStorage()
|
||||||
|
}
|
||||||
|
}, [showSnapshots, fetchSnapshots, fetchStorage])
|
||||||
|
|
||||||
// Poll task status for this container
|
// Poll task status for this container
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -513,10 +550,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('')
|
||||||
@@ -631,6 +670,42 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openIPAssign = () => {
|
||||||
|
const currentIPv4 = (container?.public_ipv4s || []).map((item) => item.address).filter(Boolean)
|
||||||
|
const currentIPv6 = (container?.ipv6_addresses || []).map((item) => item.address).filter(Boolean)
|
||||||
|
setIPv4Selected(currentIPv4)
|
||||||
|
setIPv4AssignMode(currentIPv4.length > 0 ? 'custom' : 'clear')
|
||||||
|
setIPv4AssignCount(Math.max(1, currentIPv4.length || 1))
|
||||||
|
setIPv6DraftText(currentIPv6.join('\n'))
|
||||||
|
setIPv6AssignMode(currentIPv6.length > 0 ? 'custom' : 'clear')
|
||||||
|
setIPv6AssignCount(Math.max(1, currentIPv6.length || 1))
|
||||||
|
setShowIPAssign(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitIPAssign = async () => {
|
||||||
|
if (!containerIdentifier) return
|
||||||
|
setSavingIPAssign(true)
|
||||||
|
try {
|
||||||
|
await updatePublicIPv4Assignments(containerIdentifier, {
|
||||||
|
mode: ipv4AssignMode,
|
||||||
|
count: Math.max(1, Math.round(ipv4AssignCount || 1)),
|
||||||
|
addresses: ipv4AssignMode === 'custom' ? ipv4Selected : [],
|
||||||
|
})
|
||||||
|
await updateIPv6Assignments(containerIdentifier, {
|
||||||
|
mode: ipv6AssignMode,
|
||||||
|
count: Math.max(1, Math.round(ipv6AssignCount || 1)),
|
||||||
|
addresses: ipv6AssignMode === 'custom' ? splitAddressLines(ipv6DraftText) : [],
|
||||||
|
})
|
||||||
|
await fetchContainer()
|
||||||
|
setShowIPAssign(false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
dialog.alert('公网 IP 分配失败', error.response?.data?.message || '请检查地址是否可用或已被占用')
|
||||||
|
} finally {
|
||||||
|
setSavingIPAssign(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openAddMapping = () => {
|
const openAddMapping = () => {
|
||||||
if (isSubUser && container?.policy_blocked) return
|
if (isSubUser && container?.policy_blocked) return
|
||||||
setDraft(emptyDraft)
|
setDraft(emptyDraft)
|
||||||
@@ -726,6 +801,10 @@ export default function ContainerDetail() {
|
|||||||
const handleCreateSnapshot = async () => {
|
const handleCreateSnapshot = async () => {
|
||||||
if (!containerIdentifier) return
|
if (!containerIdentifier) return
|
||||||
if (!(await ensureSubUserCanOperate())) return
|
if (!(await ensureSubUserCanOperate())) return
|
||||||
|
if (!snapshotStorageReady) {
|
||||||
|
await dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isSubUser && snapshots.length >= snapshotQuota) {
|
if (isSubUser && snapshots.length >= snapshotQuota) {
|
||||||
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
await dialog.alert('快照配额已满', '已达到管理员设置的快照配额,请先删除旧快照。')
|
||||||
return
|
return
|
||||||
@@ -739,7 +818,7 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
setSnapshotBusy('create')
|
setSnapshotBusy('create')
|
||||||
try {
|
try {
|
||||||
await createContainerSnapshot(containerIdentifier)
|
await createContainerSnapshot(containerIdentifier, { storage_pool_id: snapshotStoragePoolID || undefined })
|
||||||
await Promise.all([fetchSnapshots(), fetchContainer()])
|
await Promise.all([fetchSnapshots(), fetchContainer()])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { message?: string } } }
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
@@ -751,6 +830,10 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const openSnapshotSchedule = () => {
|
const openSnapshotSchedule = () => {
|
||||||
if (isSubUser && container?.policy_blocked) return
|
if (isSubUser && container?.policy_blocked) return
|
||||||
|
if (!snapshotStorageReady) {
|
||||||
|
dialog.alert('未配置快照存储', '请先在存储管理中为快照开启至少一块存储磁盘。')
|
||||||
|
return
|
||||||
|
}
|
||||||
setSnapshotScheduleDraft({
|
setSnapshotScheduleDraft({
|
||||||
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
intervalHours: Math.max(snapshotSchedule?.interval_hours || 24, 24),
|
||||||
time: snapshotSchedule?.time || '03:00',
|
time: snapshotSchedule?.time || '03:00',
|
||||||
@@ -865,6 +948,7 @@ export default function ContainerDetail() {
|
|||||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||||
const publicIPv4s = container.public_ipv4s || []
|
const publicIPv4s = container.public_ipv4s || []
|
||||||
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
|
||||||
|
const allocatableIPv4s = mergeIPv4Choices(hostInfo?.network.public_ipv4_addresses || [], publicIPv4s)
|
||||||
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||||
const ipv6List = (container.ipv6_addresses || [])
|
const ipv6List = (container.ipv6_addresses || [])
|
||||||
.map((item) => item.address)
|
.map((item) => item.address)
|
||||||
@@ -875,6 +959,10 @@ export default function ContainerDetail() {
|
|||||||
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
const hasIndependentIPv4 = assignedIPv4List.length > 0
|
||||||
const hasIndependentIPv6 = ipv6List.length > 0
|
const hasIndependentIPv6 = ipv6List.length > 0
|
||||||
const defaultConnPort = isWindows ? 3389 : 22
|
const defaultConnPort = isWindows ? 3389 : 22
|
||||||
|
const snapshotStoragePools = (storageInfo?.pools || []).filter((pool) =>
|
||||||
|
pool.enabled !== false && pool.available !== false && (pool.content_types || []).includes('snapshots')
|
||||||
|
)
|
||||||
|
const snapshotStorageReady = isSubUser || snapshotStoragePools.length > 0
|
||||||
|
|
||||||
let publicEndpoint = '-'
|
let publicEndpoint = '-'
|
||||||
let sshCommand = ''
|
let sshCommand = ''
|
||||||
@@ -907,20 +995,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 +1058,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)}%`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1171,22 +1270,35 @@ export default function ContainerDetail() {
|
|||||||
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
<PlainRow label="vCPU" value={`${container.vcpu} 核`} />
|
||||||
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
<PlainRow label="内存" value={`${container.ram_mb} MB`} />
|
||||||
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
|
||||||
<PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
|
<PlainRow label="网络速率" value={formatDirectionalLimit(t('下行'), networkDownLimit, t('上行'), networkUpLimit, 'Mbps')} />
|
||||||
<PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
|
<PlainRow label="IO 速度" value={formatDirectionalLimit(t('读取'), ioReadLimit, t('写入'), ioWriteLimit, 'MB/s')} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="实时状态">
|
<Panel title="实时状态">
|
||||||
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
||||||
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
||||||
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
||||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
|
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText}>
|
||||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
{!isSubUser && (
|
||||||
{!isSubUser && ipv6List.length === 0 && (
|
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||||
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
<Pencil className="w-3 h-3" />
|
||||||
Assign
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</PlainRow>
|
</PlainRow>
|
||||||
|
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||||
|
{!isSubUser && (
|
||||||
|
<>
|
||||||
|
{ipv6List.length === 0 && (
|
||||||
|
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PlainRow>
|
||||||
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
|
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
|
||||||
<PlainRow label="创建时间" value={container.created_at} />
|
<PlainRow label="创建时间" value={container.created_at} />
|
||||||
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
|
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
|
||||||
@@ -1419,7 +1531,7 @@ export default function ContainerDetail() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={openSnapshotSchedule}
|
onClick={openSnapshotSchedule}
|
||||||
disabled={!!snapshotBusy}
|
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady}
|
||||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs ${
|
||||||
snapshotSchedule?.enabled
|
snapshotSchedule?.enabled
|
||||||
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
? 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||||
@@ -1431,7 +1543,7 @@ export default function ContainerDetail() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateSnapshot}
|
onClick={handleCreateSnapshot}
|
||||||
disabled={!!snapshotBusy || (isSubUser && snapshots.length >= snapshotQuota)}
|
disabled={!!snapshotBusy || storageLoading || !snapshotStorageReady || (isSubUser && snapshots.length >= snapshotQuota)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-1.5 text-xs text-white hover:bg-gray-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Camera className="w-3.5 h-3.5" />
|
<Camera className="w-3.5 h-3.5" />
|
||||||
@@ -1441,6 +1553,20 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{storageLoading && !isSubUser && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
正在检查存储配置...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!storageLoading && !snapshotStorageReady && (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
<span>尚未开启快照存储,无法新建或启用定时快照。</span>
|
||||||
|
<button onClick={() => { setShowSnapshots(false); navigate('/storage') }} className="shrink-0 rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100">
|
||||||
|
去开启
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-600">
|
||||||
<div>
|
<div>
|
||||||
快照数量:
|
快照数量:
|
||||||
@@ -1476,6 +1602,26 @@ export default function ContainerDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isSubUser && snapshotStoragePools.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||||
|
<Field label="新建快照存储磁盘">
|
||||||
|
<select
|
||||||
|
value={snapshotStoragePoolID}
|
||||||
|
onChange={(event) => setSnapshotStoragePoolID(event.target.value)}
|
||||||
|
className="w-72 px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black"
|
||||||
|
>
|
||||||
|
<option value="">自动选择(默认盘优先,空间不足自动切换)</option>
|
||||||
|
{snapshotStoragePools.map((pool) => (
|
||||||
|
<option key={pool.id} value={pool.id}>
|
||||||
|
{pool.name} · {pool.mount_point || pool.path}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="pb-2 text-xs text-gray-400">仅影响手动新建快照;定时快照使用默认磁盘。</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingSnapshotQuota && !isSubUser && (
|
{editingSnapshotQuota && !isSubUser && (
|
||||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||||
<Field label="子用户每台容器快照上限">
|
<Field label="子用户每台容器快照上限">
|
||||||
@@ -1807,6 +1953,88 @@ export default function ContainerDetail() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showIPAssign && (
|
||||||
|
<Modal title="公网 IP 分配" onClose={() => setShowIPAssign(false)} wide>
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">独立 IPv4</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">修改后会重放端口映射、SNAT 和防火墙规则。</p>
|
||||||
|
</div>
|
||||||
|
<Segmented value={ipv4AssignMode} onChange={setIPv4AssignMode} />
|
||||||
|
{ipv4AssignMode === 'random' && (
|
||||||
|
<Field label="随机数量">
|
||||||
|
<input type="number" min={1} max={64} value={ipv4AssignCount} onChange={(e) => setIPv4AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{ipv4AssignMode === 'custom' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{allocatableIPv4s.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500">没有可选择的公网 IPv4,请先到路由管理配置 IPv4 池。</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{allocatableIPv4s.map((ip) => (
|
||||||
|
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-xs text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ipv4Selected.includes(ip.address)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.checked
|
||||||
|
? Array.from(new Set([...ipv4Selected, ip.address]))
|
||||||
|
: ipv4Selected.filter((value) => value !== ip.address)
|
||||||
|
setIPv4Selected(next)
|
||||||
|
setIPv4AssignCount(Math.max(1, next.length || 1))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-mono">{ip.address}</span>
|
||||||
|
<span className="shrink-0 text-gray-400">{ip.interface}</span>
|
||||||
|
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">独立 IPv6</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">自定义地址必须落在路由管理配置的 IPv6 前缀内。</p>
|
||||||
|
</div>
|
||||||
|
<Segmented value={ipv6AssignMode} onChange={setIPv6AssignMode} />
|
||||||
|
{ipv6AssignMode === 'random' && (
|
||||||
|
<Field label="随机数量">
|
||||||
|
<input type="number" min={1} max={64} value={ipv6AssignCount} onChange={(e) => setIPv6AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{ipv6AssignMode === 'custom' && (
|
||||||
|
<Field label="IPv6 地址">
|
||||||
|
<textarea
|
||||||
|
value={ipv6DraftText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIPv6DraftText(e.target.value)
|
||||||
|
setIPv6AssignCount(Math.max(1, splitAddressLines(e.target.value).length || 1))
|
||||||
|
}}
|
||||||
|
className={`${inputClass} min-h-32 font-mono text-xs`}
|
||||||
|
placeholder="2001:db8:100::100 2001:db8:100::101"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-2 border-t border-gray-200 pt-4">
|
||||||
|
<button onClick={() => setShowIPAssign(false)} disabled={savingIPAssign} className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button onClick={submitIPAssign} disabled={savingIPAssign} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{savingIPAssign ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
{showNat && !hasIndependentIPv4 && (
|
{showNat && !hasIndependentIPv4 && (
|
||||||
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||||
!isSubUser && canAddMapping && (
|
!isSubUser && canAddMapping && (
|
||||||
@@ -2432,6 +2660,28 @@ function Field({ label, children, hint }: { label: string; children: ReactNode;
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, onChange }: { value: IPAssignMode; onChange: (value: IPAssignMode) => void }) {
|
||||||
|
const items: Array<{ value: IPAssignMode; label: string }> = [
|
||||||
|
{ value: 'clear', label: '不分配' },
|
||||||
|
{ value: 'random', label: '随机分配' },
|
||||||
|
{ value: 'custom', label: '自定义' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-1 rounded-md bg-gray-100 p-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(item.value)}
|
||||||
|
className={`rounded px-2 py-1.5 text-xs font-medium ${value === item.value ? 'bg-white text-black shadow-sm' : 'text-gray-600 hover:text-black'}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
@@ -2469,6 +2719,45 @@ function readHistory(containerName: string): MetricPoint[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoint {
|
||||||
|
return {
|
||||||
|
ts: point.ts,
|
||||||
|
cpu: clamp(point.cpu),
|
||||||
|
memory: clamp(point.memory),
|
||||||
|
network: point.network || 0,
|
||||||
|
networkRx: point.network_rx || 0,
|
||||||
|
networkTx: point.network_tx || 0,
|
||||||
|
diskIO: point.disk_io || 0,
|
||||||
|
diskRead: point.disk_read || 0,
|
||||||
|
diskWrite: point.disk_write || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitAddressLines(value: string) {
|
||||||
|
return value
|
||||||
|
.split(/[\n,,\s]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIPv4Choices(candidates: PublicIPv4Info[], assigned: { address: string; interface?: string; prefix_len?: number; gateway?: string }[]) {
|
||||||
|
const byAddress = new Map<string, PublicIPv4Info>()
|
||||||
|
for (const item of candidates) {
|
||||||
|
if (item.address) byAddress.set(item.address, item)
|
||||||
|
}
|
||||||
|
for (const item of assigned) {
|
||||||
|
if (!item.address || byAddress.has(item.address)) continue
|
||||||
|
byAddress.set(item.address, {
|
||||||
|
address: item.address,
|
||||||
|
interface: item.interface || '',
|
||||||
|
prefix: item.prefix_len ? `${item.address}/${item.prefix_len}` : item.address,
|
||||||
|
prefix_len: item.prefix_len,
|
||||||
|
gateway: item.gateway,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Array.from(byAddress.values()).sort((a, b) => a.address.localeCompare(b.address, undefined, { numeric: true }))
|
||||||
|
}
|
||||||
|
|
||||||
function historyKey(containerName: string) {
|
function historyKey(containerName: string) {
|
||||||
return `clicd_container_metric_history:${containerName}`
|
return `clicd_container_metric_history:${containerName}`
|
||||||
}
|
}
|
||||||
@@ -2514,8 +2803,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 {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import CreateContainerModal from '../components/CreateContainerModal'
|
import CreateContainerModal from '../components/CreateContainerModal'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
CreateContainerRequest,
|
CreateContainerRequest,
|
||||||
@@ -391,14 +392,12 @@ export default function Containers() {
|
|||||||
{pageContainers.map((container) => {
|
{pageContainers.map((container) => {
|
||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
const isInitializing = container.status === 'initializing'
|
const isInitializing = container.status === 'initializing'
|
||||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
const task = (container.id > 0 ? taskStatusMap[container.id] : undefined) || taskNameMap[container.name] || container.createTask
|
||||||
const isPlaceholder = !!container.isPlaceholder
|
const 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
|
||||||
@@ -583,12 +582,13 @@ type DisplayContainer = Container & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
|
||||||
if (policyBlocked) {
|
if (policyBlocked) {
|
||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||||
策略封禁
|
{t('策略封禁')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -597,7 +597,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
<span className={`${baseClass} bg-red-50 text-red-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||||
初始化失败
|
{t('初始化失败')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -606,16 +606,17 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
|
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
|
||||||
初始化完成
|
{t('初始化完成')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task?.type === 'create' && task.status === 'running') {
|
if (task?.type === 'create' && task.status === 'running') {
|
||||||
|
const detail = t(task.stage_detail || '正在初始化')
|
||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
<span className={`${baseClass} max-w-[210px] bg-amber-50 text-amber-700`} title={`${t('正在初始化')}: ${detail}`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-500 animate-pulse"></span>
|
||||||
正在初始化
|
<span className="truncate">{detail}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -624,7 +625,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
|
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||||
排队等待
|
{t('排队等待')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -636,7 +637,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||||
{taskLabels[task.type] || '处理中'}
|
{t(taskLabels[task.type] || '处理中')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -645,7 +646,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
|
||||||
正在初始化
|
{t('正在初始化')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -653,7 +654,7 @@ function StatusBadge({ running, initializing, task, placeholder, policyBlocked }
|
|||||||
return (
|
return (
|
||||||
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
|
||||||
{running ? '在线' : '离线'}
|
{t(running ? '在线' : '离线')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -790,7 +791,7 @@ type ContainerFilters = {
|
|||||||
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
||||||
const keyword = filters.search.trim().toLowerCase()
|
const keyword = filters.search.trim().toLowerCase()
|
||||||
return containers.filter((container) => {
|
return containers.filter((container) => {
|
||||||
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
|
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : undefined) || filters.taskNameMap[container.name] || container.createTask
|
||||||
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -867,6 +868,7 @@ function getContainerStatusFilterValue(container: DisplayContainer, task?: Task)
|
|||||||
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
||||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||||
|
if (task.type === 'create' && task.status === 'running') return task.stage_detail || '正在初始化'
|
||||||
return actionLabels[task.type] || '处理中...'
|
return actionLabels[task.type] || '处理中...'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,13 +877,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
|||||||
onRefresh: () => void | Promise<void>
|
onRefresh: () => void | Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useLanguage()
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
<div className="flex max-h-[86vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-black">任务队列</h2>
|
<h2 className="text-base font-semibold text-black">{t('任务队列')}</h2>
|
||||||
<p className="mt-0.5 text-xs text-gray-500">共 {tasks.length} 个任务</p>
|
<p className="mt-0.5 text-xs text-gray-500">{t(`共 ${tasks.length} 个任务`)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -889,26 +892,27 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
|||||||
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
刷新
|
{t('刷新')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
|
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title={t('关闭')}>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className="p-8 text-center text-sm text-gray-500">暂无任务</div>
|
<div className="p-8 text-center text-sm text-gray-500">{t('暂无任务')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
|
||||||
<th className="whitespace-nowrap px-4 py-2.5">状态</th>
|
<th className="whitespace-nowrap px-4 py-2.5">{t('状态')}</th>
|
||||||
<th className="whitespace-nowrap px-4 py-2.5">操作</th>
|
<th className="whitespace-nowrap px-4 py-2.5">{t('操作')}</th>
|
||||||
<th className="whitespace-nowrap px-4 py-2.5">容器</th>
|
<th className="whitespace-nowrap px-4 py-2.5">{t('容器')}</th>
|
||||||
<th className="whitespace-nowrap px-4 py-2.5">创建时间</th>
|
<th className="whitespace-nowrap px-4 py-2.5">{t('当前阶段')}</th>
|
||||||
<th className="px-4 py-2.5">错误</th>
|
<th className="whitespace-nowrap px-4 py-2.5">{t('创建时间')}</th>
|
||||||
|
<th className="px-4 py-2.5">{t('错误')}</th>
|
||||||
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
|
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -917,11 +921,14 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
|||||||
<tr key={task.id} className="hover:bg-gray-50">
|
<tr key={task.id} className="hover:bg-gray-50">
|
||||||
<td className="whitespace-nowrap px-4 py-2.5">
|
<td className="whitespace-nowrap px-4 py-2.5">
|
||||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
|
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
|
||||||
{taskStatusLabel(task.status)}
|
{t(taskStatusLabel(task.status))}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
|
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{t(actionLabel(task.type))}</td>
|
||||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
|
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
|
||||||
|
<td className="min-w-[210px] px-4 py-2.5 text-xs text-gray-700">
|
||||||
|
{task.type === 'create' ? t(task.stage_detail || (task.status === 'pending' ? '排队等待' : '-')) : '-'}
|
||||||
|
</td>
|
||||||
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
|
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
|
||||||
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
|
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
|
||||||
<td className="whitespace-nowrap px-2 py-2.5">
|
<td className="whitespace-nowrap px-2 py-2.5">
|
||||||
@@ -934,7 +941,7 @@ function TaskQueueModal({ tasks, onRefresh, onClose }: {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}}
|
}}
|
||||||
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
|
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
|
||||||
title="取消任务"
|
title={t('取消任务')}
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -969,6 +976,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,9 +986,12 @@ 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',
|
||||||
|
'kvm-windows-11': 'Windows 11',
|
||||||
'kvm-windows-10': 'Windows 10',
|
'kvm-windows-10': 'Windows 10',
|
||||||
}
|
}
|
||||||
return map[id] || id
|
return map[id] || id
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { Cpu, HardDrive, MemoryStick, Network, Server } from 'lucide-react'
|
import { Cpu, HardDrive, MemoryStick, Network, Server } from 'lucide-react'
|
||||||
import RingStats from '../components/RingStats'
|
import RingStats from '../components/RingStats'
|
||||||
import ResourceStatsPanel, {
|
import ResourceStatsPanel, {
|
||||||
@@ -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)}`,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -138,7 +171,7 @@ function SummaryCard({
|
|||||||
value,
|
value,
|
||||||
muted = false,
|
muted = false,
|
||||||
}: {
|
}: {
|
||||||
icon?: JSX.Element
|
icon?: ReactNode
|
||||||
dot?: string
|
dot?: string
|
||||||
title: string
|
title: string
|
||||||
value: number
|
value: number
|
||||||
@@ -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 || '-'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user