Compare commits

...

38 Commits

Author SHA1 Message Date
MengMengCode c303fe6d17 release: v1.1.15 2026-06-10 14:52:30 +08:00
MengMengCode eedb2d7fb0 修复了一些已知问题 2026-06-10 14:51:22 +08:00
MengMengCode bfc98d043b feat(security): 快照并持久化告警时的连接跟踪数据 2026-06-10 13:28:46 +08:00
MengMengCode 0a4cf5c0bd release: v1.1.14 2026-06-10 11:12:43 +08:00
MengMengCode 54a608c19d Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 11:12:32 +08:00
MengMengCode 73bd6934f9 修复预设密码以及KEY问题 2026-06-10 11:12:27 +08:00
Meng Meng a35595edcf Update README.md 2026-06-10 11:04:58 +08:00
MengMengCode b605df613e Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-10 10:35:13 +08:00
MengMengCode 3cc8f7df7f 修复了一些已知问题 2026-06-10 10:35:08 +08:00
Meng Meng 744246c0a8 Update thanks section formatting in README.md 2026-06-10 09:33:31 +08:00
MengMengCode aed512cb09 patch 2026-06-10 02:39:58 +08:00
MengMengCode b6f48acc4c Refactor code structure for improved readability and maintainability 2026-06-10 02:33:18 +08:00
MengMengCode d83a5e3473 release: v1.1.13 2026-06-10 01:27:21 +08:00
MengMengCode 18c9d75a05 Merge pull request #5 from StarVM-OpenSource/main
Add Mofang finance module sources without packaged zip.
2026-06-10 01:22:45 +08:00
MengMengCode b80e4817ef ci: package mofang module artifacts 2026-06-10 01:21:57 +08:00
MengMengCode 0b052f2217 优化UI 2026-06-10 01:14:51 +08:00
80386d35ba 已测试LXC功能基本正常 KVM功能暂未进行测试 2026-06-10 01:01:39 +08:00
MengMengCode a45e063fc2 Add comprehensive documentation for CLICD features and operations
- Introduced Container Management documentation covering lifecycle operations, resource management, and console access.
- Added Dashboard documentation detailing metrics and related APIs.
- Created Host Report documentation summarizing host environment and resource status.
- Included Image Management documentation for template handling and management actions.
- Documented Networking and Routing features including NAT4 and IPv6 management.
- Added Security Alerts documentation outlining alert scenarios and API usage.
- Created Snapshot Management documentation for snapshot operations and scheduling.
- Documented Sub-user management for granting access to specific containers.
- Added Configuration guide detailing runtime settings and security recommendations.
- Created Installation guide for setting up CLICD with requirements and steps.
- Added Introduction and Quick Start guides for new users.
- Documented Upgrade process with version checking and pre-upgrade checklist.
- Created Deployment guide for service exposure and firewall recommendations.
- Added FAQ section addressing common questions and concerns.
- Documented Troubleshooting steps for common issues encountered.
2026-06-10 00:42:06 +08:00
MengMengCode e71adf6830 修复了一些已知问题 2026-06-09 23:34:09 +08:00
MengMengCode bb0c4f999d release: v1.1.12 2026-06-09 23:25:29 +08:00
MengMengCode 0c9f420474 添加新建虚拟机/重装系统 预设SSH密码以及KEY Auth功能 2026-06-09 23:25:19 +08:00
MengMengCode 9a826add87 release: v1.1.11 2026-06-09 22:48:20 +08:00
MengMengCode 63611dc932 Support webSSH Origin Allowlist 2026-06-09 22:48:09 +08:00
MengMengCode e6551bf4ae release: v1.1.10 2026-06-09 22:25:42 +08:00
MengMengCode 5bf2b6534a Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-09 22:22:42 +08:00
MengMengCode 917afc3157 支持公网IPV4分配,单独IPV6分配,以及混合网络分配。 2026-06-09 22:22:40 +08:00
Meng Meng c8081edbac Update README.md 2026-06-09 16:50:19 +08:00
Meng Meng 95eb00a31d Update Buy Me a Coffee username in FUNDING.yml 2026-06-09 15:56:08 +08:00
MengMengCode f4edf94800 release: v1.1.9 2026-06-09 15:34:06 +08:00
MengMengCode 82b42e7961 Merge branch 'main' of https://github.com/MengMengCode/CLICD 2026-06-09 15:33:19 +08:00
MengMengCode e971d99070 Refactor code structure for improved readability and maintainability 2026-06-09 15:33:17 +08:00
Meng Meng 8dd09ff009 Revise badges and add support indicators in README
Updated badge descriptions and added new badges for supported features.
2026-06-09 15:33:13 +08:00
Meng Meng 7f4755788a Enhance README with features and tech stack
Added detailed features and technology stack information to README.
2026-06-09 15:26:55 +08:00
Meng Meng c24df1d42f Add Let's Encrypt badge to README 2026-06-09 15:16:40 +08:00
MengMengCode cf00d0d03d 加入了certbot检测 2026-06-09 13:49:09 +08:00
MengMengCode 79be2d5cbd 修复了一些已知问题 2026-06-09 13:35:08 +08:00
MengMengCode e66327db29 release: v1.1.8 2026-06-09 13:14:08 +08:00
MengMengCode 2b4fe4f5bc 修复webvnc 2026-06-09 13:13:51 +08:00
92 changed files with 13497 additions and 1524 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: mengmengcode
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+37 -13
View File
@@ -6,7 +6,7 @@ on:
- main
- master
tags:
- "v*"
- 'v*'
pull_request:
workflow_dispatch:
@@ -25,31 +25,39 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
cache-dependency-path: |
frontend/package-lock.json
docs/package-lock.json
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: '1.24.5'
cache-dependency-path: backend/go.sum
- name: Install frontend dependencies
run: npm ci --prefix frontend
- name: Install docs dependencies
run: npm ci --prefix docs
- name: Build docs
run: npm run build --prefix docs
- name: Set version from tag
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
echo "CLICD_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
else
echo "CLICD_VERSION=dev" >> $GITHUB_ENV
echo "CLICD_VERSION=dev" >> "$GITHUB_ENV"
fi
- name: Build
shell: bash
- name: Build CLICD
run: bash build.sh
- name: Package
shell: bash
- name: Package CLICD
run: |
mkdir -p dist package/clicd-linux-amd64
cp build/clicd package/clicd-linux-amd64/clicd
@@ -57,7 +65,24 @@ jobs:
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
cp build/clicd dist/clicd-linux-amd64
sha256sum dist/* > dist/SHA256SUMS
- name: Package Mofang module
run: |
if [ ! -f Mofang/clicd.php ]; then
echo "Mofang module not present; skipping package."
exit 0
fi
if ! command -v zip >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y zip
fi
cd Mofang
zip -r ../dist/clicd-mofang.zip clicd.php handlers templates -x '*.DS_Store' -x '*/.DS_Store'
- name: Generate checksums
run: sha256sum dist/* > dist/SHA256SUMS
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -69,7 +94,6 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
+1
View File
@@ -13,6 +13,7 @@ backend/internal/server/web/*
# Build artifacts
/build/
Mofang/*.zip
*.exe
*.dll
*.so
+375
View File
@@ -0,0 +1,375 @@
# CLICD 魔方财务对接模块
这是用于智简魔方 / IDCSMART 的 CLICD 服务器模块。模块通过 CLICD API 完成实例开通、删除、开关机、重启、重装、改密、资源变更、流量重置、NAT 端口映射管理、实例信息展示和 WebSSH 入口。
## 文件结构
```text
clicd.php
README.md
handlers/
webssh.php
templates/
info.html
nat.html
```
安装时请保持目录结构不变,将整个 `clicd` 目录放入魔方服务器模块目录:
```text
public/plugins/servers/clicd/
```
## 服务器配置
在魔方后台添加服务器时,模块名称选择 `clicd`
CLICD 面板地址建议使用 HTTPS
```text
主机名 = https://0.0.0.0:8999
```
也可以拆分填写:
```text
IP地址 = 0.0.0.0
端口 = 8999
secure = 开启
```
API Key 可以填写在以下任意一个字段中:
```text
Hash
密码
```
模块请求 CLICD 时会同时携带:
```text
X-API-Key: clicd_sk_xxxx
Authorization: Bearer clicd_sk_xxxx
Content-Type: application/json
```
## 产品配置项
| 字段 | 说明 |
| --- | --- |
| `virtualization` | 虚拟化类型,`lxc``kvm` |
| `template_id` | CLICD 模板 / 镜像 ID |
| `vcpu` | CPU 核心数 |
| `cpu_percent` | CPU 使用率限制,`0` 表示不额外限制 |
| `ram_mb` | 内存,单位 MB |
| `disk_gb` | 系统盘,单位 GB |
| `network_bw_mbps` | 带宽,单位 Mbps |
| `traffic_mode` | `total` 总流量,或 `in_out` 入 / 出分开 |
| `monthly_traffic_gb` | 月流量 GB |
| `traffic_in_gb` | 入站流量 GB`in_out` 模式使用 |
| `traffic_out_gb` | 出站流量 GB`in_out` 模式使用 |
| `io_speed_mbps` | 磁盘 IO 限制,`0` 表示不限制 |
| `port_mapping_count` | 开通时分配的 NAT 端口数量,最小 2 |
| `snapshot_limit` | 快照配额 |
| `extra_ports` | 额外映射的容器端口,逗号分隔,例如 `80,443` |
| `assign_ipv6` | 开通时是否自动分配 IPv6 |
| `sync_expiry` | 是否同步魔方到期时间到 CLICD |
客户产品的 `domain` 会作为 CLICD 容器名称。模块会自动把不适合作为容器名的字符替换为 `-`
## 开通后字段同步
开通、同步、重装、改密后,模块会从 CLICD 容器详情拉取最新信息并写回魔方主机表:
| 魔方字段 | 写入内容 |
| --- | --- |
| `dedicatedip` | NAT 外网 IP,优先使用 API 返回的公网字段,否则使用服务器 IP |
| `username` | 固定写入 `root` |
| `password` | CLICD 返回的 SSH 密码,兼容魔方 `cmf_encrypt()` |
| `port` | CLICD 返回的 `ssh_port` |
| `domainstatus` | CLICD 状态为 `running` 时写 `Active`,否则写 `Suspended` |
如果接口返回的密码是 `***` 这类脱敏值,模块不会覆盖魔方里已有密码。
## 客户区页面
模块提供两个客户区选项卡:
```text
实例信息
NAT转发
```
客户区按钮提供:
```text
WebSSH
```
## 实例信息
实例信息页展示:
- 实例名称、运行状态、SSH 地址、IPv6
- CPU、内存、负载、磁盘圆环状态
- 月流量进度
- CPU 使用率、内存使用、网络流量、磁盘 IO 图表
- IPv4、SSH 端口、SSH 密码、资源配置、到期时间
图表数据通过客户区懒加载接口获取,不会强制刷新整个魔方页面。页面首次打开会加载一次数据,之后由用户选择是否自动刷新:
```text
不刷新
10 秒
1 分钟
5 分钟
10 分钟
```
也可以点击“立即刷新”手动刷新一次。当前 CLICD 用量接口返回的是实时值,不是历史数组;图表曲线由客户区前端持续采样生成。若需要打开页面立即显示历史曲线,需要 CLICD 额外提供历史指标接口。
流量显示支持智能单位,小流量会显示 B / KB / MB,大流量显示 GB,例如:
```text
370.5 KB / 100 GB
```
模块会优先调用:
```text
GET /api/v1/containers/{name}/usage
GET /api/v1/containers/{name}/traffic
```
如果 `/api/v1/containers/{name}/usage` 不可用,模块会在容器详情存在 `uuid` 时尝试兼容:
```text
GET /api/containers/{uuid}/usage
```
已兼容的常见用量字段包括:
```text
cpu_usage_pct
memory_usage_bytes
disk_usage_bytes
network_rx_bps
network_tx_bps
disk_read_bps
disk_write_bps
rx_used_bytes
tx_used_bytes
total_used_bytes
limit_gb
used_pct
```
## NAT 转发
NAT 转发是独立页面,支持:
- 查看端口映射
- 获取随机可用端口
- 添加端口映射
- 修改端口映射
- 删除端口映射
删除端口映射时使用页面内确认弹窗,不使用浏览器自带确认框。
使用的 CLICD API
```text
GET /api/v1/containers/{id|uuid|name}
GET /api/v1/containers/{id}/random-port
POST /api/v1/containers/{id}/port-mappings
PUT /api/v1/containers/{id}/port-mappings/{index}
DELETE /api/v1/containers/{id}/port-mappings/{index}
```
添加 / 修改 NAT 映射时必须使用 JSON 请求体,例如:
```json
{
"container_port": 8080,
"host_port": 61320,
"protocol": "tcp",
"description": "HTTP"
}
```
## WebSSH
WebSSH 按钮会调用:
```text
POST /api/v1/ssh-ticket
```
请求体:
```json
{
"container_name": "example-vm"
}
```
接口返回 60 秒有效票据后,模块会打开本地 handler:
```text
/plugins/servers/clicd/handlers/webssh.php
```
浏览器会从该页面直连 CLICD
```text
wss://0.0.0.0:8999/api/ssh?container=example-vm
Sec-WebSocket-Protocol: clicd-ticket.xxxxx
```
注意:WebSSH 受浏览器安全策略和 CLICD 后端 Origin 校验影响。魔方客户区通常是 HTTPS,因此 CLICD 面板也必须启用 HTTPS/WSS。请把魔方服务器配置里的 `主机名` 改为 `https://0.0.0.0:8999`,或把 `secure` 设为 `开启`
新版 CLICD 已支持 WebSSH Origin 放行。部署时需要在 CLICD 后端把魔方财务客户区域名加入 WebSSH Origin 白名单,例如:
```text
https://www.example.com
```
如果 WebSSH 页面显示 `WebSocket error``Disconnected code=1006`,但直接以 CLICD 自身 Origin 测试能返回 `101 Switching Protocols`,通常说明 CLICD 后端未放行魔方客户区域名的 WebSocket Origin。此时请检查 CLICD 的 WebSSH Origin 白名单配置;前端页面无法伪造浏览器 Origin。
## 支持的魔方操作
| 魔方操作 | CLICD API |
| --- | --- |
| 连接测试 | `GET /api/v1/dashboard` |
| 开通 | `POST /api/v1/containers` |
| 删除 | `DELETE /api/v1/containers/{name}/delete` |
| 开机 | `POST /api/v1/containers/{name}/start` |
| 关机 | `POST /api/v1/containers/{name}/stop` |
| 重启 | `POST /api/v1/containers/{name}/restart` |
| 重装 | `POST /api/v1/containers/{name}/reinstall` |
| 改密 | `POST /api/v1/containers/{name}/reset-password` |
| 重置流量 | `POST /api/v1/containers/{name}/traffic-reset` |
| 变更资源 | `PUT /api/v1/containers/{name}/resource-limit` |
| 变更流量 | `PUT /api/v1/containers/{name}/traffic-limit` |
| 同步到期 | `PUT /api/v1/containers/{name}/expiry` |
| WebSSH | `POST /api/v1/ssh-ticket` |
## 建议 API 权限
API Key 至少需要以下权限,具体名称以 CLICD 后端实际权限系统为准:
```text
dashboard:read
container:read
container:create
container:power
container:delete
container:reinstall
container:password
container:traffic
container:resize
container:port
task:read
ssh-ticket:create
```
如果 API Key 使用 `*``admin:*`,通常可以覆盖上述权限。
## 建议先测试的 curl
连接测试:
```bash
curl -H "X-API-Key: clicd_sk_xxxx" \
https://0.0.0.0:8999/api/v1/dashboard
```
容器详情:
```bash
curl -H "X-API-Key: clicd_sk_xxxx" \
https://0.0.0.0:8999/api/v1/containers/example-vm
```
资源用量:
```bash
curl -H "X-API-Key: clicd_sk_xxxx" \
https://0.0.0.0:8999/api/v1/containers/example-vm/usage
```
流量统计:
```bash
curl -H "X-API-Key: clicd_sk_xxxx" \
https://0.0.0.0:8999/api/v1/containers/example-vm/traffic
```
修改 NAT
```bash
curl --location --request PUT \
"https://0.0.0.0:8999/api/v1/containers/10/port-mappings/1" \
--header "X-API-Key: clicd_sk_xxxx" \
--header "Authorization: Bearer clicd_sk_xxxx" \
--header "Content-Type: application/json" \
--data-raw '{"container_port":8081,"host_port":61320,"protocol":"tcp","description":"HTTP"}'
```
创建 WebSSH 票据:
```bash
curl --location --request POST \
"https://0.0.0.0:8999/api/v1/ssh-ticket" \
--header "X-API-Key: clicd_sk_xxxx" \
--header "Content-Type: application/json" \
--data-raw '{"container_name":"example-vm"}'
```
## 常见问题
### NAT 修改不生效
确认请求体必须是 JSON,不要使用 `multipart/form-data`。正确请求头:
```text
Content-Type: application/json
```
### 图表刚打开只有一条横线
CLICD 当前用量接口返回的是实时值,不是历史序列。页面刚打开时只有一个采样点,所以会显示当前值横线。选择 `10 秒` 自动刷新或点击“立即刷新”多采样几次后,会逐步形成折线。
### 流量显示为 0
旧版本只显示 GB,小流量换算后会被四舍五入成 `0 GB`。当前版本已改为智能单位,会显示 B / KB / MB / GB。
### WebSSH 打不开或提示不安全 WebSocket
请确认 CLICD 面板已经启用 HTTPS/WSS,并且魔方服务器配置使用 HTTPS:
```text
server_host = https://0.0.0.0:8999
```
如果仍然使用 `http://`,模块会生成 `ws://` 地址,HTTPS 客户区页面会被浏览器拦截。
如果 WSS 证书正常但仍返回 `Forbidden` 或浏览器显示 `code=1006`,请检查 CLICD 的 WebSSH Origin 白名单。新版 CLICD 已支持放行魔方财务域名,需要把魔方客户区访问域名完整加入白名单,例如:
```text
https://www.example.com
```
注意需要填写浏览器实际访问魔方客户区时的协议和域名,`http` / `https`、带不带 `www` 都要与实际访问地址一致。
### 开通后魔方里的 IP、端口、密码不对
执行“同步状态”或重装 / 改密后,模块会重新拉取容器详情。请确认 CLICD 容器详情接口能返回:
```text
ssh_port
ssh_password
status
```
公网 IP 优先使用 `nat_public_ip/public_ip/host_ip/external_ip/node_ip/nat_host` 等字段;如果接口没有返回,则使用魔方服务器配置的 IP。
+1368
View File
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
<?php
$ws = isset($_GET['ws']) ? (string)$_GET['ws'] : (isset($_GET['amp;ws']) ? (string)$_GET['amp;ws'] : '');
$protocol = isset($_GET['protocol']) ? (string)$_GET['protocol'] : (isset($_GET['amp;protocol']) ? (string)$_GET['amp;protocol'] : '');
$container = isset($_GET['container']) ? (string)$_GET['container'] : (isset($_GET['amp;container']) ? (string)$_GET['amp;container'] : '');
if ($ws === '' || $protocol === '') {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo "Missing WebSSH parameters\n";
echo "Received query: " . ($_SERVER['QUERY_STRING'] ?? '') . "\n";
exit;
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebSSH</title>
<style>
html,body{height:100%;margin:0;background:#0b1020;color:#e5e7eb;font-family:Consolas,Menlo,monospace}
body{cursor:text}
.bar{height:44px;display:flex;align-items:center;gap:12px;padding:0 14px;background:#111827;border-bottom:1px solid #243047}
.dot{width:9px;height:9px;border-radius:50%;background:#f59e0b}
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}
.title{font-size:14px;color:#cbd5e1;flex:1}
.tools{display:flex;align-items:center;gap:8px;font-size:12px;color:#94a3b8;flex-wrap:wrap;justify-content:flex-end}
.tools select{height:26px;background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:4px}
.tools button{height:26px;border:1px solid #334155;background:#0f172a;color:#cbd5e1;border-radius:4px;padding:0 8px;cursor:pointer}
#keyhint{min-width:44px;text-align:right}
#iostat{min-width:120px;text-align:right}
#term{height:calc(100% - 89px);box-sizing:border-box;padding:14px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.45;outline:none}
.inputbar{height:44px;display:flex;align-items:center;gap:8px;padding:6px 10px;box-sizing:border-box;background:#111827;border-top:1px solid #243047}
#cmd{flex:1;height:30px;background:#020617;color:#e5e7eb;border:1px solid #334155;border-radius:4px;padding:0 8px;font:14px Consolas,Menlo,monospace;outline:none}
#sendcmd{height:30px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
.hint{color:#94a3b8}
.meta{color:#94a3b8}
</style>
</head>
<body>
<div class="bar">
<span id="state" class="dot"></span>
<span class="title">WebSSH <?php echo htmlspecialchars($container, ENT_QUOTES, 'UTF-8'); ?></span>
<span class="tools">
<span>发送模式</span>
<select id="send-mode">
<option value="raw" selected>raw</option>
<option value="binary">binary</option>
<option value="json-input">json input</option>
<option value="json-data">json data</option>
<option value="json-stdin">json stdin</option>
</select>
<button id="send-enter" type="button">回车</button>
<span id="iostat">S0 R0</span>
<span id="keyhint"></span>
</span>
</div>
<div id="term" tabindex="0"><span class="hint">正在连接...</span></div>
<div class="inputbar">
<input id="cmd" type="text" autocomplete="off" spellcheck="false" placeholder="在这里输入命令,例如 ls -la">
<button id="sendcmd" type="button">发送</button>
</div>
<script>
(function(){
var wsUrl = <?php echo json_encode($ws, JSON_UNESCAPED_SLASHES); ?>;
var protocol = <?php echo json_encode($protocol, JSON_UNESCAPED_SLASHES); ?>;
var term = document.getElementById('term');
var state = document.getElementById('state');
var modeSelect = document.getElementById('send-mode');
var keyhint = document.getElementById('keyhint');
var iostat = document.getElementById('iostat');
var sendEnter = document.getElementById('send-enter');
var cmd = document.getElementById('cmd');
var sendcmd = document.getElementById('sendcmd');
var socket;
var hintTimer;
var sentCount = 0;
var recvCount = 0;
var decoder = window.TextDecoder ? new TextDecoder('utf-8') : null;
var termLines = [''];
var cursorRow = 0;
var cursorCol = 0;
var maxLines = 2000;
function append(text) {
writeTerminal(stripTerminalControls(String(text || '')));
renderTerminal();
}
function clearTerminal() {
termLines = [''];
cursorRow = 0;
cursorCol = 0;
renderTerminal();
}
function stripTerminalControls(text) {
return text
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
.replace(/\x1b\[(?:2J|H)/g, '\f')
.replace(/\x1b\[[0-?]*[ -/]*K/g, '\v')
.replace(/\ufffd\[[0-?]*[ -/]*K/g, '\v')
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\ufffd\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\x1b[()][A-Za-z0-9]/g, '')
.replace(/\x1b[@-Z\\-_]/g, '')
.replace(/[\x00-\x08\x0e-\x1f\x7f]/g, '');
}
function ensureLine() {
while (cursorRow >= termLines.length) {
termLines.push('');
}
}
function trimTerminal() {
if (termLines.length <= maxLines) {
return;
}
var overflow = termLines.length - maxLines;
termLines.splice(0, overflow);
cursorRow = Math.max(0, cursorRow - overflow);
}
function writeTerminal(text) {
for (var i = 0; i < text.length; i++) {
var ch = text.charAt(i);
if (ch === '\f') {
termLines = [''];
cursorRow = 0;
cursorCol = 0;
continue;
}
if (ch === '\v') {
ensureLine();
termLines[cursorRow] = termLines[cursorRow].slice(0, cursorCol);
continue;
}
if (ch === '\r') {
cursorCol = 0;
continue;
}
if (ch === '\n') {
cursorRow++;
cursorCol = 0;
ensureLine();
trimTerminal();
continue;
}
if (ch === '\b') {
cursorCol = Math.max(0, cursorCol - 1);
continue;
}
if (ch === '\t') {
var spaces = 4 - (cursorCol % 4);
for (var s = 0; s < spaces; s++) {
writePrintable(' ');
}
continue;
}
writePrintable(ch);
}
trimTerminal();
}
function writePrintable(ch) {
ensureLine();
var line = termLines[cursorRow];
if (cursorCol > line.length) {
line += new Array(cursorCol - line.length + 1).join(' ');
}
termLines[cursorRow] = line.slice(0, cursorCol) + ch + line.slice(cursorCol + 1);
cursorCol++;
}
function renderTerminal() {
term.textContent = termLines.join('\n');
term.scrollTop = term.scrollHeight;
}
function setState(cls, text) {
state.className = 'dot ' + cls;
append(text);
}
function updateIoStatus() {
if (!iostat) return;
var stateText = socket ? ['CONNECTING','OPEN','CLOSING','CLOSED'][socket.readyState] : '-';
iostat.textContent = 'S' + sentCount + ' R' + recvCount + ' ' + stateText;
}
try {
socket = new WebSocket(wsUrl, protocol);
socket.binaryType = 'arraybuffer';
} catch (e) {
setState('err', '\nWebSocket 创建失败:' + e.message + '\n');
return;
}
socket.onopen = function(){
clearTerminal();
setState('ok', '已连接。\r\n');
updateIoStatus();
if (cmd) cmd.focus();
};
socket.onmessage = function(event){
recvCount++;
updateIoStatus();
if (typeof event.data === 'string') {
handleIncomingText(event.data);
return;
}
if (event.data instanceof ArrayBuffer) {
handleIncomingText(decodeIncoming(event.data));
return;
}
if (window.Blob && event.data instanceof Blob) {
event.data.arrayBuffer().then(function(buffer){
handleIncomingText(decodeIncoming(buffer));
}).catch(function(){
append('\n[WebSSH] 无法解码服务端返回内容。\n');
});
}
};
socket.onerror = function(){
setState('err', '\nWebSocket 连接错误,请检查 HTTPS 证书、WSS 服务、Origin 策略和票据有效期。\n');
};
socket.onclose = function(event){
updateIoStatus();
setState('err', '\n连接已断开。code=' + event.code + ' reason=' + (event.reason || '-') + ' clean=' + event.wasClean + '\n');
};
function flashKey(text) {
if (!keyhint) return;
keyhint.textContent = text;
window.clearTimeout(hintTimer);
hintTimer = window.setTimeout(function(){ keyhint.textContent = ''; }, 500);
}
function decodeIncoming(buffer) {
if (decoder) {
return decoder.decode(new Uint8Array(buffer));
}
var bytes = new Uint8Array(buffer);
var text = '';
for (var i = 0; i < bytes.length; i++) {
text += String.fromCharCode(bytes[i]);
}
try {
return decodeURIComponent(escape(text));
} catch (e) {
return text;
}
}
function handleIncomingText(text) {
append(text);
if (text.indexOf('SSH shell ready') !== -1) {
window.setTimeout(function(){ send('\r'); }, 250);
}
}
function wsPayload(data) {
var mode = modeSelect ? modeSelect.value : 'raw';
if (mode === 'raw') {
return data;
}
if (mode === 'binary') {
return new TextEncoder().encode(data);
}
if (mode === 'json-data') {
return JSON.stringify({type:'data', data:data});
}
if (mode === 'json-stdin') {
return JSON.stringify({type:'stdin', data:data});
}
return JSON.stringify({type:'input', data:data});
}
function send(data) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return false;
}
socket.send(wsPayload(data));
sentCount++;
updateIoStatus();
flashKey(data === '\r' ? '回车' : data === '\x7f' ? '退格' : data.length > 1 ? data.length + ' 字符' : data);
return true;
}
function keyToData(e) {
if (e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
var code = e.key.toUpperCase().charCodeAt(0);
if (code >= 64 && code <= 95) {
return String.fromCharCode(code - 64);
}
}
var map = {
Enter: '\r',
Backspace: '\x7f',
Tab: '\t',
Escape: '\x1b',
ArrowUp: '\x1b[A',
ArrowDown: '\x1b[B',
ArrowRight: '\x1b[C',
ArrowLeft: '\x1b[D',
Delete: '\x1b[3~',
Home: '\x1b[H',
End: '\x1b[F',
PageUp: '\x1b[5~',
PageDown: '\x1b[6~'
};
if (map[e.key]) {
return map[e.key];
}
if (!e.ctrlKey && !e.altKey && !e.metaKey && e.key.length === 1) {
return e.key;
}
return null;
}
document.addEventListener('keydown', function(e){
if (e.target === cmd) {
return;
}
var data = keyToData(e);
if (data !== null && send(data)) {
e.preventDefault();
}
});
document.addEventListener('paste', function(e){
if (e.target === cmd) {
return;
}
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
if (text && send(text)) {
e.preventDefault();
}
});
document.addEventListener('mousedown', function(){
if (cmd) cmd.focus();
});
function sendCommandLine() {
if (!cmd) return;
var value = cmd.value;
if (value === '') {
send('\r');
return;
}
if (send(value + '\r')) {
cmd.value = '';
}
}
if (sendcmd) {
sendcmd.addEventListener('click', sendCommandLine);
}
if (sendEnter) {
sendEnter.addEventListener('click', function(){
send('\r');
if (cmd) cmd.focus();
});
}
if (cmd) {
cmd.addEventListener('keydown', function(e){
if (e.key === 'Enter') {
sendCommandLine();
e.preventDefault();
return;
}
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
send('\x03');
e.preventDefault();
}
});
}
window.setInterval(updateIoStatus, 1000);
})();
</script>
</body>
</html>
+378
View File
@@ -0,0 +1,378 @@
<style>
.clicd-info{font-size:14px;color:#1f2937;background:#f6f8fb;padding:14px;border-radius:6px;max-width:100%;overflow:hidden}
.clicd-info *{box-sizing:border-box}
.clicd-head{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin-bottom:12px}
.clicd-mini{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:10px}
.clicd-mini-label{font-size:12px;color:#6b7280;margin-bottom:4px}
.clicd-mini-value{font-size:16px;font-weight:600;color:#111827;word-break:break-all}
.clicd-section{background:#fff;border:1px solid #e5e7eb;border-radius:6px;margin-top:12px;padding:14px}
.clicd-section-title{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:15px;font-weight:700;margin-bottom:12px;color:#111827;min-width:0;flex-wrap:wrap}
.clicd-section-title>span:first-child{min-width:0}
.clicd-refresh{display:flex;align-items:center;justify-content:flex-end;gap:8px;font-size:12px;color:#6b7280;font-weight:400;flex-wrap:wrap;min-width:0;max-width:100%}
.clicd-refresh label{display:inline-flex;align-items:center;gap:4px;min-width:0;white-space:nowrap}
.clicd-refresh-select{height:28px;border:1px solid #d1d5db;border-radius:4px;background:#fff;color:#374151;padding:3px 6px;font-size:12px}
.clicd-refresh-btn{height:28px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:3px 8px;font-size:12px;cursor:pointer;white-space:nowrap;max-width:96px;overflow:hidden;text-overflow:ellipsis}
.clicd-refresh-btn[disabled]{opacity:.6;cursor:not-allowed}
.clicd-gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.clicd-gauge{display:flex;align-items:center;gap:12px;min-height:92px}
.clicd-ring{--p:0%;width:78px;height:78px;border-radius:50%;background:conic-gradient(#2f80ed var(--p),#e5e7eb 0);display:grid;place-items:center;flex:0 0 auto;position:relative}
.clicd-ring:before{content:"";width:66px;height:66px;border-radius:50%;background:#fff;position:absolute}
.clicd-ring span{position:relative;display:inline-flex;align-items:center;justify-content:center;max-width:62px;min-width:0;font-size:17px;font-weight:700;line-height:1;color:#111827;white-space:nowrap;text-align:center;background:#fff;border-radius:3px;padding:0 1px}
.clicd-ring[data-tight="1"] span{font-size:15px}
.clicd-ring[data-tight="2"] span{font-size:14px}
.clicd-gauge-title{font-weight:700;color:#111827;margin-bottom:4px}
.clicd-gauge-sub{font-size:12px;color:#6b7280;line-height:1.45}
.clicd-progress{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden}
.clicd-progress span{display:block;height:100%;width:0;background:linear-gradient(90deg,#2f80ed,#10b981);transition:width .25s ease}
.clicd-traffic-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;margin-top:8px;color:#374151;min-width:0}
.clicd-traffic-row>div{min-width:0;word-break:break-word}
.clicd-charts{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}
.clicd-chart{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff;min-height:190px;min-width:0;overflow:hidden}
.clicd-chart-title{display:flex;justify-content:space-between;gap:8px;align-items:center;font-weight:700;margin-bottom:8px;color:#111827;min-width:0;flex-wrap:wrap}
.clicd-chart-value{font-size:12px;color:#6b7280;font-weight:400;white-space:normal;overflow-wrap:anywhere;text-align:right}
.clicd-chart canvas{width:100%;height:132px;display:block}
.clicd-table{width:100%;border-collapse:collapse;background:#fff}
.clicd-table th,.clicd-table td{border:1px solid #e5e7eb;padding:8px;text-align:left}
.clicd-table th{width:16%;background:#f9fafb;color:#374151;font-weight:600}
.clicd-debug{display:none;margin-top:10px;padding:8px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;border-radius:6px;font-size:12px}
@media (max-width:640px){
.clicd-info{padding:10px}
.clicd-section-title{align-items:flex-start}
.clicd-refresh{justify-content:flex-start;width:100%}
.clicd-charts{grid-template-columns:1fr}
.clicd-table th,.clicd-table td{display:block;width:100%}
.clicd-ring{width:70px;height:70px}
.clicd-ring:before{width:60px;height:60px}
.clicd-ring span{max-width:56px;font-size:15px}
}
</style>
<div class="clicd-info" data-clicd-info-root="1">
<div class="clicd-head">
<div class="clicd-mini">
<div class="clicd-mini-label">&#23454;&#20363;&#21517;&#31216;</div>
<div class="clicd-mini-value">{$container.name|default='-'}</div>
</div>
<div class="clicd-mini">
<div class="clicd-mini-label">&#36816;&#34892;&#29366;&#24577;</div>
<div class="clicd-mini-value">{$status_text|default='-'}</div>
</div>
<div class="clicd-mini">
<div class="clicd-mini-label">SSH &#22320;&#22336;</div>
<div class="clicd-mini-value">{$ssh_host|default='-'}:{$ssh_port|default='-'}</div>
</div>
<div class="clicd-mini">
<div class="clicd-mini-label">IPv6</div>
<div class="clicd-mini-value">{$ipv6|default='-'}</div>
</div>
</div>
<div class="clicd-section">
<div class="clicd-section-title">
<span>&#29366;&#24577;</span>
<span class="clicd-refresh">
<span>&#26356;&#26032;&#20110; <span data-clicd-info="chart_time">-</span></span>
<label>
&#33258;&#21160;&#21047;&#26032;
<select class="clicd-refresh-select" id="clicd-info-refresh">
<option value="0" selected>&#19981;&#21047;&#26032;</option>
<option value="10000">10 &#31186;</option>
<option value="60000">1 &#20998;&#38047;</option>
<option value="300000">5 &#20998;&#38047;</option>
<option value="600000">10 &#20998;&#38047;</option>
</select>
</label>
<button class="clicd-refresh-btn" type="button" id="clicd-info-refresh-now">&#31435;&#21363;&#21047;&#26032;</button>
</span>
</div>
<div class="clicd-gauges">
<div class="clicd-gauge">
<div class="clicd-ring" data-gauge="cpu_percent"><span><span data-clicd-info="cpu_percent">0</span>%</span></div>
<div>
<div class="clicd-gauge-title">CPU</div>
<div class="clicd-gauge-sub" data-clicd-info="cpu_detail">-</div>
</div>
</div>
<div class="clicd-gauge">
<div class="clicd-ring" data-gauge="mem_percent"><span><span data-clicd-info="mem_percent">0</span>%</span></div>
<div>
<div class="clicd-gauge-title">&#20869;&#23384;</div>
<div class="clicd-gauge-sub" data-clicd-info="mem_detail">-</div>
</div>
</div>
<div class="clicd-gauge">
<div class="clicd-ring" data-gauge="load_percent"><span><span data-clicd-info="load_percent">0</span>%</span></div>
<div>
<div class="clicd-gauge-title">&#36127;&#36733;</div>
<div class="clicd-gauge-sub" data-clicd-info="load_detail">-</div>
</div>
</div>
<div class="clicd-gauge">
<div class="clicd-ring" data-gauge="disk_percent"><span><span data-clicd-info="disk_percent">0</span>%</span></div>
<div>
<div class="clicd-gauge-title">&#30913;&#30424;</div>
<div class="clicd-gauge-sub" data-clicd-info="disk_detail">-</div>
</div>
</div>
</div>
<div style="margin-top:14px">
<div class="clicd-traffic-row">
<div>&#26376;&#27969;&#37327;</div>
<div><span data-clicd-info="traffic_used_text">{$traffic_used_text|default='-'}</span> / <span data-clicd-info="traffic_limit_text">{$traffic_limit_text|default='-'}</span></div>
</div>
<div class="clicd-progress"><span data-progress="traffic_percent"></span></div>
<div class="clicd-traffic-row" style="font-size:12px;color:#6b7280">
<div>&#20837;&#31449; <span data-clicd-info="traffic_in_text">{$traffic_in_text|default='-'}</span></div>
<div>&#20986;&#31449; <span data-clicd-info="traffic_out_text">{$traffic_out_text|default='-'}</span></div>
</div>
</div>
</div>
<div class="clicd-section">
<div class="clicd-section-title"><span>&#32479;&#35745;&#20449;&#24687;</span></div>
<div class="clicd-charts">
<div class="clicd-chart">
<div class="clicd-chart-title">CPU &#20351;&#29992;&#29575; <span class="clicd-chart-value" data-clicd-info="cpu_detail">-</span></div>
<canvas data-chart="cpu_percent"></canvas>
</div>
<div class="clicd-chart">
<div class="clicd-chart-title">&#20869;&#23384;&#20351;&#29992; <span class="clicd-chart-value" data-clicd-info="mem_detail">-</span></div>
<canvas data-chart="mem_percent"></canvas>
</div>
<div class="clicd-chart">
<div class="clicd-chart-title">&#32593;&#32476;&#27969;&#37327; <span class="clicd-chart-value"><span data-clicd-info="net_in_rate">0 B/s</span> / <span data-clicd-info="net_out_rate">0 B/s</span></span></div>
<canvas data-chart="network"></canvas>
</div>
<div class="clicd-chart">
<div class="clicd-chart-title">&#30913;&#30424; IO <span class="clicd-chart-value"><span data-clicd-info="disk_read_rate">0 B/s</span> / <span data-clicd-info="disk_write_rate">0 B/s</span></span></div>
<canvas data-chart="diskio"></canvas>
</div>
</div>
</div>
<div class="clicd-section">
<div class="clicd-section-title"><span>&#23454;&#20363;&#20449;&#24687;</span></div>
<table class="clicd-table">
<tbody>
<tr>
<th>IPv4</th><td>{$ipv4|default='-'}</td>
<th>&#29992;&#25143;&#21517;</th><td>root</td>
</tr>
<tr>
<th>SSH &#31471;&#21475;</th><td>{$ssh_port|default='-'}</td>
<th>SSH &#23494;&#30721;</th><td>{$ssh_password|default='-'}</td>
</tr>
<tr>
<th>CPU</th><td>{$vcpu|default='-'} &#26680;</td>
<th>&#20869;&#23384;</th><td>{$ram_mb|default='-'} MB</td>
</tr>
<tr>
<th>&#30828;&#30424;</th><td>{$disk_gb|default='-'} GB</td>
<th>&#24102;&#23485;</th><td>{$bandwidth|default='-'} Mbps</td>
</tr>
<tr>
<th>&#21040;&#26399;&#26102;&#38388;</th><td colspan="3">{$expires_at|default='-'}</td>
</tr>
</tbody>
</table>
</div>
<div class="clicd-debug" id="clicd-info-debug"></div>
</div>
<script>
(function(){
var root = document.querySelector('[data-clicd-info-root="1"]:not([data-info-bound="1"])');
if (!root) return;
root.setAttribute('data-info-bound', '1');
var history = {
cpu_percent: [],
mem_percent: [],
network_in: [],
network_out: [],
disk_read: [],
disk_write: []
};
var maxPoints = 18;
var refreshTimer = null;
function endpoint() {
return "{$MODULE_CUSTOM_API}";
}
function number(value) {
var n = parseFloat(value);
return isFinite(n) ? n : 0;
}
function push(name, value) {
history[name].push(number(value));
if (history[name].length > maxPoints) history[name].shift();
}
function setText(key, value) {
root.querySelectorAll('[data-clicd-info="' + key + '"]').forEach(function(node){
if (typeof value !== 'object') node.textContent = value;
});
}
function setGauge(key, value) {
var pct = Math.max(0, Math.min(100, number(value)));
root.querySelectorAll('[data-gauge="' + key + '"]').forEach(function(node){
node.style.setProperty('--p', pct + '%');
});
}
function fitGaugeText() {
root.querySelectorAll('.clicd-ring').forEach(function(ring){
var label = ring.querySelector('span');
if (!label) return;
ring.removeAttribute('data-tight');
if (label.scrollWidth > label.clientWidth) {
ring.setAttribute('data-tight', '1');
}
if (label.scrollWidth > label.clientWidth) {
ring.setAttribute('data-tight', '2');
}
});
}
function setProgress(key, value) {
var pct = Math.max(0, Math.min(100, number(value)));
root.querySelectorAll('[data-progress="' + key + '"]').forEach(function(node){
node.style.width = pct + '%';
});
}
function draw(canvas, series, colors, maxValue) {
if (!canvas || !canvas.getContext) return;
var rect = canvas.getBoundingClientRect();
var ratio = window.devicePixelRatio || 1;
var width = Math.max(220, Math.floor(rect.width || canvas.clientWidth || 220));
var height = Math.max(120, Math.floor(rect.height || canvas.clientHeight || 132));
if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
canvas.width = width * ratio;
canvas.height = height * ratio;
}
var ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (var i = 1; i < 4; i++) {
var y = Math.round((height / 4) * i);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
series.forEach(function(values, idx){
if (!values.length) return;
var color = colors[idx] || '#2f80ed';
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
if (values.length === 1) {
var singleY = height - (Math.max(0, Math.min(maxValue, number(values[0]))) / maxValue) * (height - 6) - 3;
ctx.moveTo(0, singleY);
ctx.lineTo(width, singleY);
ctx.stroke();
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(width - 8, singleY, 3, 0, Math.PI * 2);
ctx.fill();
return;
}
values.forEach(function(value, i){
var x = values.length <= 1 ? width : (i / (values.length - 1)) * width;
var y = height - (Math.max(0, Math.min(maxValue, number(value))) / maxValue) * (height - 6) - 3;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
});
}
function redraw() {
draw(root.querySelector('[data-chart="cpu_percent"]'), [history.cpu_percent], ['#2f80ed'], 100);
draw(root.querySelector('[data-chart="mem_percent"]'), [history.mem_percent], ['#10b981'], 100);
var netMax = Math.max(1, Math.max.apply(null, history.network_in.concat(history.network_out, [1])));
draw(root.querySelector('[data-chart="network"]'), [history.network_in, history.network_out], ['#2f80ed', '#f59e0b'], netMax);
var ioMax = Math.max(1, Math.max.apply(null, history.disk_read.concat(history.disk_write, [1])));
draw(root.querySelector('[data-chart="diskio"]'), [history.disk_read, history.disk_write], ['#10b981', '#ef4444'], ioMax);
}
function showInfoError(text) {
var debug = document.getElementById('clicd-info-debug');
if (debug) {
debug.style.display = 'block';
debug.textContent = text || 'info load failed';
}
}
function loadInfo() {
var refreshNow = document.getElementById('clicd-info-refresh-now');
if (refreshNow) refreshNow.disabled = true;
var body = new URLSearchParams();
body.set('id', '{$service_id}');
body.set('func', 'infoData');
fetch(endpoint(), {
method:'POST',
headers:{
'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8',
'Authorization':'JWT {$Think.get.jwt}'
},
credentials:'same-origin',
body: body.toString()
})
.then(function(res){ return res.json(); })
.then(function(json){
if (!json || (json.status !== 200 && json.status !== 'success') || !json.data) {
showInfoError(json && json.msg ? json.msg : 'info load failed');
return;
}
var data = json.data;
Object.keys(data).forEach(function(key){ setText(key, data[key]); });
['cpu_percent','mem_percent','load_percent','disk_percent'].forEach(function(key){ setGauge(key, data[key]); });
fitGaugeText();
setProgress('traffic_percent', data.traffic_percent);
push('cpu_percent', data.cpu_percent);
push('mem_percent', data.mem_percent);
push('network_in', data.net_in_bps);
push('network_out', data.net_out_bps);
push('disk_read', data.disk_read_bps);
push('disk_write', data.disk_write_bps);
redraw();
})
.catch(function(error){
showInfoError(error && error.message ? error.message : 'info request failed');
})
.finally(function(){
if (refreshNow) refreshNow.disabled = false;
});
}
loadInfo();
var refreshSelect = document.getElementById('clicd-info-refresh');
var refreshNow = document.getElementById('clicd-info-refresh-now');
if (refreshNow) {
refreshNow.addEventListener('click', loadInfo);
}
if (refreshSelect) {
refreshSelect.addEventListener('change', function(){
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = null;
}
var ms = number(refreshSelect.value);
if (ms > 0) {
loadInfo();
refreshTimer = window.setInterval(loadInfo, ms);
}
});
}
window.addEventListener('resize', function(){ window.setTimeout(function(){ fitGaugeText(); redraw(); }, 50); });
})();
</script>
+328
View File
@@ -0,0 +1,328 @@
<style>
.clicd-nat-panel{font-size:14px;color:#1f2937}
.clicd-nat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
.clicd-nat-card{border:1px solid #e5e7eb;border-radius:6px;padding:12px;background:#fff}
.clicd-nat-label{color:#6b7280;font-size:12px;margin-bottom:4px}
.clicd-nat-value{font-size:18px;font-weight:600;word-break:break-all}
.clicd-nat-title{font-weight:600;margin:18px 0 8px}
.clicd-nat-muted{color:#6b7280}
.clicd-nat-form{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px;margin-top:8px}
.clicd-nat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;align-items:end}
.clicd-nat-field label{display:block;color:#6b7280;font-size:12px;margin-bottom:4px}
.clicd-nat-input,.clicd-nat-select{width:100%;height:34px;border:1px solid #d1d5db;border-radius:4px;padding:6px 8px;box-sizing:border-box}
.clicd-nat-actions{display:flex;gap:8px;flex-wrap:wrap}
.clicd-nat-btn{height:34px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;padding:0 12px;cursor:pointer}
.clicd-nat-btn[disabled]{opacity:.6;cursor:not-allowed}
.clicd-nat-btn-secondary{border-color:#d1d5db;background:#fff;color:#374151}
.clicd-nat-btn-danger{border-color:#dc2626;background:#dc2626;color:#fff}
.clicd-nat-list{display:flex;flex-direction:column;gap:10px;margin-top:8px}
.clicd-nat-item{border:1px solid #e5e7eb;border-radius:6px;background:#fff;padding:12px}
.clicd-nat-message{border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;border-radius:6px;padding:10px 12px;margin-bottom:12px;display:none}
.clicd-nat-message.error{border-color:#fecaca;background:#fef2f2;color:#b91c1c}
.clicd-nat-debug{margin-top:12px;border:1px dashed #d1d5db;border-radius:6px;background:#f9fafb;padding:10px;color:#374151;white-space:pre-wrap;font-size:12px;display:none}
.clicd-nat-modal-mask{position:fixed;inset:0;background:rgba(15,23,42,.42);display:none;align-items:center;justify-content:center;z-index:9999;padding:16px}
.clicd-nat-modal{width:min(420px,100%);background:#fff;border-radius:6px;border:1px solid #e5e7eb;box-shadow:0 18px 48px rgba(15,23,42,.22);padding:16px}
.clicd-nat-modal-title{font-size:16px;font-weight:700;color:#111827;margin-bottom:8px}
.clicd-nat-modal-body{font-size:14px;color:#4b5563;line-height:1.6;margin-bottom:14px}
.clicd-nat-modal-actions{display:flex;justify-content:flex-end;gap:8px}
</style>
<div class="clicd-nat-panel" id="clicd-nat-panel" data-service-id="{$service_id}" data-area-key="{$area_key}">
<div class="clicd-nat-message" id="clicd-nat-message"></div>
<div class="clicd-nat-grid">
<div class="clicd-nat-card">
<div class="clicd-nat-label">&#23454;&#20363;&#21517;&#31216;</div>
<div class="clicd-nat-value">{$container_name}</div>
</div>
<div class="clicd-nat-card">
<div class="clicd-nat-label">&#20844;&#32593;&#22320;&#22336;</div>
<div class="clicd-nat-value">{$nat_host}</div>
</div>
<div class="clicd-nat-card">
<div class="clicd-nat-label">SSH &#31471;&#21475;</div>
<div class="clicd-nat-value">{$ssh_port}</div>
</div>
</div>
<div class="clicd-nat-title">&#28155;&#21152;&#31471;&#21475;&#26144;&#23556;</div>
<div class="clicd-nat-form">
<div class="clicd-nat-row">
<div class="clicd-nat-field">
<label>&#20844;&#32593;&#31471;&#21475;</label>
<input class="clicd-nat-input" id="clicd-add-host-port" type="number" min="1" max="65535" placeholder="61320">
</div>
<div class="clicd-nat-field">
<label>&#23481;&#22120;&#31471;&#21475;</label>
<input class="clicd-nat-input" id="clicd-add-container-port" type="number" min="1" max="65535" placeholder="8080">
</div>
<div class="clicd-nat-field">
<label>&#21327;&#35758;</label>
<select class="clicd-nat-select" id="clicd-add-protocol">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="clicd-nat-field">
<label>&#35828;&#26126;</label>
<input class="clicd-nat-input" id="clicd-add-description" type="text" placeholder="HTTP">
</div>
<div class="clicd-nat-actions">
<button class="clicd-nat-btn" type="button" data-clicd-action="add">&#28155;&#21152;</button>
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="random-port">&#33719;&#21462;&#38543;&#26426;&#31471;&#21475;</button>
</div>
</div>
</div>
<div class="clicd-nat-title">&#29616;&#26377;&#31471;&#21475;&#26144;&#23556;</div>
<div id="clicd-nat-list" class="clicd-nat-list">
{if condition="empty($port_mappings)"}
<div class="clicd-nat-form clicd-nat-muted">&#26242;&#26080;&#31471;&#21475;&#26144;&#23556;</div>
{else/}
{foreach name="port_mappings" item="mapping"}
<div class="clicd-nat-item" data-index="{$mapping.index}">
<div class="clicd-nat-row">
<div class="clicd-nat-field">
<label>&#32034;&#24341;</label>
<div class="clicd-nat-value">{$mapping.index}</div>
</div>
<div class="clicd-nat-field">
<label>&#20844;&#32593;&#35775;&#38382;</label>
<div class="clicd-nat-value">{$nat_host}:{$mapping.host_port}</div>
</div>
<div class="clicd-nat-field">
<label>&#20844;&#32593;&#31471;&#21475;</label>
<input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="{$mapping.host_port}">
</div>
<div class="clicd-nat-field">
<label>&#23481;&#22120;&#31471;&#21475;</label>
<input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="{$mapping.container_port}">
</div>
<div class="clicd-nat-field">
<label>&#21327;&#35758;</label>
<select class="clicd-nat-select" data-field="protocol">
<option value="tcp" {$mapping.tcp_selected}>TCP</option>
<option value="udp" {$mapping.udp_selected}>UDP</option>
</select>
</div>
<div class="clicd-nat-field">
<label>&#35828;&#26126;</label>
<input class="clicd-nat-input" data-field="description" type="text" value="{$mapping.description}">
</div>
<div class="clicd-nat-actions">
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">&#20445;&#23384;</button>
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">&#21024;&#38500;</button>
</div>
</div>
</div>
{/foreach}
{/if}
</div>
<pre class="clicd-nat-debug" id="clicd-nat-debug"></pre>
<div class="clicd-nat-modal-mask" id="clicd-nat-delete-modal">
<div class="clicd-nat-modal">
<div class="clicd-nat-modal-title">&#30830;&#35748;&#21024;&#38500;</div>
<div class="clicd-nat-modal-body" id="clicd-nat-delete-text">&#30830;&#35748;&#21024;&#38500;&#35813;&#31471;&#21475;&#26144;&#23556;&#65311;</div>
<div class="clicd-nat-modal-actions">
<button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" id="clicd-nat-delete-cancel">&#21462;&#28040;</button>
<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" id="clicd-nat-delete-confirm">&#21024;&#38500;</button>
</div>
</div>
</div>
</div>
<script>
(function(){
var panel = document.getElementById('clicd-nat-panel');
if (!panel || panel.getAttribute('data-bound') === '1') return;
panel.setAttribute('data-bound', '1');
var message = document.getElementById('clicd-nat-message');
var debugBox = document.getElementById('clicd-nat-debug');
var list = document.getElementById('clicd-nat-list');
var natHost = '{$nat_host}';
var deleteModal = document.getElementById('clicd-nat-delete-modal');
var deleteText = document.getElementById('clicd-nat-delete-text');
var deleteCancel = document.getElementById('clicd-nat-delete-cancel');
var deleteConfirm = document.getElementById('clicd-nat-delete-confirm');
var pendingDeletePayload = null;
function showMessage(type, text) {
message.className = 'clicd-nat-message' + (type === 'error' ? ' error' : '');
message.style.display = 'block';
message.textContent = text || '';
}
function showDebug(data) {
debugBox.style.display = 'block';
debugBox.textContent = JSON.stringify(data || {}, null, 2);
}
function endpoint() {
return "{$MODULE_CUSTOM_API}";
}
function field(item, name) {
return item.querySelector('[data-field="' + name + '"]');
}
function setBusy(busy) {
panel.querySelectorAll('button').forEach(function(btn){ btn.disabled = !!busy; });
}
function renderList(items) {
if (!Array.isArray(items) || items.length === 0) {
list.innerHTML = '<div class="clicd-nat-form clicd-nat-muted">&#26242;&#26080;&#31471;&#21475;&#26144;&#23556;</div>';
return;
}
list.innerHTML = items.map(function(item) {
var protocol = (item.protocol || 'tcp').toLowerCase();
var desc = escapeHtml(item.description || '');
var index = escapeHtml(String(item.index));
var hostPort = escapeHtml(String(item.host_port || ''));
var containerPort = escapeHtml(String(item.container_port || ''));
return '<div class="clicd-nat-item" data-index="' + index + '">' +
'<div class="clicd-nat-row">' +
'<div class="clicd-nat-field"><label>&#32034;&#24341;</label><div class="clicd-nat-value">' + index + '</div></div>' +
'<div class="clicd-nat-field"><label>&#20844;&#32593;&#35775;&#38382;</label><div class="clicd-nat-value">' + escapeHtml(natHost) + ':' + hostPort + '</div></div>' +
'<div class="clicd-nat-field"><label>&#20844;&#32593;&#31471;&#21475;</label><input class="clicd-nat-input" data-field="host_port" type="number" min="1" max="65535" value="' + hostPort + '"></div>' +
'<div class="clicd-nat-field"><label>&#23481;&#22120;&#31471;&#21475;</label><input class="clicd-nat-input" data-field="container_port" type="number" min="1" max="65535" value="' + containerPort + '"></div>' +
'<div class="clicd-nat-field"><label>&#21327;&#35758;</label><select class="clicd-nat-select" data-field="protocol">' +
'<option value="tcp"' + (protocol === 'tcp' ? ' selected' : '') + '>TCP</option>' +
'<option value="udp"' + (protocol === 'udp' ? ' selected' : '') + '>UDP</option>' +
'</select></div>' +
'<div class="clicd-nat-field"><label>&#35828;&#26126;</label><input class="clicd-nat-input" data-field="description" type="text" value="' + desc + '"></div>' +
'<div class="clicd-nat-actions"><button class="clicd-nat-btn clicd-nat-btn-secondary" type="button" data-clicd-action="update">&#20445;&#23384;</button>' +
'<button class="clicd-nat-btn clicd-nat-btn-danger" type="button" data-clicd-action="delete">&#21024;&#38500;</button></div>' +
'</div></div>';
}).join('');
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
async function request(action, payload, silent) {
setBusy(true);
try {
var funcMap = {
'random-port': 'randomPort',
'add': 'addNat',
'update': 'updateNat',
'delete': 'deleteNat',
'list': 'natList'
};
var body = new URLSearchParams();
body.set('id', panel.getAttribute('data-service-id') || '');
body.set('func', funcMap[action] || action);
Object.keys(payload || {}).forEach(function(key){ body.set(key, payload[key]); });
var res = await fetch(endpoint(), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Authorization': 'JWT {$Think.get.jwt}'
},
credentials: 'same-origin',
body: body.toString()
});
var text = await res.text();
var data;
try { data = JSON.parse(text); } catch (e) { data = {status:'error', msg:'\u975e JSON \u54cd\u5e94: ' + text}; }
showDebug((data.data && data.data.debug) || data.debug || data);
if (data.status === 200 || data.status === 'success') {
if (!silent) {
showMessage('success', data.msg || '\u64cd\u4f5c\u6210\u529f');
}
if (data.data && data.data.port) {
document.getElementById('clicd-add-host-port').value = data.data.port;
}
if (data.data && Array.isArray(data.data.port_mappings)) {
renderList(data.data.port_mappings);
} else if (action !== 'random-port') {
request('list', {}, true);
}
} else {
showMessage('error', data.msg || '\u64cd\u4f5c\u5931\u8d25');
}
} catch (e) {
showMessage('error', e.message || '\u8bf7\u6c42\u5931\u8d25');
showDebug({error: String(e)});
} finally {
setBusy(false);
}
}
function openDeleteModal(payload) {
pendingDeletePayload = payload;
if (deleteText) {
deleteText.textContent = '\u786e\u8ba4\u5220\u9664\u7aef\u53e3\u6620\u5c04 ' + natHost + ':' + (payload.host_port || '-') + ' -> ' + (payload.container_port || '-') + '/' + (payload.protocol || 'tcp') + ' \u5417\uff1f';
}
if (deleteModal) {
deleteModal.style.display = 'flex';
}
}
function closeDeleteModal() {
pendingDeletePayload = null;
if (deleteModal) {
deleteModal.style.display = 'none';
}
}
panel.addEventListener('click', function(event) {
var button = event.target.closest('[data-clicd-action]');
if (!button) return;
var action = button.getAttribute('data-clicd-action');
if (action === 'random-port') {
request('random-port', {});
return;
}
if (action === 'add') {
request('add', {
host_port: document.getElementById('clicd-add-host-port').value,
container_port: document.getElementById('clicd-add-container-port').value,
protocol: document.getElementById('clicd-add-protocol').value,
description: document.getElementById('clicd-add-description').value
});
return;
}
var item = button.closest('.clicd-nat-item');
if (!item) return;
var payload = {
index: item.getAttribute('data-index'),
host_port: field(item, 'host_port') ? field(item, 'host_port').value : '',
container_port: field(item, 'container_port') ? field(item, 'container_port').value : '',
protocol: field(item, 'protocol') ? field(item, 'protocol').value : 'tcp',
description: field(item, 'description') ? field(item, 'description').value : ''
};
if (action === 'delete') {
openDeleteModal(payload);
return;
}
request(action, payload);
});
if (deleteCancel) {
deleteCancel.addEventListener('click', closeDeleteModal);
}
if (deleteModal) {
deleteModal.addEventListener('click', function(event){
if (event.target === deleteModal) closeDeleteModal();
});
}
if (deleteConfirm) {
deleteConfirm.addEventListener('click', function(){
if (!pendingDeletePayload) return;
var payload = pendingDeletePayload;
closeDeleteModal();
request('delete', payload);
});
}
})();
</script>
+70 -28
View File
@@ -5,53 +5,94 @@
<h1 align="center">CLICD</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.22-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
<img alt="LXC" src="https://img.shields.io/badge/LXC-Supported-111111?style=flat-square">
<img alt="KVM" src="https://img.shields.io/badge/KVM-Supported-EE0000?style=flat-square">
</p>
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
<p align="center">
<img alt="WebSSH" src="https://img.shields.io/badge/WebSSH-Built--in-009688?style=flat-square">
<img alt="VNC" src="https://img.shields.io/badge/VNC-Supported-7B1FA2?style=flat-square">
<img alt="IPv6" src="https://img.shields.io/badge/IPv6-Native-1976D2?style=flat-square">
<img alt="NAT" src="https://img.shields.io/badge/NAT-Port_Forwarding-FF9800?style=flat-square">
<img alt="REST API" src="https://img.shields.io/badge/API-REST-4CAF50?style=flat-square">
<img alt="Multi User" src="https://img.shields.io/badge/Multi_User-Supported-8E24AA?style=flat-square">
<img alt="Traffic Control" src="https://img.shields.io/badge/Traffic-Control-795548?style=flat-square">
<img alt="Security Alert" src="https://img.shields.io/badge/Security-Alert-orange?style=flat-square">
<img alt="CLI" src="https://img.shields.io/badge/CLI-Mode-424242?style=flat-square">
<img alt="TLS" src="https://img.shields.io/badge/TLS-Let's_Encrypt-003A70?style=flat-square&logo=letsencrypt&logoColor=white">
</p>
## 功能介绍
CLICD is a lightweight virtualization management panel for LXC and KVM. It combines a web console, CLI tools, REST API, NAT/IPv6 networking, WebSSH/WebVNC access, resource quotas, traffic limits, snapshots, delegated sub-user access, and security alerts into a single deployable service.
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,集成 Web 控制台、CLI、REST API、NAT/IPv6 网络、WebSSH/WebVNC、资源配额、流量限制、快照、子用户授权和安全告警能力,适合 VPS 商家、实验室、开发者自建虚拟化节点以及需要批量开通容器的场景
## 技术栈
![alt text](/img/image-1.png)
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.22
## Installation / 安装
## 安装
一键安装:
One-click Install / 一键安装:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
一键卸载:
One-click Uninstall / 一键卸载:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
```
![alt text](/img/image.png)
![alt text](/img/image-1.png)
## Features / 功能介绍
### English
| Area | What CLICD provides |
| --- | --- |
| Virtualization | Manage LXC containers and KVM virtual machines from one panel, including create, reinstall, start, stop, restart, delete, password reset, expiry control, and batch actions. |
| Images and templates | Built-in template and image management for Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other common distributions. Images can be enabled, disabled, downloaded, cancelled, or removed from cache. |
| Networking | NAT4 port quotas, random available port allocation, TCP/UDP port mappings, public IPv4 pool management, IPv6 prefix detection, IPv6 status checks, and per-container IPv6 assignment. |
| Resource control | CPU, memory, disk, swap, bandwidth usage, traffic reset, traffic limit, and resource limit management, with automatic shutdown behavior for expired or over-quota containers. |
| Console access | Browser-based WebSSH and WebVNC ticket access, so users can open terminals or consoles without manually exchanging credentials. |
| Snapshots | Snapshot overview, per-container snapshots, create/delete/restore operations, scheduled snapshots, and quota controls. |
| Security | Conntrack-based security alerts for port scans, lateral scans, brute-force behavior, SMTP abuse, UDP reflection, mining ports, proxy/VPN/Tor usage, plus security logs, summaries, and configurable settings. |
| Accounts and audit | Delegated sub-user links, sub-user password rotation, per-user container permissions, audit logs, login logs, and API key management. |
| Automation | Versioned REST API under `/api/v1`, task queue endpoints, batch create/action endpoints, and a Mofang finance integration module packaged automatically by GitHub Actions. |
| Operations | Dashboard statistics, host resource overview, routing overview, swap management, CLI-only mode, and release artifacts generated by GitHub Actions. |
### 中文
| 模块 | CLICD 提供的能力 |
| --- | --- |
| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 |
| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 |
| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 |
| 资源限制 | 支持 CPU、内存、磁盘、Swap、带宽用量、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 |
| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 |
| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 |
| 账号与审计 | 支持子用户管理链接、子用户密码轮换、按容器授权、操作日志、登录日志和 API Key 管理,适合分发给下游用户或拼车用户。 |
| 自动化接入 | 全量接口统一使用 `/api/v1`,覆盖任务队列、容器、镜像、网络、流量、安全、批量创建和批量操作;同时提供魔方财务对接模块,并由 GitHub Actions 自动打包发布。 |
| 运维入口 | 提供总览统计、主机资源、路由概览、Swap 管理、CLI-only 模式和 GitHub Actions 自动发布产物,便于在小型节点上长期维护。 |
## Technology Stack / 技术栈
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC, KVM/QEMU
- Build: GitHub Actions, Node.js 20, Go 1.24
## Preview / 预览
![alt text](/img/image-2.png)
![alt text](/img/image-3.png)
![alt text](/img/image-4.png)
![alt text](/img/image-5.png)
## Disclaimer/免责声明
@@ -71,10 +112,11 @@ This open-source software is intended solely for educational purposes, specifica
本开源软件仅供学习和研究 LXC、KVM 等虚拟化技术原理之目的使用,不得用于任何违反适用法律法规、软件许可协议或第三方权益的行为。
本软件中涉及的 Windows 名称、标识、图标及相关知识产权均归 Microsoft Corporation 及其权利人所有。本项目与微软公司不存在任何关联、授权或合作关系。
## Thanks/鸣谢
## Thanks / 鸣谢
- [Nodeseek.com](https://www.nodeseek.com) — 一个专注于服务器的社区
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
## Star History
<a href="https://www.star-history.com/?repos=MengMengCode%2FCLICD&type=date&legend=top-left">
@@ -83,4 +125,4 @@ This open-source software is intended solely for educational purposes, specifica
<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>
</a>
+33 -38
View File
@@ -2,12 +2,10 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"clicd/internal/config"
"clicd/internal/lxc"
@@ -228,13 +226,38 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
if cfg.DiskGB < 1 {
cfg.DiskGB = 5
}
if cfg.PortMappingCount < 2 {
if cfg.PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot be negative"})
return
}
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
}
if cfg.PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
return
}
if cfg.IPv4Count < 0 || cfg.IPv6Count < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot be negative"})
return
}
if cfg.IPv4Count > 64 || cfg.IPv6Count > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "IP address count cannot exceed 64"})
return
}
if !cfg.AssignIPv4 && len(cfg.PublicIPv4s) == 0 {
cfg.IPv4Count = 0
}
if !cfg.AssignIPv6 && len(cfg.IPv6Addresses) == 0 {
cfg.IPv6Count = 0
}
if !hasRequestedNetwork(cfg) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: noNetworkSelectedMessage})
return
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
}
@@ -242,6 +265,10 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if err := validateCreateSSHAuth(cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.ExpiresAt != "" {
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
if !ok {
@@ -405,24 +432,11 @@ func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
// Find a random unused port between 10000-65535
used := map[int]bool{}
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
}
// Also check all containers
for _, oc := range config.AppConfig.Containers {
if oc.ID == id {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
}
}
hostIP := strings.TrimSpace(r.URL.Query().Get("host_ip"))
// Try random ports
for tries := 0; tries < 100; tries++ {
port := 10000 + (int(time.Now().UnixNano()) % 55535)
if !used[port] {
if lxc.HostPortAvailable(c, hostIP, port, "tcp") {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
return
}
@@ -524,26 +538,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
}
func validateSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 {
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return nil
return lxc.ValidateCustomSSHPassword(password)
}
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
+115 -7
View File
@@ -85,6 +85,7 @@ type HostDiskProbe struct {
Serial string `json:"serial"`
SizeBytes uint64 `json:"size_bytes"`
Type string `json:"type"`
Virtual bool `json:"virtual"`
Rotational bool `json:"rotational"`
Mountpoints []string `json:"mountpoints"`
Health string `json:"health"`
@@ -201,6 +202,7 @@ type NetworkInfo struct {
TXBps float64 `json:"tx_bps"`
PublicIPv4 string `json:"public_ipv4"`
PublicIPv4Interface string `json:"public_ipv4_interface"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPv6Interface string `json:"public_ipv6_interface"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
@@ -398,7 +400,8 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
publicIPv4 := lxc.DetectPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
@@ -540,7 +543,7 @@ func getHostProbeReport() HostProbeReport {
Disks: detectHostDisks(),
NetworkInterfaces: detectHostNICs(),
PublicIPv4: detectAllPublicIPv4(),
IPv6Prefixes: lxc.DetectPublicIPv6Prefixes(),
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
Gateways: detectGateways(),
GPUs: detectGPUs(),
System: detectSystemProbe(),
@@ -676,17 +679,23 @@ func detectHostDisks() []HostDiskProbe {
}
base := filepath.Join("/sys/block", name)
path := "/dev/" + name
model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name")))
vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor")))
virtual := isVirtualBlockDevice(name, model, vendor)
disk := HostDiskProbe{
Name: name,
Path: path,
Model: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name"))),
Model: model,
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
Type: detectDiskType(base, name),
Type: detectDiskType(base, name, virtual),
Virtual: virtual,
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
Mountpoints: mounts[name],
}
disk.SMART = detectDiskSMART(path)
if !virtual {
disk.SMART = detectDiskSMART(path)
}
disk.Health = disk.SMARTHealth()
disk.HealthDetail = disk.SMARTDetail()
disks = append(disks, disk)
@@ -695,7 +704,10 @@ func detectHostDisks() []HostDiskProbe {
return disks
}
func detectDiskType(base, name string) string {
func detectDiskType(base, name string, virtual bool) string {
if virtual {
return "Virtual"
}
if strings.HasPrefix(name, "nvme") {
return "NVMe"
}
@@ -705,7 +717,26 @@ func detectDiskType(base, name string) string {
return "SSD"
}
func isVirtualBlockDevice(name, model, vendor string) bool {
lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor))
if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") {
return true
}
for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft",
} {
if strings.Contains(lower, token) {
return true
}
}
return false
}
func (disk HostDiskProbe) SMARTHealth() string {
if disk.Virtual {
return "virtual"
}
if disk.SMART.Available && disk.Health != "" {
return disk.Health
}
@@ -713,6 +744,9 @@ func (disk HostDiskProbe) SMARTHealth() string {
}
func (disk HostDiskProbe) SMARTDetail() string {
if disk.Virtual {
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
}
return disk.SMART.Detail()
}
@@ -1424,6 +1458,7 @@ func detectHostEnvironment() []HostEnvCheck {
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
certbotCheck(),
}
checks = append(checks, HostEnvCheck{Key: "dev-kvm", Label: "/dev/kvm 硬件虚拟化", OK: fileExists("/dev/kvm"), Required: false, Detail: boolDetail(fileExists("/dev/kvm"))})
checks = append(checks, HostEnvCheck{Key: "ipv4-forward", Label: "IPv4 转发", OK: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward")) == "1", Required: true, Detail: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward"))})
@@ -1436,7 +1471,7 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
ok := commandExists(cmd)
detail := "missing"
if ok {
detail = strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
detail = commandVersionDetail(cmd)
if detail == "" {
detail = "installed"
}
@@ -1446,6 +1481,79 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
}
func commandVersionDetail(cmd string) string {
switch cmd {
case "ip":
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1"))
default:
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
}
}
func certbotCheck() HostEnvCheck {
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
if !commandExists("certbot") {
return check
}
detail := strings.TrimSpace(runCommandOutput(3*time.Second, "certbot", "--version"))
if detail == "" {
detail = strings.TrimSpace(runCommandOutput(3*time.Second, "sh", "-c", "certbot --version 2>&1 | head -n 1"))
}
if detail == "" {
detail = "installed, version unknown"
}
check.Detail = detail
version := extractCertbotVersion(detail)
check.OK = certbotVersionAtLeast(version, 5, 4)
if version == "" {
check.Detail = detail + " (version unknown, need >= 5.4)"
} else if !check.OK {
check.Detail = detail + " (need >= 5.4)"
}
return check
}
func extractCertbotVersion(output string) string {
for _, field := range strings.Fields(output) {
field = strings.Trim(field, "vV,;:()[]{}")
if field == "" || field[0] < '0' || field[0] > '9' {
continue
}
return field
}
return ""
}
func certbotVersionAtLeast(version string, minMajor, minMinor int) bool {
parts := strings.Split(version, ".")
if len(parts) < 2 {
return false
}
major, err := strconv.Atoi(numericPrefix(parts[0]))
if err != nil {
return false
}
minor, err := strconv.Atoi(numericPrefix(parts[1]))
if err != nil {
return false
}
if major != minMajor {
return major > minMajor
}
return minor >= minMinor
}
func numericPrefix(value string) string {
var b strings.Builder
for _, r := range value {
if r < '0' || r > '9' {
break
}
b.WriteRune(r)
}
return b.String()
}
func envCheckOK(checks []HostEnvCheck, key string) bool {
for _, check := range checks {
if check.Key == key {
+43
View File
@@ -0,0 +1,43 @@
package api
import "testing"
func TestExtractCertbotVersion(t *testing.T) {
tests := []struct {
output string
want string
}{
{"certbot 5.4.0", "5.4.0"},
{"certbot v5.10.1", "5.10.1"},
{"certbot, version 4.9", "4.9"},
{"installed", ""},
}
for _, tt := range tests {
if got := extractCertbotVersion(tt.output); got != tt.want {
t.Fatalf("extractCertbotVersion(%q) = %q, want %q", tt.output, got, tt.want)
}
}
}
func TestCertbotVersionAtLeast54(t *testing.T) {
tests := []struct {
version string
want bool
}{
{"5.4", true},
{"5.4.0", true},
{"5.10", true},
{"6.0.0", true},
{"5.3.9", false},
{"4.99", false},
{"5", false},
{"", false},
}
for _, tt := range tests {
if got := certbotVersionAtLeast(tt.version, 5, 4); got != tt.want {
t.Fatalf("certbotVersionAtLeast(%q, 5, 4) = %v, want %v", tt.version, got, tt.want)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"clicd/internal/config"
)
type webSSHOriginSettingsRequest struct {
Origins []string `json:"origins"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
}
type webSSHOriginSettingsResponse struct {
Origins []string `json:"origins"`
CurrentOrigin string `json:"current_origin,omitempty"`
}
func HandleWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: webSSHOriginSettingsStatus(r)})
case http.MethodPut:
updateWebSSHOriginSettings(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func updateWebSSHOriginSettings(w http.ResponseWriter, r *http.Request) {
var req webSSHOriginSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
origins := req.Origins
if len(origins) == 0 && len(req.WebSSHAllowedOrigins) > 0 {
origins = req.WebSSHAllowedOrigins
}
normalized, err := config.NormalizeAllowedOrigins(origins)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
config.AppConfig.WebSSHAllowedOrigins = normalized
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save Origin allowlist failed"})
return
}
auditRequest(r, "settings.webssh_origins", "WebSSH Origin", "origins="+strings.Join(normalized, ","), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Origin allowlist saved", Data: webSSHOriginSettingsStatus(r)})
}
func webSSHOriginSettingsStatus(r *http.Request) webSSHOriginSettingsResponse {
origins := config.AppConfig.WebSSHAllowedOrigins
if origins == nil {
origins = []string{}
}
return webSSHOriginSettingsResponse{
Origins: origins,
CurrentOrigin: requestOrigin(r),
}
}
func requestOrigin(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" {
return ""
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
scheme = strings.ToLower(strings.Split(forwarded, ",")[0])
}
return scheme + "://" + host
}
+238 -20
View File
@@ -1,7 +1,9 @@
package api
import (
"encoding/json"
"net/http"
"net/netip"
"sort"
"strconv"
@@ -21,12 +23,24 @@ type nat4Route struct {
LXCName string `json:"lxc_name"`
Status string `json:"status"`
IP string `json:"ip"`
HostIP string `json:"host_ip"`
HostPort int `json:"host_port"`
ContainerPort int `json:"container_port"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
type ipv4Route 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"`
}
type ipv6Route struct {
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
@@ -38,30 +52,79 @@ type ipv6Route struct {
}
type routingResponse struct {
NAT4 routeCapacity `json:"nat4"`
IPv6 routeCapacity `json:"ipv6"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
NAT4 routeCapacity `json:"nat4"`
IPv4 routeCapacity `json:"ipv4"`
IPv6 routeCapacity `json:"ipv6"`
HostPublicIPv4 lxc.PublicIPInfo `json:"host_public_ipv4"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
IPv4Assignments []ipv4Route `json:"ipv4_assignments"`
NAT4Mappings []nat4Route `json:"nat4_mappings"`
IPv6Assignments []ipv6Route `json:"ipv6_assignments"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
}
type routingPoolsRequest struct {
Addresses *[]string `json:"addresses"`
Items *[]config.PublicIPv4Assignment `json:"items"`
IPv6Prefixes *[]config.PublicIPv6Prefix `json:"ipv6_prefixes"`
}
type publicIPv4ScanRequest struct {
CIDR string `json:"cidr"`
Interface string `json:"interface"`
Gateway string `json:"gateway"`
Verify bool `json:"verify"`
Limit int `json:"limit"`
}
func HandleRouting(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
switch r.Method {
case http.MethodGet:
handleRoutingGet(w, r)
case http.MethodPut:
handleRoutingPoolsUpdate(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
func HandleRoutingIPv4Scan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "routing:read") {
if !requireScope(w, r, "routing:write") {
return
}
var req publicIPv4ScanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
results, err := lxc.ScanPublicIPv4Segment(req.CIDR, req.Interface, req.Gateway, req.Verify, req.Limit)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
}
func handleRoutingGet(w http.ResponseWriter, r *http.Request) {
if !hasAnyScope(r, "routing:read", "routing:write") {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Insufficient API key scope"})
return
}
nat4Mappings := make([]nat4Route, 0)
usedPorts := map[int]bool{}
ipv4Assignments := make([]ipv4Route, 0)
ipv6Assignments := make([]ipv6Route, 0)
const nat4StartPort = 20000
const nat4EndPort = 65535
for _, c := range config.AppConfig.Containers {
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
for _, pm := range c.PortMappings {
if pm.HostPort >= nat4StartPort && pm.HostPort <= nat4EndPort {
usedPorts[pm.HostPort] = true
@@ -72,30 +135,56 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
LXCName: c.LxcName(),
Status: c.Status,
IP: c.IP,
HostIP: pm.HostIP,
HostPort: pm.HostPort,
ContainerPort: pm.ContainerPort,
Protocol: pm.Protocol,
Description: pm.Description,
})
}
if c.IPv6 != "" {
for _, ip := range c.PublicIPv4s {
if ip.Address == "" {
continue
}
ipv4Assignments = append(ipv4Assignments, ipv4Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: ip.Address,
Interface: ip.Interface,
PrefixLen: ip.PrefixLen,
Gateway: ip.Gateway,
})
}
c.NormalizeNetworkAssignments()
for _, ip := range c.IPv6Addresses {
if ip.Address == "" {
continue
}
ipv6Assignments = append(ipv6Assignments, ipv6Route{
ContainerID: c.ID,
ContainerName: c.Name,
LXCName: c.LxcName(),
Status: c.Status,
Address: c.IPv6,
PrefixLen: c.IPv6PrefixLen,
Interface: c.IPv6Interface,
Address: ip.Address,
PrefixLen: ip.PrefixLen,
Interface: ip.Interface,
})
}
}
sort.SliceStable(nat4Mappings, func(i, j int) bool {
if nat4Mappings[i].HostPort == nat4Mappings[j].HostPort {
if nat4Mappings[i].HostIP != nat4Mappings[j].HostIP {
return nat4Mappings[i].HostIP < nat4Mappings[j].HostIP
}
return nat4Mappings[i].ContainerName < nat4Mappings[j].ContainerName
}
return nat4Mappings[i].HostPort < nat4Mappings[j].HostPort
})
sort.SliceStable(ipv4Assignments, func(i, j int) bool {
return ipv4Assignments[i].Address < ipv4Assignments[j].Address
})
sort.SliceStable(ipv6Assignments, func(i, j int) bool {
return ipv6Assignments[i].Address < ipv6Assignments[j].Address
})
@@ -108,12 +197,16 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
}
prefixes := lxc.DetectPublicIPv6Prefixes()
ipv6Total := "0"
ipv6Remaining := "0"
if len(prefixes) > 0 {
ipv6Total = lxc.IPv6PrefixCapacity(prefixes[0].PrefixLen)
ipv6Remaining = subtractCapacity(ipv6Total, len(ipv6Assignments))
hostPublicIPv4 := lxc.DetectPublicIPv4()
publicIPv4s := lxc.DetectPublicIPv4Candidates()
ipv4Total := len(publicIPv4s)
ipv4Used := len(ipv4Assignments)
ipv4Remaining := ipv4Total - ipv4Used
if ipv4Remaining < 0 {
ipv4Remaining = 0
}
ipv6Total := totalIPv6Capacity(prefixes)
ipv6Remaining := subtractCapacity(ipv6Total, len(ipv6Assignments))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
@@ -123,18 +216,143 @@ func HandleRouting(w http.ResponseWriter, r *http.Request) {
Remaining: strconv.Itoa(nat4Remaining),
Total: strconv.Itoa(totalNAT4Ports),
},
IPv4: routeCapacity{
Used: ipv4Used,
Remaining: strconv.Itoa(ipv4Remaining),
Total: strconv.Itoa(ipv4Total),
},
IPv6: routeCapacity{
Used: len(ipv6Assignments),
Remaining: ipv6Remaining,
Total: ipv6Total,
},
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
HostPublicIPv4: hostPublicIPv4,
PublicIPv4Addresses: publicIPv4s,
IPv4Assignments: ipv4Assignments,
NAT4Mappings: nat4Mappings,
IPv6Assignments: ipv6Assignments,
IPv6Prefixes: prefixes,
},
})
}
func handleRoutingPoolsUpdate(w http.ResponseWriter, r *http.Request) {
if !requireScope(w, r, "routing:write") {
return
}
var req routingPoolsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if req.Items != nil || req.Addresses != nil {
items := []config.PublicIPv4Assignment{}
if req.Items != nil {
items = *req.Items
} else if req.Addresses != nil {
items = make([]config.PublicIPv4Assignment, 0, len(*req.Addresses))
for _, address := range *req.Addresses {
items = append(items, config.PublicIPv4Assignment{Address: address})
}
}
normalized, err := lxc.NormalizePublicIPv4Pool(items)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
allowed := map[string]bool{}
for _, item := range normalized {
allowed[item.Address] = true
}
for _, c := range config.AppConfig.Containers {
for _, item := range c.PublicIPv4s {
if item.Address != "" && !allowed[item.Address] {
jsonResponse(w, http.StatusBadRequest, APIResponse{
Success: false,
Message: "IPv4 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
})
return
}
}
}
config.AppConfig.PublicIPv4Pool = normalized
}
if req.IPv6Prefixes != nil {
normalized, err := lxc.NormalizePublicIPv6Prefixes(*req.IPv6Prefixes)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
parsedPrefixes := make([]netip.Prefix, 0, len(normalized))
for _, item := range normalized {
prefix, err := netip.ParsePrefix(item.Prefix)
if err == nil {
parsedPrefixes = append(parsedPrefixes, prefix)
}
}
for _, c := range config.AppConfig.Containers {
c.NormalizeNetworkAssignments()
for _, item := range c.IPv6Addresses {
if item.Address == "" {
continue
}
addr, err := netip.ParseAddr(item.Address)
if err != nil {
continue
}
contained := false
for _, prefix := range parsedPrefixes {
if prefix.Contains(addr) {
contained = true
break
}
}
if !contained {
jsonResponse(w, http.StatusBadRequest, APIResponse{
Success: false,
Message: "IPv6 " + item.Address + " is assigned to container " + c.Name + " and cannot be removed from the pool",
})
return
}
}
}
config.AppConfig.PublicIPv6Prefixes = normalized
}
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
return
}
handleRoutingGet(w, r)
}
func totalIPv6Capacity(prefixes []lxc.IPv6PrefixInfo) string {
if len(prefixes) == 0 {
return "0"
}
var total uint64
for _, prefix := range prefixes {
capacity := lxc.IPv6PrefixCapacity(prefix.PrefixLen)
if capacity == "large" {
return "large"
}
parsed, err := strconv.ParseUint(capacity, 10, 64)
if err != nil {
continue
}
if ^uint64(0)-total < parsed {
return "large"
}
total += parsed
}
if total == 0 {
return "0"
}
return strconv.FormatUint(total, 10)
}
func subtractCapacity(total string, used int) string {
if total == "" || total == "0" {
return "0"
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"clicd/internal/config"
)
func TestHandleRoutingGetAllowsRoutingWriteScope(t *testing.T) {
config.AppConfig = &config.ClicdConfig{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/routing", nil)
req = withAuthContext(req, AuthContext{
Type: authTypeAPIKey,
Scopes: []string{"routing:write"},
})
rec := httptest.NewRecorder()
handleRoutingGet(rec, req)
if rec.Code == http.StatusForbidden {
t.Fatal("routing:write scope should be able to receive the routing response after updates")
}
}
+29 -3
View File
@@ -13,10 +13,16 @@ import (
var kvmManager = kvm.NewManager()
const noNetworkSelectedMessage = "请勾选任意一个可用网络"
func runtimeFromRequest(value string) string {
return config.NormalizeVirtualization(value)
}
func hasRequestedNetwork(cfg lxc.ContainerConfig) bool {
return cfg.WantsNAT() || cfg.AssignIPv4 || len(cfg.PublicIPv4s) > 0 || cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0
}
func runtimeFromTemplateID(templateID string) string {
if kvm.FindImage(templateID) != nil {
return config.VirtualizationKVM
@@ -32,6 +38,26 @@ func createByRuntime(cfg lxc.ContainerConfig) error {
return lxcManager.CreateContainer(cfg)
}
func validateCreateSSHAuth(cfg lxc.ContainerConfig) error {
if cfg.Virtualization == config.VirtualizationKVM && kvm.IsWindowsImage(cfg.TemplateID) {
return nil
}
_, err := lxc.ResolveCreateSSHAccess(cfg)
return err
}
func validateReinstallSSHAuth(c *config.Container, templateID string, cfg lxc.ContainerConfig) error {
if c != nil && c.IsKVM() && kvm.IsWindowsImage(templateID) {
return nil
}
currentPassword := ""
if c != nil {
currentPassword = c.SSHPassword
}
_, err := lxc.ResolveReinstallSSHAccess(currentPassword, cfg)
return err
}
func startByRuntime(id int) error {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
@@ -64,12 +90,12 @@ func destroyByRuntime(id int) error {
return lxcManager.DestroyContainer(id)
}
func reinstallByRuntime(id int, templateID string) error {
func reinstallByRuntime(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.ReinstallContainer(id, templateID)
return kvmManager.ReinstallContainer(id, templateID, authConfig...)
}
return lxcManager.ReinstallContainer(id, templateID)
return lxcManager.ReinstallContainer(id, templateID, authConfig...)
}
func resetPasswordByRuntime(id int, password string) (string, error) {
+38 -5
View File
@@ -180,6 +180,12 @@ func (ss *SecurityScanner) monitorLoop() {
}
}
func (ss *SecurityScanner) alertCount() int {
ss.mu.Lock()
defer ss.mu.Unlock()
return len(ss.alerts)
}
func (ss *SecurityScanner) checkAllContainers() {
for _, c := range config.AppConfig.Containers {
if c.Status != "running" || c.IP == "" {
@@ -208,6 +214,7 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
return
}
alertBefore := ss.alertCount()
ss.detectPortScans(name, ip, stats)
ss.detectBruteForce(name, ip, stats)
ss.detectSpam(name, ip, stats)
@@ -216,6 +223,11 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
ss.detectMining(name, ip, stats)
ss.detectProxyAndTor(name, ip, stats)
ss.detectMalware(name, ip, stats)
// If new alerts were generated, snapshot the conntrack data for later retrieval.
if ss.alertCount() > alertBefore {
config.SaveConntrackSnapshot(ip, lines)
}
}
func newTrafficStats() *trafficStats {
@@ -759,28 +771,49 @@ func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
func getConnectionLogs(ip string) []map[string]interface{} {
logs := make([]map[string]interface{}, 0)
seen := map[string]bool{}
for _, line := range readConntrackLines(ip) {
parseLine := func(line string) map[string]interface{} {
srcIP := extractField(line, "src=")
dstIP := extractField(line, "dst=")
srcPort := extractField(line, "sport=")
dstPort := extractField(line, "dport=")
sPort, _ := strconv.Atoi(srcPort)
dPort, _ := strconv.Atoi(dstPort)
logs = append(logs, map[string]interface{}{
return map[string]interface{}{
"src_ip": srcIP,
"dst_ip": dstIP,
"src_port": sPort,
"dst_port": dPort,
"protocol": extractProtocol(line),
"state": extractConnState(line),
})
}
}
// First, load stored snapshots from database (persisted at alert time).
for _, line := range config.GetConntrackSnapshotLines(ip) {
if len(logs) >= 100 {
break
}
key := strings.TrimSpace(line)
if key == "" || seen[key] {
continue
}
seen[key] = true
logs = append(logs, parseLine(line))
}
// Then, merge live conntrack data (deduplicated).
for _, line := range readConntrackLines(ip) {
if len(logs) >= 100 {
break
}
key := strings.TrimSpace(line)
if key == "" || seen[key] {
continue
}
seen[key] = true
logs = append(logs, parseLine(line))
}
return logs
+28
View File
@@ -20,6 +20,34 @@ type LoginLog struct {
var loginLogs = make([]LoginLog, 0)
// HandleLanguage returns or updates the global panel language.
func HandleLanguage(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"language": config.NormalizeLanguage(config.AppConfig.Language),
}})
case http.MethodPost, http.MethodPut:
var req struct {
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
config.AppConfig.Language = config.NormalizeLanguage(req.Language)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save language"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"language": config.AppConfig.Language,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// RecordLoginLog adds a login attempt to the log (persisted to config)
func RecordLoginLog(username, ip, userAgent string, success bool) {
config.AddLoginLog(username, ip, userAgent, success)
+37 -30
View File
@@ -17,7 +17,6 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -92,10 +91,12 @@ func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
if target == "" {
target = detectedRequestHost(r)
}
if target == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"})
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
target = normalizedTarget
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
if err != nil {
@@ -220,12 +221,10 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
certPath, keyPath, err := config.UploadedSSLPaths()
if err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "uploaded-fullchain.pem")
keyPath := filepath.Join(dir, "uploaded-privkey.pem")
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
return "", "", err
}
@@ -237,9 +236,11 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
func generateSelfSignedCertificate(target string) (string, string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("self-signed certificate target is required")
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
if err != nil {
return "", "", err
}
target = normalizedTarget
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", "", err
@@ -273,12 +274,10 @@ func generateSelfSignedCertificate(target string) (string, string, error) {
if err != nil {
return "", "", err
}
dir := sslStorageDir()
if err := os.MkdirAll(dir, 0700); err != nil {
certPath, keyPath, err := config.SelfSignedSSLPaths()
if err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "self-signed-fullchain.pem")
keyPath := filepath.Join(dir, "self-signed-privkey.pem")
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
@@ -295,9 +294,11 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
return "", "", fmt.Errorf("certbot is not installed on this server")
}
target = strings.TrimSpace(target)
if target == "" {
return "", "", fmt.Errorf("Let's Encrypt target is required")
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
if err != nil {
return "", "", err
}
target = normalizedTarget
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
if email != "" {
args = append(args, "--email", email)
@@ -317,12 +318,14 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
if err != nil {
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
}
certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem")
keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem")
if _, err := os.Stat(certPath); err != nil {
certPath, keyPath, err := config.LetsEncryptSSLPaths(target)
if err != nil {
return "", "", err
}
if _, err := config.ReadableFileStat(certPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
}
if _, err := os.Stat(keyPath); err != nil {
if _, err := config.ReadableFileStat(keyPath); err != nil {
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
}
return certPath, keyPath, nil
@@ -347,11 +350,19 @@ func ensureCertbotSupportsIPCertificates() error {
}
func validateCertificatePair(certPath, keyPath string) error {
certPEM, err := os.ReadFile(certPath)
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
return err
}
keyPEM, err := os.ReadFile(keyPath)
safeKeyPath, err := config.ResolveSSLPath(keyPath)
if err != nil {
return err
}
certPEM, err := os.ReadFile(safeCertPath)
if err != nil {
return err
}
keyPEM, err := os.ReadFile(safeKeyPath)
if err != nil {
return err
}
@@ -443,7 +454,11 @@ func readLeafCertificate(certPath string) (*x509.Certificate, error) {
if certPath == "" {
return nil, errors.New("certificate path is empty")
}
data, err := os.ReadFile(certPath)
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
return nil, err
}
data, err := os.ReadFile(safeCertPath)
if err != nil {
return nil, err
}
@@ -494,14 +509,6 @@ func firstPublicInterfaceIP() string {
return ""
}
func sslStorageDir() string {
dataDir := config.AppConfig.DataDir
if dataDir == "" {
dataDir = "/root/.clicd"
}
return filepath.Join(dataDir, "ssl")
}
func maskExistingPath(path string) string {
if path == "" {
return ""
+101 -8
View File
@@ -75,6 +75,10 @@ func (q *TaskQueue) enqueueTask(task *Task) {
}
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
return q.EnqueueWithAudit(containerID, containerName, taskType, templateID, cfg, "admin", "", "")
}
func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig, user string, ip string, userAgent string) []string {
q.mu.Lock()
defer q.mu.Unlock()
@@ -88,6 +92,9 @@ func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType Task
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
IP: ip,
UserAgent: userAgent,
}
if cfg != nil {
task.Config = *cfg
@@ -343,7 +350,11 @@ func (q *TaskQueue) opWorker() {
}
}
case TaskReinstall:
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
}
@@ -472,6 +483,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
var taskType TaskType
var templateID string
var taskConfig *lxc.ContainerConfig
switch action {
case "start":
taskType = TaskStart
@@ -483,7 +495,10 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
taskType = TaskDelete
case "reinstall":
var req struct {
TemplateID string `json:"template_id"`
TemplateID string `json:"template_id"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
}
json.NewDecoder(r.Body).Decode(&req)
templateID = req.TemplateID
@@ -501,13 +516,26 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
authCfg := lxc.ContainerConfig{
TemplateID: templateID,
SSHAuthMode: req.SSHAuthMode,
SSHPassword: req.SSHPassword,
SSHPublicKey: req.SSHPublicKey,
}
if lxc.HasSSHAuthOptions(authCfg) {
if err := validateReinstallSSHAuth(c, templateID, authCfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
taskConfig = &authCfg
}
taskType = TaskReinstall
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return
}
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
ids := globalQueue.EnqueueWithAudit(id, name, taskType, templateID, taskConfig, user, ip, userAgent)
jsonResponse(w, http.StatusAccepted, APIResponse{
Success: true,
Message: "Task queued",
@@ -575,8 +603,37 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
return
}
if req.Containers[i].PortMappingCount < 2 {
if req.Containers[i].PortMappingCount < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot be negative"})
return
}
if req.Containers[i].WantsNAT() && req.Containers[i].PortMappingCount < 2 {
req.Containers[i].PortMappingCount = 2
} else if !req.Containers[i].WantsNAT() {
req.Containers[i].PortMappingCount = 0
req.Containers[i].ExtraPorts = nil
}
if req.Containers[i].PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": port mapping count cannot exceed 64"})
return
}
if req.Containers[i].IPv4Count < 0 || req.Containers[i].IPv6Count < 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot be negative"})
return
}
if req.Containers[i].IPv4Count > 64 || req.Containers[i].IPv6Count > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": IP address count cannot exceed 64"})
return
}
if !req.Containers[i].AssignIPv4 && len(req.Containers[i].PublicIPv4s) == 0 {
req.Containers[i].IPv4Count = 0
}
if !req.Containers[i].AssignIPv6 && len(req.Containers[i].IPv6Addresses) == 0 {
req.Containers[i].IPv6Count = 0
}
if !hasRequestedNetwork(req.Containers[i]) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + noNetworkSelectedMessage})
return
}
if req.Containers[i].SnapshotLimit <= 0 {
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
@@ -585,6 +642,10 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
if err := validateCreateSSHAuth(req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
requestNames[name] = true
}
ids := globalQueue.EnqueueBatchCreateWithAudit(req.Containers, requestActor(r), clientIP(r), r.UserAgent())
@@ -602,9 +663,12 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
Action string `json:"action"`
Containers []int `json:"containers"`
TemplateID string `json:"template_id,omitempty"`
Action string `json:"action"`
Containers []int `json:"containers"`
TemplateID string `json:"template_id,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
@@ -613,6 +677,7 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
var taskType TaskType
var requiredScope string
var taskConfig *lxc.ContainerConfig
switch req.Action {
case "start":
taskType = TaskStart
@@ -635,6 +700,15 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
return
}
authCfg := lxc.ContainerConfig{
TemplateID: req.TemplateID,
SSHAuthMode: req.SSHAuthMode,
SSHPassword: req.SSHPassword,
SSHPublicKey: req.SSHPublicKey,
}
if lxc.HasSSHAuthOptions(authCfg) {
taskConfig = &authCfg
}
taskType = TaskReinstall
requiredScope = "container:reinstall"
default:
@@ -650,9 +724,28 @@ func HandleBatchAction(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to one or more containers"})
return
}
if taskConfig != nil {
if err := validateReinstallSSHAuth(c, req.TemplateID, *taskConfig); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: c.Name + ": " + err.Error()})
return
}
}
}
ids := globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
var ids []string
if taskConfig != nil {
for _, id := range req.Containers {
c := config.FindContainer(id)
name := ""
if c != nil {
name = c.Name
}
queued := globalQueue.EnqueueWithAudit(id, name, taskType, req.TemplateID, taskConfig, requestActor(r), clientIP(r), r.UserAgent())
ids = append(ids, queued...)
}
} else {
ids = globalQueue.EnqueueBatchWithAudit(taskType, req.Containers, req.TemplateID, requestActor(r), clientIP(r), r.UserAgent())
}
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
+3 -17
View File
@@ -1,10 +1,9 @@
package api
import (
"net"
"net/http"
"net/url"
"strings"
"clicd/internal/config"
"github.com/gorilla/websocket"
)
@@ -17,19 +16,6 @@ var upgrader = websocket.Upgrader{
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil {
return false
}
originHost := strings.ToLower(stripPort(originURL.Host))
requestHost := strings.ToLower(stripPort(r.Host))
return originHost != "" && originHost == requestHost
return config.IsOriginAllowed(origin, r.Host)
},
}
func stripPort(host string) string {
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return strings.Trim(host, "[]")
}
+395 -115
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -26,18 +27,170 @@ const (
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
)
var cliEnglish = detectCLIEnglish()
var cliTranslations = map[string]string{
"重新加载配置失败": "Failed to reload config",
"请选择操作": "Select an action",
"再见": "Goodbye",
"无效选择": "Invalid choice",
"CLICD - LXC 容器管理器": "CLICD - Container Manager",
"Web 面板": "Web panel",
"端口": "port",
"运行中": "running",
"已停止": "stopped",
"当前版本": "Current version",
"查看容器列表": "List containers",
"创建容器": "Create container",
"开机容器": "Start container",
"关机容器": "Stop container",
"重启容器": "Restart container",
"删除容器": "Delete container",
"重装容器系统": "Reinstall container OS",
"重置 Web 管理员密码": "Reset web admin password",
"启动": "Start",
"停止": "Stop",
"导入现有 LXC 容器": "Import existing LXC containers",
"检查并升级 CLICD": "Check and upgrade CLICD",
"卸载 CLICD": "Uninstall CLICD",
"系统信息": "System info",
"退出": "Exit",
"获取容器列表失败": "Failed to get container list",
"暂无容器": "No containers",
"容器": "Container",
"名称": "Name",
"状态": "Status",
"镜像": "Image",
"内存(MB)": "Memory(MB)",
"磁盘(GB)": "Disk(GB)",
"容器名称": "Container name",
"容器名称不能为空": "Container name cannot be empty",
"可用镜像": "Available images",
"镜像选择无效": "Invalid image selection",
"内存 (MB)": "Memory (MB)",
"磁盘 (GB)": "Disk (GB)",
"网络带宽 (Mbps)": "Network bandwidth (Mbps)",
"月流量 (GB)": "Monthly traffic (GB)",
"IO 速度 (MB/s)": "IO speed (MB/s)",
"额外 NAT 端口,多个用逗号分隔": "Extra NAT ports, comma-separated",
"正在创建容器": "Creating container",
"创建失败": "Create failed",
"创建成功": "created successfully",
"端口未分配": "port not assigned",
"密码已保存,请在 Web 面板中查看或重置": "Password saved. View or reset it in the web panel",
"开机失败": "Start failed",
"已开机": "started",
"关机失败": "Stop failed",
"已关机": "stopped",
"重启失败": "Restart failed",
"已重启": "restarted",
"开机": "start",
"关机": "stop",
"重启": "restart",
"删除": "delete",
"重装": "reinstall",
"确认删除容器": "Delete container",
"输入 yes 继续": "type yes to continue",
"已取消": "Cancelled",
"删除失败": "Delete failed",
"已删除": "deleted",
"确认重装容器": "Reinstall container",
"重装失败": "Reinstall failed",
"已重装": "reinstalled",
"新的管理员密码(至少 6 位)": "New admin password (at least 6 characters)",
"密码至少需要 6 位": "Password must be at least 6 characters",
"确认密码": "Confirm password",
"两次输入的密码不一致": "Passwords do not match",
"管理员密码已重置。": "Admin password has been reset.",
"按 Enter 返回菜单": "Press Enter to return to menu",
"选择要": "Select a container to ",
"的容器": "",
"选择无效": "Invalid selection",
"主机名": "Hostname",
"管理员用户": "Admin user",
"容器总数": "Total containers",
"切换语言": "Switch language",
"当前语言": "Current language",
"请选择语言": "Select language",
"语言已切换为": "Language switched to",
"保存语言失败": "Failed to save language",
"简体中文": "Simplified Chinese",
"重置失败": "Reset failed",
"停止 Web 面板失败": "Failed to stop web panel",
"Web 面板已停止,LXC 容器不会受影响。": "Web panel stopped. LXC containers are not affected.",
"启动 Web 面板失败": "Failed to start web panel",
"Web 面板已启动": "Web panel started",
"升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。": "The upgrade only replaces /usr/local/bin/clicd and keeps configuration, container data, and task records under /root/.clicd.",
"升级需要 root 权限。请使用: sudo clicd cli": "Upgrade requires root privileges. Use: sudo clicd cli",
"检查仓库": "Checking repository",
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
"最新版本": "Latest version",
"发布页面": "Release page",
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.",
"当前已经是最新版本。": "The current version is already the latest.",
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
"已取消。": "Cancelled.",
"升级失败": "Upgrade failed",
"升级完成": "Upgrade completed",
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
"GitHub API 返回": "GitHub API returned",
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
"GitHub releases/latest 返回": "GitHub releases/latest returned",
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
"正在下载升级包...": "Downloading upgrade package...",
"正在解压升级包...": "Extracting upgrade package...",
"解压失败": "Extraction failed",
"备份旧二进制失败": "Failed to back up old binary",
"旧版本已备份": "Old version backed up",
"正在替换二进制...": "Replacing binary...",
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
"下载失败,HTTP": "Download failed, HTTP",
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
"导入失败": "Import failed",
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
"已导入": "Imported",
"个容器": "containers",
"将删除 CLICD 服务和 /usr/local/bin/clicd。": "This will remove the CLICD service and /usr/local/bin/clicd.",
"同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。": "It will also remove /root/.clicd, /var/lib/lxc, /var/lib/clicd, image caches, backups, temporary files, /swapfile, and CLICD network rules.",
"卸载需要 root 权限。": "Uninstall requires root privileges.",
"请运行: sudo clicd cli --no-web": "Run: sudo clicd cli --no-web",
"输入 uninstall 继续卸载": "Type uninstall to continue uninstalling",
"CLICD 已卸载。": "CLICD has been uninstalled.",
"服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。": "Service, binary, configuration, containers/VMs, local images, cache, backups, temporary files, and CLICD network rules have been removed.",
"检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。": "Non-CLICD VMs are still using the libvirt default network, so default/virbr0 has been kept.",
"Web 面板重载跳过": "Web panel reload skipped",
"Web 面板已重载并应用配置变更。": "Web panel reloaded and configuration changes applied.",
"读取容器状态失败": "Failed to read container status",
"CLICD 版本": "CLICD version",
"Web 端口": "Web port",
"LXC 版本": "LXC version",
"暂无可用容器": "No available containers",
"忽略无效端口": "Ignoring invalid port",
"": "? ",
"。": ". ",
"": ", ",
"": ": ",
}
// Run starts the CLI interface.
func Run() {
reader := bufio.NewReader(os.Stdin)
for {
if _, err := config.InitConfig(); err != nil {
fmt.Printf("重新加载配置失败: %v\n", err)
cliPrintf("重新加载配置失败: %v\n", err)
waitEnter(reader)
}
refreshCLILanguage()
clearScreen()
printMenu()
fmt.Print("\n请选择操作 [1-12,0/q]: ")
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
@@ -94,11 +247,15 @@ func Run() {
clearScreen()
cliShowInfo()
waitEnter(reader)
case "l", "lang", "language":
clearScreen()
cliSwitchLanguage(reader)
waitEnter(reader)
case "q", "exit", "quit":
fmt.Println("再见")
cliPrintln("再见")
return
default:
fmt.Println("无效选择")
cliPrintln("无效选择")
}
}
}
@@ -108,49 +265,86 @@ func printMenu() {
if isWebPanelRunning() {
webStatus = "停止"
}
fmt.Println()
fmt.Println(" ==========================================")
fmt.Println(" CLICD - LXC 容器管理器")
fmt.Println(" ==========================================")
fmt.Println()
fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string {
cliPrintln("")
cliPrintln(" ==========================================")
cliPrintln(" CLICD - LXC 容器管理器")
cliPrintln(" ==========================================")
cliPrintln("")
cliPrintf(" Web 面板: %s (端口 %d)\n", func() string {
if isWebPanelRunning() {
return "运行中"
}
return "已停止"
}(), config.AppConfig.Port)
fmt.Printf(" 当前版本: %s\n", version.Current())
fmt.Println()
fmt.Println(" 1. 查看容器列表")
fmt.Println(" 2. 创建容器")
fmt.Println(" 3. 开机容器")
fmt.Println(" 4. 关机容器")
fmt.Println(" 5. 重启容器")
fmt.Println(" 6. 删除容器")
fmt.Println(" 7. 重装容器系统")
fmt.Println(" 8. 重置 Web 管理员密码")
fmt.Printf(" 9. %s Web 面板\n", webStatus)
fmt.Println(" 10. 导入现有 LXC 容器")
fmt.Println(" 11. 检查并升级 CLICD")
fmt.Println(" 12. 卸载 CLICD")
fmt.Println(" 0. 系统信息")
fmt.Println(" q. 退出")
cliPrintf(" 当前版本: %s\n", version.Current())
cliPrintln("")
cliPrintln(" 1. 查看容器列表")
cliPrintln(" 2. 创建容器")
cliPrintln(" 3. 开机容器")
cliPrintln(" 4. 关机容器")
cliPrintln(" 5. 重启容器")
cliPrintln(" 6. 删除容器")
cliPrintln(" 7. 重装容器系统")
cliPrintln(" 8. 重置 Web 管理员密码")
cliPrintf(" 9. %s Web 面板\n", webStatus)
cliPrintln(" 10. 导入现有 LXC 容器")
cliPrintln(" 11. 检查并升级 CLICD")
cliPrintln(" 12. 卸载 CLICD")
cliPrintln(" 0. 系统信息")
cliPrintln(" l. 切换语言")
cliPrintln(" q. 退出")
}
func cliSwitchLanguage(reader *bufio.Reader) {
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
cliPrintln(" 1. 简体中文")
cliPrintln(" 2. English")
choice := promptString(reader, "请选择语言 [1/2]", func() string {
if config.NormalizeLanguage(config.AppConfig.Language) == "en" {
return "2"
}
return "1"
}())
next := "zh"
switch strings.ToLower(strings.TrimSpace(choice)) {
case "2", "en", "english":
next = "en"
case "1", "zh", "cn", "chinese":
next = "zh"
default:
cliPrintln("无效选择")
return
}
config.AppConfig.Language = next
if err := config.SaveConfig(); err != nil {
cliPrintf("保存语言失败: %v\n", err)
return
}
_ = os.Setenv("CLICD_LANG", next)
refreshCLILanguage()
cliPrintf("%s: %s\n", cliT("语言已切换为"), cliLanguageLabel(next))
if isWebPanelRunning() {
restartWebPanelForConfigChange()
}
}
func cliListContainers() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("获取容器列表失败: %v\n", err)
cliPrintf("获取容器列表失败: %v\n", err)
return
}
if len(containers) == 0 {
fmt.Println("\n暂无容器")
cliPrintln("\n暂无容器")
return
}
fmt.Println()
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "名称", "状态", "镜像", "vCPU", "内存(MB)", "磁盘(GB)", "SSH")
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", cliT("名称"), cliT("状态"), cliT("镜像"), "vCPU", cliT("内存(MB)"), cliT("磁盘(GB)"), "SSH")
fmt.Println(strings.Repeat("-", 94))
for _, c := range containers {
ssh := "-"
@@ -163,23 +357,23 @@ func cliListContainers() {
}
func cliCreateContainer(reader *bufio.Reader) {
fmt.Println("\n--- 创建容器 ---")
cliPrintln("\n--- 创建容器 ---")
name := promptString(reader, "容器名称", "")
if name == "" {
fmt.Println("容器名称不能为空")
cliPrintln("容器名称不能为空")
return
}
templates := lxc.GetTemplates()
fmt.Println("\n可用镜像:")
cliPrintln("\n可用镜像:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("镜像选择无效")
cliPrintln("镜像选择无效")
return
}
@@ -195,16 +389,16 @@ func cliCreateContainer(reader *bufio.Reader) {
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
}
fmt.Printf("\n正在创建容器 %s ...\n", name)
cliPrintf("\n正在创建容器 %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil {
fmt.Printf("创建失败: %v\n", err)
cliPrintf("创建失败: %v\n", err)
return
}
container := config.FindContainerByName(name)
fmt.Printf("容器 %s 创建成功\n", name)
cliPrintf("容器 %s 创建成功\n", name)
if container != nil {
fmt.Print(formatSSHAccess(container.SSHPort))
cliPrint(formatSSHAccess(container.SSHPort))
}
restartWebPanelForConfigChange()
}
@@ -222,10 +416,10 @@ func cliStartContainer(reader *bufio.Reader) {
return
}
if err := manager.StartContainer(id); err != nil {
fmt.Printf("开机失败: %v\n", err)
cliPrintf("开机失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已开机\n", name)
cliPrintf("容器 %s 已开机\n", name)
}
func cliStopContainer(reader *bufio.Reader) {
@@ -234,10 +428,10 @@ func cliStopContainer(reader *bufio.Reader) {
return
}
if err := manager.StopContainer(id); err != nil {
fmt.Printf("关机失败: %v\n", err)
cliPrintf("关机失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已关机\n", name)
cliPrintf("容器 %s 已关机\n", name)
}
func cliRestartContainer(reader *bufio.Reader) {
@@ -246,10 +440,10 @@ func cliRestartContainer(reader *bufio.Reader) {
return
}
if err := manager.RestartContainer(id); err != nil {
fmt.Printf("重启失败: %v\n", err)
cliPrintf("重启失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已重启\n", name)
cliPrintf("容器 %s 已重启\n", name)
}
func cliDeleteContainer(reader *bufio.Reader) {
@@ -259,14 +453,14 @@ func cliDeleteContainer(reader *bufio.Reader) {
}
confirm := promptString(reader, fmt.Sprintf("确认删除容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("已取消")
cliPrintln("已取消")
return
}
if err := manager.DestroyContainer(id); err != nil {
fmt.Printf("删除失败: %v\n", err)
cliPrintf("删除失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已删除\n", name)
cliPrintf("容器 %s 已删除\n", name)
restartWebPanelForConfigChange()
}
@@ -277,66 +471,66 @@ func cliReinstallContainer(reader *bufio.Reader) {
}
templates := lxc.GetTemplates()
fmt.Println("\n可用镜像:")
cliPrintln("\n可用镜像:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("镜像选择无效")
cliPrintln("镜像选择无效")
return
}
confirm := promptString(reader, fmt.Sprintf("确认重装容器 %s?输入 yes 继续", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("已取消")
cliPrintln("已取消")
return
}
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
fmt.Printf("重装失败: %v\n", err)
cliPrintf("重装失败: %v\n", err)
return
}
fmt.Printf("容器 %s 已重装\n", name)
cliPrintf("容器 %s 已重装\n", name)
restartWebPanelForConfigChange()
}
func cliResetPassword(reader *bufio.Reader) {
newPass := promptString(reader, "新的管理员密码(至少 6 位)", "")
if len(newPass) < 6 {
fmt.Println("密码至少需要 6 位")
cliPrintln("密码至少需要 6 位")
return
}
confirm := promptString(reader, "确认密码", "")
if newPass != confirm {
fmt.Println("两次输入的密码不一致")
cliPrintln("两次输入的密码不一致")
return
}
if err := config.ResetAdminPassword(newPass); err != nil {
fmt.Printf("重置失败: %v\n", err)
cliPrintf("重置失败: %v\n", err)
return
}
fmt.Println("管理员密码已重置。")
cliPrintln("管理员密码已重置。")
restartWebPanelForConfigChange()
}
func cliToggleWebPanel() {
if isWebPanelRunning() {
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 面板失败: %v\n", err)
cliPrintf("停止 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
cliPrintln("Web 面板已停止,LXC 容器不会受影响。")
return
}
if err := startService("clicd"); err != nil {
fmt.Printf("启动 Web 面板失败: %v\n", err)
cliPrintf("启动 Web 面板失败: %v\n", err)
return
}
fmt.Println("Web 面板已启动")
cliPrintln("Web 面板已启动")
}
type githubRelease struct {
@@ -350,11 +544,11 @@ type githubRelease struct {
}
func cliUpgradeSystem(reader *bufio.Reader) {
fmt.Println("\n--- 检查并升级 CLICD ---")
fmt.Println("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
cliPrintln("\n--- 检查并升级 CLICD ---")
cliPrintln("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
if os.Geteuid() != 0 {
fmt.Println("升级需要 root 权限。请使用: sudo clicd cli")
cliPrintln("升级需要 root 权限。请使用: sudo clicd cli")
return
}
@@ -363,51 +557,51 @@ func cliUpgradeSystem(reader *bufio.Reader) {
repo = version.Repo
}
current := version.Current()
fmt.Printf("当前版本: %s\n", current)
fmt.Printf("检查仓库: https://github.com/%s\n", repo)
cliPrintf("当前版本: %s\n", current)
cliPrintf("检查仓库: https://github.com/%s\n", repo)
release, err := fetchLatestRelease(repo)
if err != nil {
fmt.Printf("检查 GitHub 最新版本失败: %v\n", err)
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
return
}
latest := strings.TrimSpace(release.TagName)
if latest == "" {
fmt.Println("GitHub Release 没有 tag_name,无法判断最新版本。")
cliPrintln("GitHub Release 没有 tag_name,无法判断最新版本。")
return
}
fmt.Printf("最新版本: %s\n", latest)
cliPrintf("最新版本: %s\n", latest)
if release.HTMLURL != "" {
fmt.Printf("发布页面: %s\n", release.HTMLURL)
cliPrintf("发布页面: %s\n", release.HTMLURL)
}
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
if assetURL == "" {
fmt.Println("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
return
}
if sameVersion(current, latest) {
fmt.Println("当前已经是最新版本。")
cliPrintln("当前已经是最新版本。")
confirm := promptString(reader, "是否仍然重新安装最新版本?输入 reinstall 继续", "no")
if strings.ToLower(confirm) != "reinstall" {
fmt.Println("已取消。")
cliPrintln("已取消。")
return
}
} else {
confirm := promptString(reader, "输入 upgrade 开始升级", "no")
if strings.ToLower(confirm) != "upgrade" {
fmt.Println("已取消。")
cliPrintln("已取消。")
return
}
}
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
fmt.Printf("升级失败: %v\n", err)
cliPrintf("升级失败: %v\n", err)
return
}
fmt.Printf("升级完成: %s -> %s\n", current, latest)
fmt.Println("原有数据已保留,Web 服务已重启。")
cliPrintf("升级完成: %s -> %s\n", current, latest)
cliPrintln("原有数据已保留,Web 服务已重启。")
}
func fetchLatestRelease(repo string) (*githubRelease, error) {
@@ -434,9 +628,9 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
fmt.Println("GitHub API 被限流,已切换到备用检查方式。")
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
} else {
fmt.Println("GitHub API 不可用,已切换到备用检查方式。")
cliPrintln("GitHub API 不可用,已切换到备用检查方式。")
}
return fallback, nil
}
@@ -530,12 +724,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
fmt.Println("正在下载升级包...")
cliPrintln("正在下载升级包...")
if err := downloadFile(assetURL, archivePath); err != nil {
return err
}
fmt.Println("正在解压升级包...")
cliPrintln("正在解压升级包...")
if out, err := exec.Command("tar", "-xzf", archivePath, "-C", tmpDir).CombinedOutput(); err != nil {
return fmt.Errorf("解压失败: %v, output: %s", err, string(out))
}
@@ -555,12 +749,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
if err != nil {
return fmt.Errorf("备份旧二进制失败: %w", err)
}
fmt.Printf("旧版本已备份: %s\n", backupPath)
cliPrintf("旧版本已备份: %s\n", backupPath)
}
fmt.Println("正在替换二进制...")
cliPrintln("正在替换二进制...")
if err := stopService("clicd"); err != nil {
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
cliPrintf("停止 Web 服务失败,继续尝试替换: %v\n", err)
}
tmpBin := clicdNewBinaryPath
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
@@ -715,21 +909,21 @@ func isWebPanelRunning() bool {
}
func cliImportExistingContainers() {
fmt.Println("\n--- 导入现有 LXC 容器 ---")
fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
cliPrintln("\n--- 导入现有 LXC 容器 ---")
cliPrintln("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
cliPrintln("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
imported, err := manager.ImportExistingClicdContainers()
if err != nil {
fmt.Printf("导入失败: %v\n", err)
cliPrintf("导入失败: %v\n", err)
return
}
if len(imported) == 0 {
fmt.Println("没有发现新的 ct-* 容器。")
cliPrintln("没有发现新的 ct-* 容器。")
return
}
fmt.Printf("已导入 %d 个容器:\n", len(imported))
cliPrintf("已导入 %d 个容器:\n", len(imported))
for _, c := range imported {
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
}
@@ -737,19 +931,19 @@ func cliImportExistingContainers() {
}
func cliUninstall(reader *bufio.Reader) {
fmt.Println("\n--- 卸载 CLICD ---")
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
cliPrintln("\n--- 卸载 CLICD ---")
cliPrintln("将删除 CLICD 服务和 /usr/local/bin/clicd。")
cliPrintln("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
if os.Geteuid() != 0 {
fmt.Println("卸载需要 root 权限。")
fmt.Println("请运行: sudo clicd cli --no-web")
cliPrintln("卸载需要 root 权限。")
cliPrintln("请运行: sudo clicd cli --no-web")
return
}
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
if strings.ToLower(confirm) != "uninstall" {
fmt.Println("已取消")
cliPrintln("已取消")
return
}
@@ -776,8 +970,8 @@ func cliUninstall(reader *bufio.Reader) {
reloadSysctl()
fmt.Println()
fmt.Println("CLICD 已卸载。")
fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
cliPrintln("CLICD 已卸载。")
cliPrintln("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
}
func destroyAllLXCContainers() {
@@ -847,7 +1041,7 @@ func removeCLICDLibvirtDefaultNetwork() {
return
}
if libvirtDefaultUsedByNonCLICDDomain() {
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
cliPrintln("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
return
}
fmt.Println("Removing CLICD-created libvirt default network...")
@@ -1242,10 +1436,10 @@ func shellQuote(value string) string {
func restartWebPanelForConfigChange() {
if err := restartService("clicd"); err != nil {
fmt.Printf("Web 面板重载跳过: %v\n", err)
cliPrintf("Web 面板重载跳过: %v\n", err)
return
}
fmt.Println("Web 面板已重载并应用配置变更。")
cliPrintln("Web 面板已重载并应用配置变更。")
}
func stopService(name string) error {
@@ -1281,7 +1475,7 @@ func restartService(name string) error {
func cliShowInfo() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("读取容器状态失败: %v\n", err)
cliPrintf("读取容器状态失败: %v\n", err)
}
total := len(containers)
@@ -1292,44 +1486,44 @@ func cliShowInfo() {
}
}
fmt.Println("\n--- 系统信息 ---")
fmt.Printf("CLICD 版本: %s\n", version.Current())
fmt.Printf("Web 端口: %d\n", config.AppConfig.Port)
fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser)
fmt.Printf("容器总数: %d\n", total)
fmt.Printf("运行中: %d\n", running)
fmt.Printf("已停止: %d\n", total-running)
cliPrintln("\n--- 系统信息 ---")
cliPrintf("CLICD 版本: %s\n", version.Current())
cliPrintf("Web 端口: %d\n", config.AppConfig.Port)
cliPrintf("管理员用户: %s\n", config.AppConfig.AdminUser)
cliPrintf("容器总数: %d\n", total)
cliPrintf("运行中: %d\n", running)
cliPrintf("已停止: %d\n", total-running)
if hostname, err := os.Hostname(); err == nil {
fmt.Printf("主机名: %s\n", hostname)
cliPrintf("主机名: %s\n", hostname)
}
cmd := exec.Command("lxc-info", "--version")
output, err := cmd.Output()
if err == nil {
fmt.Printf("LXC 版本: %s", string(output))
cliPrintf("LXC 版本: %s", string(output))
}
}
func selectContainer(reader *bufio.Reader, action string) (int, string) {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("获取容器列表失败: %v\n", err)
cliPrintf("获取容器列表失败: %v\n", err)
return 0, ""
}
if len(containers) == 0 {
fmt.Println("暂无可用容器")
cliPrintln("暂无可用容器")
return 0, ""
}
fmt.Printf("\n--- 选择要%s的容器 ---\n", action)
cliPrintf("\n--- 选择要%s的容器 ---\n", cliT(action))
for i, container := range containers {
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status)
}
idx := promptInt(reader, "容器", 0)
if idx < 1 || idx > len(containers) {
fmt.Println("选择无效")
cliPrintln("选择无效")
return 0, ""
}
@@ -1338,6 +1532,7 @@ func selectContainer(reader *bufio.Reader, action string) (int, string) {
}
func promptString(reader *bufio.Reader, label string, fallback string) string {
label = cliT(label)
if fallback == "" {
fmt.Printf("%s: ", label)
} else {
@@ -1375,7 +1570,7 @@ func clearScreen() {
}
func waitEnter(reader *bufio.Reader) {
fmt.Print("\n按 Enter 返回菜单...")
cliPrint("\n按 Enter 返回菜单...")
reader.ReadString('\n')
}
@@ -1389,10 +1584,95 @@ func promptPortList(reader *bufio.Reader, label string) []int {
for _, part := range strings.Split(input, ",") {
value, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || value <= 0 || value > 65535 {
fmt.Printf("忽略无效端口: %s\n", strings.TrimSpace(part))
cliPrintf("忽略无效端口: %s\n", strings.TrimSpace(part))
continue
}
ports = append(ports, value)
}
return ports
}
func detectCLIEnglish() bool {
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
return true
}
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
return false
}
if config.AppConfig != nil {
return config.NormalizeLanguage(config.AppConfig.Language) == "en"
}
env := strings.ToLower(os.Getenv("LC_ALL") + " " + os.Getenv("LC_MESSAGES") + " " + os.Getenv("LANG"))
return strings.Contains(env, "en_") || strings.Contains(env, "en-") || strings.Contains(env, "english")
}
func refreshCLILanguage() {
cliEnglish = detectCLIEnglish()
}
func cliLanguageLabel(language string) string {
if config.NormalizeLanguage(language) == "en" {
return "English"
}
return cliT("简体中文")
}
func cliT(text string) string {
if !cliEnglish {
return text
}
translated := text
keys := make([]string, 0, len(cliTranslations))
for zh := range cliTranslations {
keys = append(keys, zh)
}
sort.Slice(keys, func(i, j int) bool {
if len(keys[i]) == len(keys[j]) {
return keys[i] < keys[j]
}
return len(keys[i]) > len(keys[j])
})
for _, zh := range keys {
en := cliTranslations[zh]
translated = strings.ReplaceAll(translated, zh, en)
}
return translated
}
func cliPrint(args ...interface{}) {
if cliEnglish {
for i, arg := range args {
if s, ok := arg.(string); ok {
args[i] = cliT(s)
}
}
}
fmt.Print(args...)
}
func cliPrintln(args ...interface{}) {
if cliEnglish {
for i, arg := range args {
if s, ok := arg.(string); ok {
args[i] = cliT(s)
}
}
}
fmt.Println(args...)
}
func cliPrintf(format string, args ...interface{}) {
if cliEnglish {
for i, arg := range args {
if s, ok := arg.(string); ok {
args[i] = cliT(s)
continue
}
if err, ok := arg.(error); ok {
args[i] = cliT(err.Error())
}
}
}
fmt.Printf(cliT(format), args...)
}
+254 -80
View File
@@ -17,10 +17,32 @@ import (
type PortMapping struct {
ContainerPort int `json:"container_port"`
HostPort int `json:"host_port"`
HostIP string `json:"host_ip,omitempty"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
type PublicIPv4Assignment struct {
Address string `json:"address"`
Interface string `json:"interface,omitempty"`
PrefixLen int `json:"prefix_len,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
type IPv6Assignment struct {
Address string `json:"address"`
PrefixLen int `json:"prefix_len"`
Interface string `json:"interface,omitempty"`
}
type PublicIPv6Prefix struct {
Address string `json:"address"`
Prefix string `json:"prefix,omitempty"`
PrefixLen int `json:"prefix_len"`
Interface string `json:"interface,omitempty"`
Gateway string `json:"gateway,omitempty"`
}
// SavedTask for persisting task queue across restarts
type SavedTask struct {
ID string `json:"id"`
@@ -68,50 +90,52 @@ type VMReadinessCheck struct {
// Container represents an LXC container configuration
type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
SSHHostKey string `json:"ssh_host_key,omitempty"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
PolicyBlocked bool `json:"policy_blocked"`
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
LXCName string `json:"lxc_name,omitempty"`
KVMName string `json:"kvm_name,omitempty"`
DiskImage string `json:"disk_image,omitempty"`
MACAddress string `json:"mac_address,omitempty"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
IPv6Addresses []IPv6Assignment `json:"ipv6_addresses,omitempty"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
SSHHostKey string `json:"ssh_host_key,omitempty"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
SnapshotLimit int `json:"snapshot_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
SnapshotScheduleEnabled bool `json:"snapshot_schedule_enabled"`
SnapshotScheduleIntervalHours int `json:"snapshot_schedule_interval_hours"`
SnapshotScheduleTime string `json:"snapshot_schedule_time"`
SnapshotScheduleLastRun string `json:"snapshot_schedule_last_run"`
SnapshotScheduleNextRun string `json:"snapshot_schedule_next_run"`
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
PolicyBlocked bool `json:"policy_blocked"`
PolicyBlockedReason string `json:"policy_blocked_reason,omitempty"`
PolicyBlockedAt string `json:"policy_blocked_at,omitempty"`
}
const (
@@ -136,6 +160,101 @@ func (c *Container) IsKVM() bool {
return c.Runtime() == VirtualizationKVM
}
func (c *Container) NormalizeNetworkAssignments() bool {
changed := false
seenIPv4 := map[string]bool{}
filteredIPv4 := make([]PublicIPv4Assignment, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s {
item.Address = strings.TrimSpace(item.Address)
item.Interface = strings.TrimSpace(item.Interface)
item.Gateway = strings.TrimSpace(item.Gateway)
if item.Address == "" || seenIPv4[item.Address] {
if item.Address != "" {
changed = true
}
continue
}
seenIPv4[item.Address] = true
filteredIPv4 = append(filteredIPv4, item)
}
if len(filteredIPv4) != len(c.PublicIPv4s) {
changed = true
}
c.PublicIPv4s = filteredIPv4
seenIPv6 := map[string]bool{}
filteredIPv6 := make([]IPv6Assignment, 0, len(c.IPv6Addresses)+1)
for _, item := range c.IPv6Addresses {
item.Address = strings.TrimSpace(item.Address)
item.Interface = strings.TrimSpace(item.Interface)
if item.Address == "" || seenIPv6[item.Address] {
if item.Address != "" {
changed = true
}
continue
}
seenIPv6[item.Address] = true
filteredIPv6 = append(filteredIPv6, item)
}
if strings.TrimSpace(c.IPv6) != "" && !seenIPv6[c.IPv6] {
filteredIPv6 = append([]IPv6Assignment{{
Address: c.IPv6,
PrefixLen: c.IPv6PrefixLen,
Interface: c.IPv6Interface,
}}, filteredIPv6...)
changed = true
}
if len(filteredIPv6) != len(c.IPv6Addresses) {
changed = true
}
c.IPv6Addresses = filteredIPv6
if len(c.IPv6Addresses) > 0 {
first := c.IPv6Addresses[0]
if c.IPv6 != first.Address || c.IPv6PrefixLen != first.PrefixLen || c.IPv6Interface != first.Interface {
c.IPv6 = first.Address
c.IPv6PrefixLen = first.PrefixLen
c.IPv6Interface = first.Interface
changed = true
}
} else if c.IPv6 != "" || c.IPv6PrefixLen != 0 || c.IPv6Interface != "" {
c.IPv6 = ""
c.IPv6PrefixLen = 0
c.IPv6Interface = ""
changed = true
}
return changed
}
func (c *Container) PublicIPv4Addresses() []string {
values := make([]string, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s {
if item.Address != "" {
values = append(values, item.Address)
}
}
return values
}
func (c *Container) PrimaryPublicIPv4() string {
if len(c.PublicIPv4s) == 0 {
return ""
}
return c.PublicIPv4s[0].Address
}
func (c *Container) IPv6AddressStrings() []string {
values := make([]string, 0, len(c.IPv6Addresses))
for _, item := range c.IPv6Addresses {
if item.Address != "" {
values = append(values, item.Address)
}
}
if len(values) == 0 && c.IPv6 != "" {
values = append(values, c.IPv6)
}
return values
}
// LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string {
if c.LXCName != "" {
@@ -225,26 +344,30 @@ type SSLConfig struct {
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
Snapshots []Snapshot `json:"snapshots"`
PublicIPv4Pool []PublicIPv4Assignment `json:"public_ipv4_pool"`
PublicIPv6Prefixes []PublicIPv6Prefix `json:"public_ipv6_prefixes"`
WebSSHAllowedOrigins []string `json:"webssh_allowed_origins"`
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
Language string `json:"language"`
SSL SSLConfig `json:"ssl"`
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
}
var configPath string
@@ -358,21 +481,24 @@ func InitConfig() (*ClicdConfig, error) {
}
AppConfig = &ClicdConfig{
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Snapshots: []Snapshot{},
PublicIPv4Pool: []PublicIPv4Assignment{},
PublicIPv6Prefixes: []PublicIPv6Prefix{},
WebSSHAllowedOrigins: []string{},
}
if err := SaveConfig(); err != nil {
@@ -423,6 +549,21 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.Snapshots = make([]Snapshot, 0)
changed = true
}
if AppConfig.PublicIPv4Pool == nil {
AppConfig.PublicIPv4Pool = make([]PublicIPv4Assignment, 0)
changed = true
}
if AppConfig.PublicIPv6Prefixes == nil {
AppConfig.PublicIPv6Prefixes = make([]PublicIPv6Prefix, 0)
changed = true
}
if AppConfig.WebSSHAllowedOrigins == nil {
AppConfig.WebSSHAllowedOrigins = make([]string, 0)
changed = true
} else if normalized, err := NormalizeAllowedOrigins(AppConfig.WebSSHAllowedOrigins); err == nil && strings.Join(normalized, "\n") != strings.Join(AppConfig.WebSSHAllowedOrigins, "\n") {
AppConfig.WebSSHAllowedOrigins = normalized
changed = true
}
if AppConfig.SubUsers == nil {
AppConfig.SubUsers = make([]SubUser, 0)
changed = true
@@ -454,12 +595,29 @@ func normalizeConfigDefaults(dataDir string) bool {
AppConfig.EnabledImages = make([]string, 0)
changed = true
}
if AppConfig.Language == "" {
AppConfig.Language = "zh"
changed = true
}
if AppConfig.Language != "zh" && AppConfig.Language != "en" {
AppConfig.Language = "zh"
changed = true
}
if normalizeSSLDefaults() {
changed = true
}
return changed
}
func NormalizeLanguage(language string) string {
switch strings.ToLower(strings.TrimSpace(language)) {
case "en", "en-us", "en_us", "english":
return "en"
default:
return "zh"
}
}
func normalizeSSLDefaults() bool {
changed := false
previousMode := AppConfig.SSL.Mode
@@ -528,6 +686,9 @@ func migrateLoadedConfig() bool {
if ensureContainerSnapshotLimits() {
changed = true
}
if ensureContainerNetworkAssignments() {
changed = true
}
if ensureContainerSnapshotScheduleDefaults() {
changed = true
}
@@ -590,13 +751,16 @@ func ensureContainerUUIDs() bool {
func ensureContainerPortMappingLimits() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].PortMappingLimit <= 0 {
if AppConfig.Containers[i].PortMappingLimit < 0 {
limit := len(AppConfig.Containers[i].PortMappings)
if limit < 2 {
limit = 2
}
AppConfig.Containers[i].PortMappingLimit = limit
changed = true
} else if AppConfig.Containers[i].PortMappingLimit == 0 && len(AppConfig.Containers[i].PortMappings) > 0 {
AppConfig.Containers[i].PortMappingLimit = len(AppConfig.Containers[i].PortMappings)
changed = true
}
}
return changed
@@ -613,6 +777,16 @@ func ensureContainerSnapshotLimits() bool {
return changed
}
func ensureContainerNetworkAssignments() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].NormalizeNetworkAssignments() {
changed = true
}
}
return changed
}
func migrateSubUsers() bool {
changed := false
for i := range AppConfig.SubUsers {
+136
View File
@@ -0,0 +1,136 @@
package config
import (
"fmt"
"net"
"net/url"
"strings"
)
// NormalizeAllowedOrigin accepts a browser Origin value such as
// https://www.example.com and returns a canonical form for exact matching.
func NormalizeAllowedOrigin(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
u, err := url.Parse(value)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("Origin must include scheme and host: %s", value)
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("Origin scheme must be http or https: %s", value)
}
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("Origin must not include path, query, or fragment: %s", value)
}
host := normalizeOriginHostPort(u.Host, scheme)
if host == "" {
return "", fmt.Errorf("Origin host is required: %s", value)
}
return scheme + "://" + host, nil
}
func NormalizeAllowedOrigins(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
origin, err := NormalizeAllowedOrigin(value)
if err != nil {
return nil, err
}
if origin == "" || seen[origin] {
continue
}
seen[origin] = true
result = append(result, origin)
}
return result, nil
}
func IsOriginAllowed(origin string, requestHost string) bool {
origin = strings.TrimSpace(origin)
if origin == "" {
return true
}
if isSameRequestOrigin(origin, requestHost) {
return true
}
normalized, err := NormalizeAllowedOrigin(origin)
if err != nil {
return false
}
if AppConfig == nil {
return false
}
for _, allowed := range AppConfig.WebSSHAllowedOrigins {
allowed, err := NormalizeAllowedOrigin(allowed)
if err == nil && normalized == allowed {
return true
}
}
return false
}
func isSameRequestOrigin(origin string, requestHost string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
originHost := normalizeHostOnly(u.Hostname())
host := normalizeHostOnly(requestHost)
if originHost == "" || host == "" {
return false
}
if originHost == host {
return true
}
return isLoopbackHost(originHost) && isLoopbackHost(host)
}
func normalizeOriginHostPort(raw string, scheme string) string {
host := raw
port := ""
if h, p, err := net.SplitHostPort(raw); err == nil {
host = h
port = p
}
host = normalizeHostOnly(host)
if host == "" {
return ""
}
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
port = ""
}
if port != "" {
return net.JoinHostPort(host, port)
}
if strings.Contains(host, ":") && net.ParseIP(host) != nil {
return "[" + host + "]"
}
return host
}
func normalizeHostOnly(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if h, _, err := net.SplitHostPort(raw); err == nil {
raw = h
}
raw = strings.Trim(raw, "[]")
if ip := net.ParseIP(raw); ip != nil {
return strings.ToLower(ip.String())
}
return strings.TrimSuffix(strings.ToLower(raw), ".")
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
+175
View File
@@ -0,0 +1,175 @@
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strings"
)
const letsEncryptLiveDir = "/etc/letsencrypt/live"
var dnsNamePattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
func SSLStorageDir() string {
dataDir := ""
if AppConfig != nil {
dataDir = AppConfig.DataDir
}
if dataDir == "" {
dataDir = getDataDir()
}
return filepath.Join(dataDir, "ssl")
}
func UploadedSSLPaths() (string, string, error) {
dir, err := safeSSLStorageDir()
if err != nil {
return "", "", err
}
return filepath.Join(dir, "uploaded-fullchain.pem"), filepath.Join(dir, "uploaded-privkey.pem"), nil
}
func SelfSignedSSLPaths() (string, string, error) {
dir, err := safeSSLStorageDir()
if err != nil {
return "", "", err
}
return filepath.Join(dir, "self-signed-fullchain.pem"), filepath.Join(dir, "self-signed-privkey.pem"), nil
}
func LetsEncryptSSLPaths(target string) (string, string, error) {
name, err := NormalizeSSLCertificateTarget(target)
if err != nil {
return "", "", err
}
base := filepath.Join(letsEncryptLiveDir, name)
return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem"), nil
}
func ResolveSSLConfigPaths(ssl SSLConfig) (string, string, error) {
mode := NormalizeSSLMode(ssl.Mode)
switch mode {
case SSLModeUploaded:
if ssl.CertPath != "" && ssl.KeyPath != "" {
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
}
return UploadedSSLPaths()
case SSLModeSelfSigned:
if ssl.CertPath != "" && ssl.KeyPath != "" {
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
}
return SelfSignedSSLPaths()
case SSLModeLetsEncrypt:
if strings.TrimSpace(ssl.Target) == "" && ssl.CertPath != "" && ssl.KeyPath != "" {
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
}
return LetsEncryptSSLPaths(ssl.Target)
default:
return "", "", fmt.Errorf("SSL is disabled")
}
}
func ResolveSSLPathPair(certPath, keyPath string) (string, string, error) {
safeCertPath, err := ResolveSSLPath(certPath)
if err != nil {
return "", "", err
}
safeKeyPath, err := ResolveSSLPath(keyPath)
if err != nil {
return "", "", err
}
return safeCertPath, safeKeyPath, nil
}
func ResolveSSLPath(path string) (string, error) {
cleaned, err := cleanAbsolutePath(path)
if err != nil {
return "", err
}
if isPathUnder(cleaned, SSLStorageDir()) || isPathUnder(cleaned, letsEncryptLiveDir) || isPathUnder(cleaned, "/etc/letsencrypt/archive") {
return cleaned, nil
}
return "", fmt.Errorf("SSL path is outside allowed certificate directories")
}
func ReadableFileStat(path string) (os.FileInfo, error) {
safePath, err := ResolveSSLPath(path)
if err != nil {
return nil, err
}
return os.Stat(safePath)
}
func NormalizeSSLCertificateTarget(target string) (string, error) {
target = strings.TrimSpace(strings.Trim(target, "[]"))
if target == "" {
return "", fmt.Errorf("SSL target is required")
}
if strings.Contains(target, "/") || strings.Contains(target, "\\") || strings.Contains(target, "..") {
return "", fmt.Errorf("SSL target contains invalid path characters")
}
if ip := net.ParseIP(target); ip != nil {
return ip.String(), nil
}
if len(target) > 253 || !dnsNamePattern.MatchString(target) {
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
}
labels := strings.Split(target, ".")
for _, label := range labels {
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
}
}
return strings.ToLower(target), nil
}
func safeSSLStorageDir() (string, error) {
dir, err := cleanAbsolutePath(SSLStorageDir())
if err != nil {
return "", err
}
dataDir := ""
if AppConfig != nil {
dataDir = AppConfig.DataDir
}
if dataDir == "" {
dataDir = getDataDir()
}
if !isPathUnder(dir, dataDir) {
return "", fmt.Errorf("SSL storage directory is outside the data directory")
}
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
return dir, nil
}
func cleanAbsolutePath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("path is empty")
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
func isPathUnder(path, root string) bool {
cleanPath, err := cleanAbsolutePath(path)
if err != nil {
return false
}
cleanRoot, err := cleanAbsolutePath(root)
if err != nil {
return false
}
rel, err := filepath.Rel(cleanRoot, cleanPath)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
+262 -33
View File
@@ -20,24 +20,33 @@ var (
)
type savedTaskConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"`
TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"`
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"`
TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"`
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
}
func parseSavedTaskConfig(raw string) savedTaskConfig {
@@ -175,10 +184,28 @@ func ensureSchema() error {
position INTEGER NOT NULL,
container_port INTEGER NOT NULL,
host_port INTEGER NOT NULL,
host_ip TEXT,
protocol TEXT,
description TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS container_public_ipv4s (
container_id INTEGER NOT NULL,
position INTEGER NOT NULL,
address TEXT NOT NULL,
interface TEXT,
prefix_len INTEGER,
gateway TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS container_ipv6_addresses (
container_id INTEGER NOT NULL,
position INTEGER NOT NULL,
address TEXT NOT NULL,
prefix_len INTEGER,
interface TEXT,
PRIMARY KEY (container_id, position)
)`,
`CREATE TABLE IF NOT EXISTS sub_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -227,6 +254,14 @@ func ensureSchema() error {
success INTEGER,
error TEXT
)`,
`CREATE TABLE IF NOT EXISTS security_conntrack_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
container_ip TEXT NOT NULL,
line TEXT NOT NULL,
captured_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_conntrack_snapshots_ip_time
ON security_conntrack_snapshots(container_ip, captured_at)`,
`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
type TEXT,
@@ -253,8 +288,17 @@ func ensureSchema() error {
cfg_traffic_out_gb INTEGER,
cfg_io_speed_mbps INTEGER,
cfg_port_mapping_count INTEGER,
cfg_assign_nat INTEGER,
cfg_snapshot_limit INTEGER,
cfg_assign_ipv4 INTEGER,
cfg_ipv4_count INTEGER,
cfg_public_ipv4s TEXT,
cfg_assign_ipv6 INTEGER,
cfg_ipv6_count INTEGER,
cfg_ipv6_addresses TEXT,
cfg_ssh_auth_mode TEXT,
cfg_ssh_password TEXT,
cfg_ssh_public_key TEXT,
cfg_expires_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS task_extra_ports (
@@ -308,6 +352,18 @@ func ensureSchemaMigrations() error {
{"api_keys", "last_used_ip", "TEXT"},
{"tasks", "ip", "TEXT"},
{"tasks", "user_agent", "TEXT"},
{"tasks", "cfg_assign_ipv4", "INTEGER"},
{"tasks", "cfg_ipv4_count", "INTEGER"},
{"tasks", "cfg_public_ipv4s", "TEXT"},
{"tasks", "cfg_assign_nat", "INTEGER"},
{"tasks", "cfg_ipv6_count", "INTEGER"},
{"tasks", "cfg_ipv6_addresses", "TEXT"},
{"tasks", "cfg_ssh_auth_mode", "TEXT"},
{"tasks", "cfg_ssh_password", "TEXT"},
{"tasks", "cfg_ssh_public_key", "TEXT"},
{"port_mappings", "host_ip", "TEXT"},
{"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"},
} {
if err := ensureColumn(column.table, column.name, column.def); err != nil {
return err
@@ -373,6 +429,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
NextSSHPort: atoi(meta["next_ssh_port"]),
SetupComplete: atob(meta["setup_complete"]),
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
Language: meta["language"],
}
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
@@ -380,6 +437,15 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates)
}
if raw := strings.TrimSpace(meta["public_ipv4_pool"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv4Pool)
}
if raw := strings.TrimSpace(meta["public_ipv6_prefixes"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.PublicIPv6Prefixes)
}
if raw := strings.TrimSpace(meta["webssh_allowed_origins"]); raw != "" {
_ = json.Unmarshal([]byte(raw), &cfg.WebSSHAllowedOrigins)
}
if cfg.Containers, err = loadContainers(); err != nil {
return nil, false, err
@@ -423,6 +489,8 @@ func saveConfigToDB() error {
for _, table := range []string{
"port_mappings",
"container_public_ipv4s",
"container_ipv6_addresses",
"sub_user_container_names",
"sub_user_container_uuids",
"containers",
@@ -474,6 +542,9 @@ func saveConfigToDB() error {
func saveMeta(tx *sql.Tx) error {
sslJSON, _ := json.Marshal(AppConfig.SSL)
sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates)
publicIPv4PoolJSON, _ := json.Marshal(AppConfig.PublicIPv4Pool)
publicIPv6PrefixesJSON, _ := json.Marshal(AppConfig.PublicIPv6Prefixes)
webSSHAllowedOriginsJSON, _ := json.Marshal(AppConfig.WebSSHAllowedOrigins)
values := map[string]string{
"admin_user": AppConfig.AdminUser,
"admin_pass_hash": AppConfig.AdminPassHash,
@@ -485,8 +556,12 @@ func saveMeta(tx *sql.Tx) error {
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
"setup_complete": btoa(AppConfig.SetupComplete),
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
"language": NormalizeLanguage(AppConfig.Language),
"ssl": string(sslJSON),
"ssl_certificates": string(sslCertificatesJSON),
"public_ipv4_pool": string(publicIPv4PoolJSON),
"public_ipv6_prefixes": string(publicIPv6PrefixesJSON),
"webssh_allowed_origins": string(webSSHAllowedOriginsJSON),
"schema_version": "1",
"updated_at": time.Now().Format("2006-01-02 15:04:05"),
}
@@ -522,8 +597,20 @@ func saveContainers(tx *sql.Tx) error {
return err
}
for i, pm := range c.PortMappings {
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, protocol, description)
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.Protocol, pm.Description); err != nil {
if _, err := tx.Exec(`INSERT INTO port_mappings(container_id, position, container_port, host_port, host_ip, protocol, description)
VALUES (?, ?, ?, ?, ?, ?, ?)`, c.ID, i, pm.ContainerPort, pm.HostPort, pm.HostIP, pm.Protocol, pm.Description); err != nil {
return err
}
}
for i, ip := range c.PublicIPv4s {
if _, err := tx.Exec(`INSERT INTO container_public_ipv4s(container_id, position, address, interface, prefix_len, gateway)
VALUES (?, ?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.Interface, ip.PrefixLen, ip.Gateway); err != nil {
return err
}
}
for i, ip := range c.IPv6Addresses {
if _, err := tx.Exec(`INSERT INTO container_ipv6_addresses(container_id, position, address, prefix_len, interface)
VALUES (?, ?, ?, ?, ?)`, c.ID, i, ip.Address, ip.PrefixLen, ip.Interface); err != nil {
return err
}
}
@@ -563,6 +650,59 @@ func saveAPIKeys(tx *sql.Tx) error {
return nil
}
// SaveConntrackSnapshot stores raw conntrack lines for a container IP.
func SaveConntrackSnapshot(containerIP string, lines []string) {
if db == nil || len(lines) == 0 || strings.TrimSpace(containerIP) == "" {
return
}
now := time.Now().Format("2006-01-02 15:04:05")
tx, err := db.Begin()
if err != nil {
return
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO security_conntrack_snapshots (container_ip, line, captured_at) VALUES (?, ?, ?)`)
if err != nil {
return
}
defer stmt.Close()
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
stmt.Exec(containerIP, line, now)
}
tx.Commit()
// Cleanup old snapshots (>1 hour)
db.Exec(`DELETE FROM security_conntrack_snapshots WHERE captured_at < ?`,
time.Now().Add(-1*time.Hour).Format("2006-01-02 15:04:05"))
}
// GetConntrackSnapshotLines returns stored conntrack lines for a container IP.
func GetConntrackSnapshotLines(containerIP string) []string {
if db == nil || strings.TrimSpace(containerIP) == "" {
return nil
}
rows, err := db.Query(
`SELECT line FROM security_conntrack_snapshots WHERE container_ip = ? ORDER BY captured_at DESC LIMIT 200`,
containerIP,
)
if err != nil {
return nil
}
defer rows.Close()
var lines []string
for rows.Next() {
var line string
if rows.Scan(&line) == nil {
lines = append(lines, line)
}
}
return lines
}
func saveAuditLogs(tx *sql.Tx) error {
for _, log := range AppConfig.AuditLogs {
successSet := 0
@@ -588,14 +728,17 @@ func saveTasksDB(tx *sql.Tx) error {
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, cfg.SnapshotLimit,
boolInt(cfg.AssignIPv6), cfg.ExpiresAt,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
); err != nil {
return err
}
@@ -684,12 +827,21 @@ func loadContainers() ([]Container, error) {
if err != nil {
return nil, err
}
result[i].PublicIPv4s, err = loadContainerPublicIPv4s(result[i].ID)
if err != nil {
return nil, err
}
result[i].IPv6Addresses, err = loadContainerIPv6Addresses(result[i].ID)
if err != nil {
return nil, err
}
result[i].NormalizeNetworkAssignments()
}
return result, nil
}
func loadPortMappings(containerID int) ([]PortMapping, error) {
rows, err := db.Query(`SELECT container_port, host_port, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
rows, err := db.Query(`SELECT container_port, host_port, host_ip, protocol, description FROM port_mappings WHERE container_id = ? ORDER BY position`, containerID)
if err != nil {
return nil, err
}
@@ -697,14 +849,64 @@ func loadPortMappings(containerID int) ([]PortMapping, error) {
result := []PortMapping{}
for rows.Next() {
var pm PortMapping
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &pm.Protocol, &pm.Description); err != nil {
var hostIP sql.NullString
if err := rows.Scan(&pm.ContainerPort, &pm.HostPort, &hostIP, &pm.Protocol, &pm.Description); err != nil {
return nil, err
}
pm.HostIP = hostIP.String
result = append(result, pm)
}
return result, rows.Err()
}
func loadContainerPublicIPv4s(containerID int) ([]PublicIPv4Assignment, error) {
rows, err := db.Query(`SELECT address, interface, prefix_len, gateway FROM container_public_ipv4s WHERE container_id = ? ORDER BY position`, containerID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []PublicIPv4Assignment{}
for rows.Next() {
var item PublicIPv4Assignment
var iface sql.NullString
var prefixLen sql.NullInt64
var gateway sql.NullString
if err := rows.Scan(&item.Address, &iface, &prefixLen, &gateway); err != nil {
return nil, err
}
item.Interface = iface.String
if prefixLen.Valid {
item.PrefixLen = int(prefixLen.Int64)
}
item.Gateway = gateway.String
result = append(result, item)
}
return result, rows.Err()
}
func loadContainerIPv6Addresses(containerID int) ([]IPv6Assignment, error) {
rows, err := db.Query(`SELECT address, prefix_len, interface FROM container_ipv6_addresses WHERE container_id = ? ORDER BY position`, containerID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []IPv6Assignment{}
for rows.Next() {
var item IPv6Assignment
var prefixLen sql.NullInt64
var iface sql.NullString
if err := rows.Scan(&item.Address, &prefixLen, &iface); err != nil {
return nil, err
}
if prefixLen.Valid {
item.PrefixLen = int(prefixLen.Int64)
}
item.Interface = iface.String
result = append(result, item)
}
return result, rows.Err()
}
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`)
if err != nil {
@@ -806,8 +1008,9 @@ func loadTasks() ([]SavedTask, error) {
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_snapshot_limit,
cfg_assign_ipv6, cfg_expires_at
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
FROM tasks ORDER BY created_at, id`)
if err != nil {
return nil, err
@@ -818,20 +1021,39 @@ func loadTasks() ([]SavedTask, error) {
for rows.Next() {
var t SavedTask
var cfg savedTaskConfig
var assignIPv6 int
var ip, userAgent sql.NullString
var assignIPv4, assignIPv6 int
var ip, userAgent, publicIPv4s, ipv6Addresses sql.NullString
var sshAuthMode, sshPassword, sshPublicKey sql.NullString
var assignNAT, ipv4Count, ipv6Count sql.NullInt64
if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &cfg.SnapshotLimit,
&assignIPv6, &cfg.ExpiresAt,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
); err != nil {
return nil, err
}
t.IP = ip.String
t.UserAgent = userAgent.String
if assignNAT.Valid {
value := assignNAT.Int64 != 0
cfg.AssignNAT = &value
}
cfg.AssignIPv4 = assignIPv4 != 0
if ipv4Count.Valid {
cfg.IPv4Count = int(ipv4Count.Int64)
}
cfg.PublicIPv4s = decodeStringSlice(publicIPv4s.String)
cfg.AssignIPv6 = assignIPv6 != 0
if ipv6Count.Valid {
cfg.IPv6Count = int(ipv6Count.Int64)
}
cfg.IPv6Addresses = decodeStringSlice(ipv6Addresses.String)
cfg.SSHAuthMode = sshAuthMode.String
cfg.SSHPassword = sshPassword.String
cfg.SSHPublicKey = sshPublicKey.String
result = append(result, t)
configs = append(configs, cfg)
}
@@ -945,6 +1167,13 @@ func boolInt(value bool) int {
return 0
}
func boolPtrInt(value *bool) interface{} {
if value == nil {
return nil
}
return boolInt(*value)
}
func btoa(value bool) string {
if value {
return "1"
+452 -103
View File
@@ -364,8 +364,11 @@ func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
if cfg.VCPU < 1 || cfg.VCPU != float64(int(cfg.VCPU)) {
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
}
if cfg.PortMappingCount < 2 {
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
@@ -403,18 +406,33 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
seedPath := filepath.Join(m.instanceDir(vmName), "seed.iso")
mac := randomMAC()
sshPassword := generateRandomString(16)
ipv6 := ""
ipv6PrefixLen := 0
ipv6Interface := ""
if cfg.AssignIPv6 {
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
sshPublicKey := ""
sshAuthMode := ""
if !IsWindowsImage(image.ID) {
sshAccess, err := lxc.ResolveCreateSSHAccess(cfg)
if err != nil {
return nil, err
}
ipv6 = assigned
ipv6PrefixLen = prefixLen
ipv6Interface = iface
sshPassword = sshAccess.Password
sshPublicKey = sshAccess.PublicKey
sshAuthMode = sshAccess.Mode
}
publicIPv4s, err := lxc.AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
return nil, err
}
ipv6Assignments := []config.IPv6Assignment{}
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
if err != nil {
return nil, err
}
ipv6Assignments = assigned
}
ipv6List := configIPv6AssignmentAddresses(ipv6Assignments)
ipv4List := configIPv4AssignmentAddresses(publicIPv4s)
defaultHostIP := lxc.DefaultPortMappingHostIP(publicIPv4s)
var xml string
winAdminPassword := ""
@@ -433,7 +451,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
}
winAdminPassword = generateWindowsPassword()
unattendPath := filepath.Join(m.instanceDir(vmName), "unattend.iso")
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6); err != nil {
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil {
return nil, err
}
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps)
@@ -449,7 +467,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := createOverlayDisk(ImagePath(image.ID), diskPath, cfg.DiskGB); err != nil {
return nil, err
}
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, mac, ipv6, *image); err != nil {
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
return nil, err
}
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "")
@@ -465,20 +483,26 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
sshPort := 0
portMappings := []config.PortMapping{}
if allocatePorts {
if allocatePorts && cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
if IsWindowsImage(image.ID) {
// Windows: RDP (3389) instead of SSH (22)
portMappings = []config.PortMapping{{
ContainerPort: 3389,
HostPort: sshPort,
HostIP: defaultHostIP,
Protocol: "tcp",
Description: "RDP",
}}
} else {
portMappings = lxc.SetupDefaultPortMappings(sshPort)
if defaultHostIP != "" {
for i := range portMappings {
portMappings[i].HostIP = defaultHostIP
}
}
}
tempC := &config.Container{PortMappings: portMappings}
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
extraPorts := cfg.ExtraPorts
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
@@ -490,6 +514,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
tempC.PortMappings = append(tempC.PortMappings, config.PortMapping{
ContainerPort: port,
HostPort: port,
HostIP: defaultHostIP,
Protocol: "tcp",
Description: fmt.Sprintf("Port-%d", port),
})
@@ -502,7 +527,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if trafficMode == "" {
trafficMode = "total"
}
return &config.Container{
container := &config.Container{
ID: id,
UUID: config.NewContainerUUID(),
Name: cfg.Name,
@@ -521,9 +546,8 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: now[:7],
IOSpeedMBps: cfg.IOSpeedMBps,
IPv6: ipv6,
IPv6PrefixLen: ipv6PrefixLen,
IPv6Interface: ipv6Interface,
PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments,
Status: "stopped",
SSHPort: sshPort,
SSHPassword: func() string {
@@ -537,7 +561,9 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
SnapshotLimit: config.NormalizeSnapshotLimit(cfg.SnapshotLimit),
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}, nil
}
container.NormalizeNetworkAssignments()
return container, nil
}
func (m *Manager) StartContainer(id int) error {
@@ -548,6 +574,7 @@ func (m *Manager) StartContainer(id int) error {
if err := m.validateHost(IsWindowsImage(c.Template)); err != nil {
return err
}
lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s)
name := c.VirshName()
if err := m.ensureDomainDefinition(c); err != nil {
fmt.Printf("Warning: failed to refresh KVM domain definition for %s: %v\n", name, err)
@@ -561,7 +588,7 @@ func (m *Manager) StartContainer(id int) error {
}
}
}
config.UpdateContainerStatus(id, "running")
config.UpdateContainerStatus(id, "initializing")
// Detect VNC port
if _, err := m.RefreshVNCPort(id); err != nil {
fmt.Printf("Warning: failed to refresh VNC port for %s: %v\n", name, err)
@@ -598,7 +625,12 @@ func (m *Manager) StartContainer(id int) error {
return err
}
}
if c.IPv6 != "" {
// Wait for cloud-init to finish and SSH to be reachable (password-only mode)
if !isWindows && c.IP != "" {
m.waitForCloudInitReady(name, c.IP, c.SSHPassword)
}
config.UpdateContainerStatus(id, "running")
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
return err
}
@@ -608,6 +640,50 @@ func (m *Manager) StartContainer(id int) error {
return nil
}
// waitForCloudInitReady waits for cloud-init to finish and SSH to be reachable.
func (m *Manager) waitForCloudInitReady(vmName, ip, password string) {
if ip == "" || password == "" {
return
}
deadline := time.Now().Add(3 * time.Minute)
target := net.JoinHostPort(ip, "22")
sshWasUp := false
qgaAttempted := false
for time.Now().Before(deadline) {
client, err := ssh.Dial("tcp", target, &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{ssh.Password(password)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
})
if err == nil {
client.Close()
if !sshWasUp {
sshWasUp = true
fmt.Printf("KVM %s SSH up, waiting for cloud-init to settle...\n", vmName)
time.Sleep(10 * time.Second)
continue
}
fmt.Printf("KVM %s ready\n", vmName)
return
}
// Try guest agent ONCE to speed things up, with timeout to avoid blocking
if !qgaAttempted && qemuGuestPing(vmName) == nil {
qgaAttempted = true
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
cmd := exec.CommandContext(ctx, "virsh", "qemu-agent-command", vmName,
`{"execute":"guest-exec","arguments":{"path":"/bin/sh","arg":["-c","cloud-init status --wait 2>/dev/null; systemctl restart sshd 2>/dev/null || systemctl restart ssh 2>/dev/null; true"],"capture-output":false}}`)
cmd.Run()
cancel()
}
time.Sleep(5 * time.Second)
}
fmt.Printf("Warning: KVM %s not ready after 3 minutes\n", vmName)
}
func (m *Manager) StopContainer(id int) error {
c := config.FindContainer(id)
if c == nil {
@@ -662,7 +738,7 @@ func (m *Manager) DestroyContainer(id int) error {
return nil
}
func (m *Manager) ReinstallContainer(id int, templateID string) error {
func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lxc.ContainerConfig) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
@@ -694,6 +770,19 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
SnapshotLimit: c.SnapshotLimit,
ExpiresAt: c.ExpiresAt,
}
if len(authConfig) > 0 && lxc.HasSSHAuthOptions(authConfig[0]) && !IsWindowsImage(templateID) {
sshAccess, err := lxc.ResolveReinstallSSHAccess(c.SSHPassword, authConfig[0])
if err != nil {
return err
}
if sshAccess.PublicKey != "" {
cfg.SSHAuthMode = lxc.SSHAuthKey
cfg.SSHPublicKey = sshAccess.PublicKey
} else {
cfg.SSHAuthMode = lxc.SSHAuthPassword
}
cfg.SSHPassword = sshAccess.Password
}
next, err := m.defineContainer(id, name, cfg, false)
if err != nil {
return err
@@ -1352,7 +1441,7 @@ func (m *Manager) RefreshNetwork(id int) (string, error) {
if changed {
config.SaveConfig()
}
if c.Status == "running" && len(c.PortMappings) > 0 && shouldApplyPortMappings(id, changed) {
if c.Status == "running" && shouldApplyPortMappings(id, changed) {
if err := lxc.NewManager().ApplyPortMappings(id); err != nil {
return ip, err
}
@@ -1559,7 +1648,7 @@ func createEmptyDisk(target string, diskGB int) error {
return nil
}
func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) error {
func createWindowsUnattendISO(target, hostname, adminPassword string, ipv6s []string, ipv4s []string) error {
tool := firstAvailableCommand("genisoimage", "mkisofs", "xorriso")
if tool == "" {
return fmt.Errorf("one of genisoimage, mkisofs, xorriso is required for Windows unattended setup")
@@ -1585,13 +1674,13 @@ func createWindowsUnattendISO(target, hostname, adminPassword, ipv6 string) erro
if err := os.WriteFile(filepath.Join(setupScriptsDir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(clicdDir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "SetupComplete.cmd"), []byte(windowsSetupCompleteCMD()), 0600); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6)), 0600); err != nil {
if err := os.WriteFile(filepath.Join(dir, "FirstLogon.ps1"), []byte(windowsFirstLogonPowerShell(adminPassword, ipv6s, ipv4s)), 0600); err != nil {
return err
}
_ = os.Remove(target)
@@ -1701,7 +1790,7 @@ exit /b 0
`
}
func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
func windowsFirstLogonPowerShell(adminPassword string, ipv6s []string, ipv4s []string) string {
commands := []string{
"$ErrorActionPreference='Continue'",
"$ProgressPreference='SilentlyContinue'",
@@ -1731,9 +1820,16 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
"Get-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic",
"Start-Service QEMU-GA,qemu-ga -ErrorAction SilentlyContinue",
}
if strings.TrimSpace(ipv6) != "" {
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) > 0 {
commands = append(commands,
windowsIPv6PowerShell(strings.TrimSpace(ipv6)),
windowsIPv6PowerShell(ipv6s),
)
}
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) > 0 {
commands = append(commands,
windowsIPv4PowerShell(ipv4s),
)
}
commands = append(commands,
@@ -1743,17 +1839,24 @@ func windowsFirstLogonPowerShell(adminPassword, ipv6 string) string {
return strings.Join(commands, "\r\n") + "\r\n"
}
func windowsIPv6PowerShell(ipv6 string) string {
ipv6 = strings.TrimSpace(ipv6)
if ipv6 == "" {
func windowsIPv6PowerShell(ipv6s []string) string {
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv6s))
for _, ipv6 := range ipv6s {
quoted = append(quoted, "'"+strings.ReplaceAll(ipv6, "'", "''")+"'")
}
return strings.Join([]string{
"$clicdIPv6=@(" + strings.Join(quoted, ",") + ")",
"$iface=$null",
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
"if ($iface) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq '" + ipv6 + "' } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetIPAddress -IPAddress '" + ipv6 + "' -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
" foreach ($ip in $clicdIPv6) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetIPAddress -IPAddress $ip -PrefixLength 128 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
" }",
" Get-NetRoute -InterfaceIndex $iface.ifIndex -DestinationPrefix '::/0' -ErrorAction SilentlyContinue | Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex $iface.ifIndex -NextHop '" + ipv6GatewayLinkLocal + "' -RouteMetric 100 -ErrorAction SilentlyContinue | Out-Null",
" Set-DnsClientServerAddress -InterfaceIndex $iface.ifIndex -ServerAddresses @('2001:4860:4860::8888','2606:4700:4700::1111') -ErrorAction SilentlyContinue",
@@ -1761,17 +1864,61 @@ func windowsIPv6PowerShell(ipv6 string) string {
}, "\r\n")
}
func windowsIPv4PowerShell(ipv4s []string) string {
ipv4s = normalizeKVMIPv4List(ipv4s)
if len(ipv4s) == 0 {
return ""
}
quoted := make([]string, 0, len(ipv4s))
for _, ipv4 := range ipv4s {
quoted = append(quoted, "'"+strings.ReplaceAll(ipv4, "'", "''")+"'")
}
return strings.Join([]string{
"$clicdIPv4=@(" + strings.Join(quoted, ",") + ")",
"$iface=$null",
"for ($i=0; $i -lt 60 -and -not $iface; $i++) { $iface=Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface } | Sort-Object ifIndex | Select-Object -First 1; if (-not $iface) { Start-Sleep -Seconds 5 } }",
"if ($iface) {",
" foreach ($ip in $clicdIPv4) {",
" Get-NetIPAddress -InterfaceIndex $iface.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $ip } | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue",
" New-NetIPAddress -IPAddress $ip -PrefixLength 32 -InterfaceIndex $iface.ifIndex -SkipAsSource:$false -ErrorAction SilentlyContinue | Out-Null",
" }",
"}",
}, "\r\n")
}
func normalizeKVMIPv4List(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func shellQuoteWindows(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
}
func createSeedISO(seedPath, instanceID, hostname, password, mac, ipv6 string, image Image) error {
guestSetup := kvmSSHSetupScript(password)
func createSeedISO(seedPath, instanceID, hostname, password, publicKey, mac string, ipv6s []string, ipv4s []string, image Image, sshAuthMode string) error {
disablePubkey := sshAuthMode == "password" || sshAuthMode == "auto_password"
guestSetup := kvmSSHSetupScript(password, disablePubkey, publicKey)
if desktopSetup := kvmDesktopSetupScript(image); desktopSetup != "" {
guestSetup += "\n" + desktopSetup
}
if strings.TrimSpace(ipv6) != "" {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6)
ipv6s = normalizeKVMIPv6List(ipv6s)
if len(ipv6s) > 0 {
guestSetup += "\n" + kvmIPv6SetupScript(ipv6s)
}
authorizedKeys := ""
if publicKey != "" {
authorizedKeys = fmt.Sprintf(`
ssh_authorized_keys:
- %s`, yamlSingleQuote(publicKey))
}
setupScript := indentScript(guestSetup, 4)
userData := fmt.Sprintf(`#cloud-config
@@ -1788,31 +1935,46 @@ chpasswd:
type: text
users:
- name: root
lock_passwd: false
lock_passwd: false%s
runcmd:
- |
%s
`, hostname, password, setupScript)
`, hostname, password, authorizedKeys, setupScript)
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", instanceID, hostname)
ipv6Block := ""
if strings.TrimSpace(ipv6) != "" {
ipv6Block = fmt.Sprintf(`
addresses:
- %s/128
// Build static address block (IPv4 + IPv6)
ipv4s = normalizeKVMIPv4List(ipv4s)
addressBlock := ""
addressLines := make([]string, 0, len(ipv4s)+len(ipv6s))
for _, ipv4 := range ipv4s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/32", ipv4))
}
for _, ipv6 := range ipv6s {
addressLines = append(addressLines, fmt.Sprintf(" - %s/128", ipv6))
}
if len(addressLines) > 0 {
addressBlock = fmt.Sprintf("\n addresses:\n%s", strings.Join(addressLines, "\n"))
}
// IPv6 routes (only needed when IPv6 addresses are configured)
ipv6RouteBlock := ""
if len(ipv6s) > 0 {
ipv6RouteBlock = fmt.Sprintf(`
routes:
- to: default
via: %s
on-link: true
metric: 100`, ipv6, ipv6GatewayLinkLocal)
metric: 100`, ipv6GatewayLinkLocal)
}
networkConfig := fmt.Sprintf(`version: 2
ethernets:
nic0:
match:
macaddress: "%s"
dhcp4: true
dhcp6: false%s
`, strings.ToLower(mac), ipv6Block)
dhcp6: false%s%s
`, strings.ToLower(mac), addressBlock, ipv6RouteBlock)
dir := filepath.Dir(seedPath)
userPath := filepath.Join(dir, "user-data")
metaPath := filepath.Join(dir, "meta-data")
@@ -1834,6 +1996,52 @@ ethernets:
return nil
}
func configIPv6AssignmentAddresses(assignments []config.IPv6Assignment) []string {
values := make([]string, 0, len(assignments))
for _, item := range assignments {
if strings.TrimSpace(item.Address) != "" {
values = append(values, strings.TrimSpace(item.Address))
}
}
return values
}
func configIPv4AssignmentAddresses(assignments []config.PublicIPv4Assignment) []string {
values := make([]string, 0, len(assignments))
for _, item := range assignments {
if strings.TrimSpace(item.Address) != "" {
values = append(values, strings.TrimSpace(item.Address))
}
}
return values
}
func normalizeKVMIPv6List(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func shellQuotedKVMIPv6List(values []string) string {
values = normalizeKVMIPv6List(values)
if len(values) == 0 {
return "''"
}
quoted := make([]string, 0, len(values))
for _, value := range values {
quoted = append(quoted, shellQuote(value))
}
return strings.Join(quoted, " ")
}
func indentScript(script string, spaces int) string {
prefix := strings.Repeat(" ", spaces)
lines := strings.Split(strings.TrimRight(script, "\n"), "\n")
@@ -1843,6 +2051,10 @@ func indentScript(script string, spaces int) string {
return strings.Join(lines, "\n")
}
func yamlSingleQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func isKVMDesktopTemplate(templateID string) bool {
image := FindImage(templateID)
return image != nil && image.Desktop != ""
@@ -2272,11 +2484,11 @@ func runKVMGuestAgentSSHSetup(name string, password string) error {
if err := qemuGuestPing(name); err != nil {
return err
}
return qemuGuestExec(name, kvmSSHSetupScript(password), 180*time.Second)
return qemuGuestExec(name, kvmSSHSetupScript(password, false), 180*time.Second)
}
func runKVMSSHSetup(client *ssh.Client, password string) error {
return runKVMSSHScript(client, kvmSSHSetupScript(password), "KVM SSH", 150*time.Second)
return runKVMSSHScript(client, kvmSSHSetupScript(password, false), "KVM SSH", 150*time.Second)
}
func runKVMSSHScript(client *ssh.Client, script string, description string, timeout time.Duration) error {
@@ -2289,7 +2501,8 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
var output []byte
go func() {
var err error
output, err = session.CombinedOutput(script)
session.Stdin = strings.NewReader(script)
output, err = session.CombinedOutput("sh -s")
done <- err
}()
select {
@@ -2304,9 +2517,18 @@ func runKVMSSHScript(client *ssh.Client, script string, description string, time
}
}
func kvmSSHSetupScript(password string) string {
return `set -u
func kvmSSHSetupScript(password string, disablePubkeyAuth bool, publicKeys ...string) string {
publicKey := ""
if len(publicKeys) > 0 {
publicKey = strings.TrimSpace(publicKeys[0])
}
pubkeyValue := "yes"
if disablePubkeyAuth {
pubkeyValue = "no"
}
script := `set -u
ROOT_PASSWORD=` + shellQuote(password) + `
SSH_PUBLIC_KEY=` + shellQuote(publicKey) + `
export DEBIAN_FRONTEND=noninteractive
if command -v apt-get >/dev/null 2>&1; then
if ! command -v sshd >/dev/null 2>&1 || ! command -v qemu-ga >/dev/null 2>&1; then
@@ -2333,6 +2555,7 @@ fi
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-clicd-root.conf <<'EOF'
PermitRootLogin yes
PubkeyAuthentication __CLICD_PUBKEY_AUTH__
PasswordAuthentication yes
KbdInteractiveAuthentication yes
ChallengeResponseAuthentication yes
@@ -2340,13 +2563,26 @@ EOF
if [ -f /etc/ssh/sshd_config ]; then
grep -q '^PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || printf '\nPermitRootLogin yes\n' >> /etc/ssh/sshd_config
grep -q '^#PermitRootLogin ' /etc/ssh/sshd_config && sed -i 's/^#PermitRootLogin .*/PermitRootLogin yes/' /etc/ssh/sshd_config || true
grep -q '^PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || printf '\nPubkeyAuthentication __CLICD_PUBKEY_AUTH__\n' >> /etc/ssh/sshd_config
grep -q '^#PubkeyAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PubkeyAuthentication .*/PubkeyAuthentication __CLICD_PUBKEY_AUTH__/' /etc/ssh/sshd_config || true
grep -q '^PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || printf '\nPasswordAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#PasswordAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config || true
grep -q '^KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || printf '\nKbdInteractiveAuthentication yes\n' >> /etc/ssh/sshd_config
grep -q '^#KbdInteractiveAuthentication ' /etc/ssh/sshd_config && sed -i 's/^#KbdInteractiveAuthentication .*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config || true
fi
if [ -n "$SSH_PUBLIC_KEY" ]; then
mkdir -p /root/.ssh
touch /root/.ssh/authorized_keys
grep -qxF "$SSH_PUBLIC_KEY" /root/.ssh/authorized_keys 2>/dev/null || printf '%s\n' "$SSH_PUBLIC_KEY" >> /root/.ssh/authorized_keys
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys
chown -R root:root /root/.ssh 2>/dev/null || true
fi
if command -v chpasswd >/dev/null 2>&1; then
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd || true
printf 'root:%s\n' "$ROOT_PASSWORD" | chpasswd 2>/tmp/clicd-chpasswd.log && echo "root password set via chpasswd" || echo "WARNING: chpasswd failed: $(cat /tmp/clicd-chpasswd.log 2>/dev/null)"
elif command -v openssl >/dev/null 2>&1 && command -v usermod >/dev/null 2>&1; then
HASH=$(echo "$ROOT_PASSWORD" | openssl passwd -6 -stdin 2>/dev/null)
[ -n "$HASH" ] && usermod -p "$HASH" root 2>/dev/null && echo "root password set via openssl/usermod" || echo "WARNING: openssl/usermod failed"
fi
ssh-keygen -A >/dev/null 2>&1 || true
if command -v systemctl >/dev/null 2>&1; then
@@ -2372,6 +2608,8 @@ if [ -w /dev/tty1 ]; then
printf '\nCLICD VNC console is ready. Press Enter for login prompt.\n' >/dev/tty1 || true
fi
`
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
return script
}
func kvmDesktopSetupScript(image Image) string {
@@ -2575,7 +2813,7 @@ func (m *Manager) syncRunningNetworks() {
} else if err != nil {
fmt.Printf("Warning: failed to sync KVM network for %s: %v\n", c.Name, err)
}
if c.IPv6 != "" {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
fmt.Printf("Warning: failed to sync KVM IPv6 for %s: %v\n", c.Name, err)
}
@@ -2589,7 +2827,7 @@ func (m *Manager) applyIPv6Guards() {
if !c.IsKVM() || c.MACAddress == "" {
continue
}
if c.IPv6 == "" {
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
ensureKVMIPv6DenyRule("virbr0", c.MACAddress)
continue
}
@@ -2935,13 +3173,12 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
}
if c.IPv6 == "" {
addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
if err != nil {
return nil, err
}
c.IPv6 = addr
c.IPv6PrefixLen = prefixLen
c.IPv6Interface = iface
c.IPv6Addresses = append(c.IPv6Addresses, assignments...)
c.NormalizeNetworkAssignments()
config.SaveConfig()
}
if err := m.applyIPv6Runtime(c); err != nil {
@@ -2951,9 +3188,10 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
}
func (m *Manager) applyIPv6Runtime(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
if err := m.applyIPv6HostRuntime(c); err != nil {
return err
}
@@ -2964,7 +3202,13 @@ func (m *Manager) applyIPv6Runtime(c *config.Container) error {
}
}
}
ensureKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
ensureKVMIPv6NAT66(assignment.Address, uplink)
}
return nil
}
@@ -2980,9 +3224,10 @@ func shouldLogIPv6GuestWarning(id int) bool {
}
func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
if c.IPv6Interface == "" {
prefixes := lxc.DetectPublicIPv6Prefixes()
if len(prefixes) == 0 {
@@ -2990,6 +3235,14 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
}
c.IPv6Interface = prefixes[0].Interface
c.IPv6PrefixLen = prefixes[0].PrefixLen
for i := range c.IPv6Addresses {
if c.IPv6Addresses[i].Interface == "" {
c.IPv6Addresses[i].Interface = c.IPv6Interface
}
if c.IPv6Addresses[i].PrefixLen == 0 {
c.IPv6Addresses[i].PrefixLen = c.IPv6PrefixLen
}
}
config.SaveConfig()
}
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
@@ -3001,14 +3254,20 @@ func (m *Manager) applyIPv6HostRuntime(c *config.Container) error {
runQuiet("sysctl", "-w", "net.ipv6.conf."+bridge+".proxy_ndp=1")
runQuiet("ip", "link", "set", bridge, "up")
runQuiet("ip", "-6", "addr", "replace", ipv6GatewayLinkLocal+"/64", "dev", bridge)
if out, err := exec.Command("ip", "-6", "route", "replace", c.IPv6+"/128", "dev", bridge).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
if out, err := exec.Command("ip", "-6", "route", "replace", assignment.Address+"/128", "dev", bridge).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 VM route: %v, output: %s", err, string(out))
}
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", assignment.Address, "dev", uplink).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
}
ensureKVMIPv6ForwardRules(assignment.Address, bridge)
ensureKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
}
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", c.IPv6, "dev", c.IPv6Interface).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
}
ensureKVMIPv6ForwardRules(c.IPv6, bridge)
ensureKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
return nil
}
@@ -3074,16 +3333,23 @@ func removeKVMIPv6Runtime(c *config.Container) {
}
bridge := "virbr0"
removeKVMIPv6DenyRule(bridge, c.MACAddress)
if c.IPv6 == "" {
if c.IPv6 == "" && len(c.IPv6Addresses) == 0 {
return
}
removeKVMIPv6NAT66(c.IPv6, c.IPv6Interface)
removeKVMIPv6ForwardRules(c.IPv6, bridge)
removeKVMIPv6AntiSpoofRules(c.IPv6, bridge, c.MACAddress)
if c.IPv6Interface != "" {
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", c.IPv6, "dev", c.IPv6Interface).Run()
c.NormalizeNetworkAssignments()
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
removeKVMIPv6NAT66(assignment.Address, uplink)
removeKVMIPv6ForwardRules(assignment.Address, bridge)
removeKVMIPv6AntiSpoofRules(assignment.Address, bridge, c.MACAddress)
if uplink != "" {
_ = exec.Command("ip", "-6", "neigh", "del", "proxy", assignment.Address, "dev", uplink).Run()
}
_ = exec.Command("ip", "-6", "route", "del", assignment.Address+"/128", "dev", bridge).Run()
}
_ = exec.Command("ip", "-6", "route", "del", c.IPv6+"/128", "dev", bridge).Run()
}
func removeKVMIPv6ForwardRules(ipv6 string, bridge string) {
@@ -3147,13 +3413,14 @@ func deleteIP6Rule(rule []string) {
}
func (m *Manager) applyGuestIPv6(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
if IsWindowsImage(c.Template) {
return m.applyWindowsGuestIPv6(c)
}
script := kvmIPv6SetupScript(c.IPv6)
script := kvmIPv6SetupScript(c.IPv6AddressStrings())
if err := qemuGuestPing(c.VirshName()); err != nil {
return err
}
@@ -3167,14 +3434,15 @@ func (m *Manager) applyWindowsGuestIPv6(c *config.Container) error {
if err := qemuGuestPing(c.VirshName()); err != nil {
return err
}
script := windowsIPv6PowerShell(c.IPv6)
script := windowsIPv6PowerShell(c.IPv6AddressStrings())
return qemuGuestExecCommand(c.VirshName(), "powershell.exe", []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}, 60*time.Second)
}
func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
c.NormalizeNetworkAssignments()
qgaErr := m.applyGuestIPv6(c)
if qgaErr == nil {
return nil
@@ -3187,7 +3455,7 @@ func (m *Manager) applyGuestIPv6Runtime(c *config.Container) error {
}
func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
if c == nil || c.IPv6 == "" {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil
}
if IsWindowsImage(c.Template) {
@@ -3206,12 +3474,13 @@ func (m *Manager) applyGuestIPv6OverSSH(c *config.Container) error {
return err
}
defer client.Close()
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6), "KVM IPv6", 60*time.Second)
return runKVMSSHScript(client, kvmIPv6SetupScript(c.IPv6AddressStrings()), "KVM IPv6", 60*time.Second)
}
func kvmIPv6SetupScript(ipv6 string) string {
func kvmIPv6SetupScript(ipv6s []string) string {
ipv6s = normalizeKVMIPv6List(ipv6s)
return `set -eu
IPV6_ADDR=` + shellQuote(ipv6) + `
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
if [ -z "$IFACE" ]; then
@@ -3224,13 +3493,15 @@ fi
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
for IPV6_ADDR in $IPV6_ADDRS; do
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
done
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
mkdir -p /usr/local/sbin /etc/systemd/system /etc/network/if-up.d /etc/local.d
cat > /usr/local/sbin/clicd-kvm-ipv6-init <<'EOF'
#!/bin/sh
set -eu
IPV6_ADDR=` + shellQuote(ipv6) + `
IPV6_ADDRS="` + strings.Join(ipv6s, " ") + `"
IPV6_GW=` + shellQuote(ipv6GatewayLinkLocal) + `
IFACE="$(ip -o -4 route show default 2>/dev/null | awk '{print $5; exit}')"
if [ -z "$IFACE" ]; then
@@ -3240,7 +3511,9 @@ fi
sysctl -w net.ipv6.conf.all.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf.default.disable_ipv6=0 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf."$IFACE".disable_ipv6=0 >/dev/null 2>&1 || true
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
for IPV6_ADDR in $IPV6_ADDRS; do
ip -6 addr replace "$IPV6_ADDR/128" dev "$IFACE"
done
ip -6 route replace default via "$IPV6_GW" dev "$IFACE" onlink metric 100
EOF
chmod +x /usr/local/sbin/clicd-kvm-ipv6-init
@@ -3279,15 +3552,43 @@ chmod +x /etc/network/if-up.d/clicd-kvm-ipv6
}
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
prefixes := lxc.DetectPublicIPv6Prefixes()
if len(prefixes) == 0 {
return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
}
prefixInfo := prefixes[0]
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
assignments, err := m.allocateIPv6AssignmentsForContainer(id, nil, 1, true)
if err != nil {
return "", 0, "", err
}
if len(assignments) == 0 {
return "", 0, "", fmt.Errorf("no free IPv6 address")
}
return assignments[0].Address, assignments[0].PrefixLen, assignments[0].Interface, nil
}
func (m *Manager) allocateIPv6AssignmentsForContainer(id int, requested []string, count int, auto bool) ([]config.IPv6Assignment, error) {
var err error
count, err = lxc.NormalizePublicIPAllocationCount(requested, count)
if err != nil {
return nil, err
}
prefixes := lxc.DetectPublicIPv6Prefixes()
if len(prefixes) == 0 {
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no usable public IPv6 prefix found")
}
parsedPrefixes := make([]struct {
info lxc.IPv6PrefixInfo
prefix netip.Prefix
}, 0, len(prefixes))
for _, prefixInfo := range prefixes {
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
if err != nil {
continue
}
parsedPrefixes = append(parsedPrefixes, struct {
info lxc.IPv6PrefixInfo
prefix netip.Prefix
}{info: prefixInfo, prefix: prefix})
}
if len(parsedPrefixes) == 0 {
return nil, fmt.Errorf("public IPv6 allocation is unavailable: no valid IPv6 prefix found")
}
used := map[string]bool{}
hostAddrs := map[string]bool{}
@@ -3295,21 +3596,69 @@ func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error)
hostAddrs[p.Address] = true
}
for _, c := range config.AppConfig.Containers {
if c.ID == id {
continue
}
if c.IPv6 != "" {
used[c.IPv6] = true
}
}
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
addr, err := ipv6Add(prefix.Masked().Addr(), offset)
if err != nil || !prefix.Contains(addr) {
break
}
candidate := addr.String()
if !used[candidate] && !hostAddrs[candidate] {
return candidate, prefix.Bits(), prefixInfo.Interface, nil
for _, ip := range c.IPv6Addresses {
if ip.Address != "" {
used[ip.Address] = true
}
}
}
return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String())
result := []config.IPv6Assignment{}
selected := map[string]bool{}
for _, raw := range requested {
raw = strings.TrimSpace(raw)
if raw == "" || selected[raw] {
continue
}
addr, err := netip.ParseAddr(raw)
if err != nil || !addr.Is6() {
return nil, fmt.Errorf("requested IPv6 %s is not valid", raw)
}
matchIndex := -1
for i, item := range parsedPrefixes {
if item.prefix.Contains(addr) {
matchIndex = i
break
}
}
if matchIndex < 0 {
return nil, fmt.Errorf("requested IPv6 %s is not in the configured IPv6 prefixes", raw)
}
if hostAddrs[raw] {
return nil, fmt.Errorf("requested IPv6 %s is used by host", raw)
}
if used[raw] {
return nil, fmt.Errorf("requested IPv6 %s is already assigned", raw)
}
selected[raw] = true
used[raw] = true
result = append(result, config.IPv6Assignment{Address: raw, PrefixLen: parsedPrefixes[matchIndex].prefix.Bits(), Interface: parsedPrefixes[matchIndex].info.Interface})
}
if len(result) >= count || !auto {
return result, nil
}
for _, item := range parsedPrefixes {
for offset := uint64(0x2000 + id); offset < 0x100000; offset++ {
addr, err := ipv6Add(item.prefix.Masked().Addr(), offset)
if err != nil || !item.prefix.Contains(addr) {
break
}
candidate := addr.String()
if !used[candidate] && !hostAddrs[candidate] {
used[candidate] = true
result = append(result, config.IPv6Assignment{Address: candidate, PrefixLen: item.prefix.Bits(), Interface: item.info.Interface})
if len(result) >= count {
return result, nil
}
}
}
}
return nil, fmt.Errorf("no free IPv6 address in configured prefixes")
}
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
File diff suppressed because it is too large Load Diff
+283 -90
View File
@@ -218,24 +218,37 @@ func NewManager() *Manager {
// ContainerConfig defines container creation parameters
type ContainerConfig struct {
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv6 bool `json:"assign_ipv6"`
ExpiresAt string `json:"expires_at"`
Name string `json:"name"`
Virtualization string `json:"virtualization,omitempty"`
TemplateID string `json:"template_id"`
VCPU float64 `json:"vcpu"`
CPUPercent int `json:"cpu_percent"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"`
ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"`
SnapshotLimit int `json:"snapshot_limit"`
AssignIPv4 bool `json:"assign_ipv4"`
IPv4Count int `json:"ipv4_count,omitempty"`
PublicIPv4s []string `json:"public_ipv4s,omitempty"`
AssignIPv6 bool `json:"assign_ipv6"`
IPv6Count int `json:"ipv6_count,omitempty"`
IPv6Addresses []string `json:"ipv6_addresses,omitempty"`
SSHAuthMode string `json:"ssh_auth_mode,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
ExpiresAt string `json:"expires_at"`
}
func (cfg ContainerConfig) WantsNAT() bool {
return cfg.AssignNAT == nil || *cfg.AssignNAT
}
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
@@ -244,8 +257,11 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID)
}
if cfg.PortMappingCount < 2 {
if cfg.WantsNAT() && cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
} else if !cfg.WantsNAT() {
cfg.PortMappingCount = 0
cfg.ExtraPorts = nil
}
if cfg.SnapshotLimit <= 0 {
cfg.SnapshotLimit = config.DefaultSnapshotLimit
@@ -257,6 +273,10 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if config.FindContainerByName(cfg.Name) != nil {
return fmt.Errorf("container name already exists: %s", cfg.Name)
}
sshAccess, err := ResolveCreateSSHAccess(cfg)
if err != nil {
return err
}
// Allocate ID and build LXC name
id := config.AllocateContainerID()
@@ -296,50 +316,64 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
return err
}
ipv6 := ""
ipv6PrefixLen := 0
ipv6Interface := ""
if cfg.AssignIPv6 {
assigned, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
publicIPv4s, err := AllocatePublicIPv4Assignments(id, cfg.PublicIPv4s, cfg.IPv4Count, cfg.AssignIPv4)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
ipv6Assignments := []config.IPv6Assignment{}
if cfg.AssignIPv6 || len(cfg.IPv6Addresses) > 0 {
assigned, err := m.allocateIPv6AssignmentsForContainer(id, cfg.IPv6Addresses, cfg.IPv6Count, true)
if err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
ipv6 = assigned
ipv6PrefixLen = prefixLen
ipv6Interface = iface
if err := m.applyIPv6Config(lxcName, ipv6); err != nil {
ipv6Assignments = assigned
if err := m.applyIPv6Config(lxcName, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
_ = m.cleanupContainerStorage(lxcName)
return err
}
}
sshPort := config.AllocateSSHPort()
sshPassword := generateRandomString(16)
sshPassword := sshAccess.Password
// Setup default port mappings (SSH only)
portMappings := SetupDefaultPortMappings(sshPort)
tempC := &config.Container{PortMappings: portMappings}
sshPort := 0
portMappings := []config.PortMapping{}
if cfg.WantsNAT() {
sshPort = config.AllocateSSHPort()
extraPorts := cfg.ExtraPorts
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
}
for _, containerPort := range extraPorts {
if containerPort <= 0 {
continue
// Setup default port mappings (SSH only)
portMappings = SetupDefaultPortMappings(sshPort)
defaultHostIP := defaultPortMappingHostIP(publicIPv4s)
if defaultHostIP != "" {
for i := range portMappings {
portMappings[i].HostIP = defaultHostIP
}
}
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
ContainerPort: containerPort,
HostPort: containerPort,
Protocol: "tcp",
Description: fmt.Sprintf("Port-%d", containerPort),
})
if err != nil {
continue
tempC := &config.Container{ID: id, PublicIPv4s: publicIPv4s, PortMappings: portMappings}
extraPorts := cfg.ExtraPorts
if len(extraPorts) == 0 && cfg.PortMappingCount > 1 {
extraPorts = allocateDefaultEqualPorts(tempC, cfg.PortMappingCount-1)
}
for _, containerPort := range extraPorts {
if containerPort <= 0 {
continue
}
pm, err := normalizePortMapping(tempC, -1, config.PortMapping{
ContainerPort: containerPort,
HostPort: containerPort,
HostIP: defaultHostIP,
Protocol: "tcp",
Description: fmt.Sprintf("Port-%d", containerPort),
})
if err != nil {
continue
}
tempC.PortMappings = append(tempC.PortMappings, pm)
portMappings = tempC.PortMappings
}
tempC.PortMappings = append(tempC.PortMappings, pm)
portMappings = tempC.PortMappings
}
now := time.Now().Format("2006-01-02 15:04:05")
@@ -368,9 +402,8 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
IOSpeedMBps: cfg.IOSpeedMBps,
Status: "stopped",
IP: "",
IPv6: ipv6,
IPv6PrefixLen: ipv6PrefixLen,
IPv6Interface: ipv6Interface,
PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments,
VNCPort: 0,
SSHPort: sshPort,
SSHPassword: sshPassword,
@@ -380,19 +413,27 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
CreatedAt: now,
ExpiresAt: cfg.ExpiresAt,
}
container.NormalizeNetworkAssignments()
config.AddContainer(container)
// Pre-configure network and SSH in the rootfs before first boot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, cfg.TemplateID)
if ipv6 != "" {
if err := installContainerIPv6Init(rootfsPath, ipv6); err != nil {
if len(ipv6Assignments) > 0 {
if err := installContainerIPv6Init(rootfsPath, ipv6AssignmentAddresses(ipv6Assignments)...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", lxcName, err)
}
}
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID); err != nil {
if err := m.preconfigureSSH(rootfsPath, cfg.TemplateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s: %v\n", lxcName, err)
}
if sshAccess.PublicKey != "" {
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
_ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
return fmt.Errorf("failed to install SSH public key: %v", err)
}
}
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName)
@@ -471,11 +512,13 @@ IPv6AcceptRA=no
}
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, templateID string) error {
func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode string) error {
_ = templateID
// Disable pubkey auth when user chose password-only mode (password or auto_password)
disablePubkey := sshAuthMode == SSHAuthPassword || sshAuthMode == SSHAuthAutoPassword
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false))
cmd, err := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(false, disablePubkey))
if err != nil {
return err
}
@@ -525,7 +568,7 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
if err != nil {
return err
}
apparmorProfile, err := findAppArmorProfile()
apparmorProfile, err := appArmorProfileForTemplate(cfg.TemplateID)
if err != nil {
return err
}
@@ -940,6 +983,26 @@ func findAppArmorProfile() (string, error) {
return "", errors.New("required LXC AppArmor profile not loaded")
}
func appArmorProfileForTemplate(templateID string) (string, error) {
if systemdTemplateNeedsUnconfinedAppArmor(templateID) {
return "unconfined", nil
}
return findAppArmorProfile()
}
func systemdTemplateNeedsUnconfinedAppArmor(templateID string) bool {
id := strings.ToLower(strings.TrimSpace(templateID))
if id == "" || strings.Contains(id, "alpine") {
return false
}
for _, token := range []string{"ubuntu", "debian", "centos", "fedora", "rocky", "rockylinux", "archlinux"} {
if strings.Contains(id, token) {
return true
}
}
return false
}
func unprivilegedIDMap() (int, int, error) {
if err := ensureSubIDRange("/etc/subuid", "root", 100000, 65536); err != nil {
return 0, 0, err
@@ -1197,27 +1260,31 @@ func (m *Manager) StartContainer(id int) error {
NetworkBWMbps: c.NetworkBWMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps,
AssignIPv6: c.IPv6 != "",
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt,
}); err != nil {
return err
}
}
if c.IPv6 != "" {
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
c.NormalizeNetworkAssignments()
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
return err
}
if err := m.ApplyIPv6(id); err != nil {
return err
}
}
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
os.Remove(logFile)
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
output, err := cmd.CombinedOutput()
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
if err != nil {
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
config.UpdateContainerStatus(id, "stopped")
return fmt.Errorf("failed to start container: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
}
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
config.UpdateContainerStatus(id, "stopped")
return err
}
config.UpdateContainerStatus(id, "running")
@@ -1262,7 +1329,7 @@ func (m *Manager) StartContainer(id int) error {
if err := m.ApplyPortMappings(id); err != nil {
fmt.Printf("Warning: failed to apply port mappings: %v\n", err)
}
if c.IPv6 != "" {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
fmt.Printf("Warning: failed to apply IPv6 routing for %s: %v\n", lxcName, err)
}
@@ -1272,6 +1339,41 @@ func (m *Manager) StartContainer(id int) error {
return nil
}
func (m *Manager) startLXCContainerDaemon(lxcName string) (string, string, []byte, error) {
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
consoleLog := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-console.log")
os.Remove(logFile)
os.Remove(consoleLog)
cmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG", "--console-log", consoleLog)
output, err := cmd.CombinedOutput()
return logFile, consoleLog, output, err
}
func (m *Manager) waitForLXCStartup(lxcName, logFile, consoleLog string) error {
runningChecks := 0
lastStatus := "unknown"
for retry := 0; retry < 10; retry++ {
time.Sleep(1 * time.Second)
status, err := m.GetContainerStatus(lxcName)
if err != nil {
lastStatus = "unknown"
continue
}
lastStatus = status
if status == "running" {
runningChecks++
if runningChecks >= 3 {
return nil
}
continue
}
if runningChecks > 0 || retry >= 1 {
break
}
}
return fmt.Errorf("container exited immediately after start (status: %s), lxc log: %s, console: %s", lastStatus, tailFile(logFile, 80), tailFile(consoleLog, 80))
}
// applyBandwidthLimit applies tc-based bandwidth limit on container's veth interface
// ApplyContainerLimits re-applies resource limits (CPU, RAM, IO, BW) to a running container.
func (m *Manager) ApplyContainerLimits(c *config.Container) error {
@@ -1553,8 +1655,17 @@ func (m *Manager) DestroyContainer(id int) error {
return fmt.Errorf("container not found: %d", id)
}
lxcName := c.LxcName()
if c.IPv6 != "" && c.IPv6Interface != "" {
removeHostIPv6Routing(c.IPv6, c.IPv6Interface)
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
c.NormalizeNetworkAssignments()
for _, assignment := range c.IPv6Addresses {
uplink := assignment.Interface
if uplink == "" {
uplink = c.IPv6Interface
}
if uplink != "" {
removeHostIPv6Routing(assignment.Address, uplink)
}
}
}
if err := m.StopContainer(id); err != nil {
@@ -1649,7 +1760,7 @@ func (m *Manager) EnsureSSH(id int) error {
config.SaveConfig()
}
script := sshSetupScript(true)
script := sshSetupScript(true, false) // keep pubkey enabled for runtime ensure
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
@@ -1717,7 +1828,11 @@ func (m *Manager) containerPortListening(lxcName string, port int) bool {
return exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", check).Run() == nil
}
func sshSetupScript(startService bool) string {
func sshSetupScript(startService bool, disablePubkeyAuth bool) string {
pubkeyValue := "yes"
if disablePubkeyAuth {
pubkeyValue = "no"
}
script := `set -u
# DNS setup: handle both traditional /etc/resolv.conf and systemd-resolved (Ubuntu 24.04).
@@ -1799,6 +1914,11 @@ install_sshd() {
return 1
}
ensure_sshd_runtime_dir() {
mkdir -p /run/sshd /var/run/sshd
chmod 0755 /run/sshd /var/run/sshd 2>/dev/null || true
}
set_sshd_option() {
key="$1"
value="$2"
@@ -1825,11 +1945,13 @@ set_sshd_option() {
install_sshd || exit 30
mkdir -p /run/sshd /var/run/sshd /etc/ssh /etc/ssh/sshd_config.d
mkdir -p /etc/ssh /etc/ssh/sshd_config.d
ensure_sshd_runtime_dir
ssh-keygen -A >/dev/null 2>&1 || true
cat >/etc/ssh/sshd_config.d/99-clicd.conf <<'EOF'
PermitRootLogin yes
PubkeyAuthentication __CLICD_PUBKEY_AUTH__
PasswordAuthentication yes
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
@@ -1837,6 +1959,7 @@ UsePAM no
EOF
set_sshd_option PermitRootLogin yes
set_sshd_option PubkeyAuthentication __CLICD_PUBKEY_AUTH__
set_sshd_option PasswordAuthentication yes
set_sshd_option KbdInteractiveAuthentication no
set_sshd_option ChallengeResponseAuthentication no
@@ -1858,11 +1981,13 @@ if command -v chkconfig >/dev/null 2>&1; then
fi
SSHD_BIN="$(sshd_path)" || exit 32
ensure_sshd_runtime_dir
"$SSHD_BIN" -t -f /etc/ssh/sshd_config >/tmp/clicd-sshd-test.log 2>&1 || {
cat /tmp/clicd-sshd-test.log
exit 32
}
`
script = strings.ReplaceAll(script, "__CLICD_PUBKEY_AUTH__", pubkeyValue)
if !startService {
return script
}
@@ -1872,6 +1997,7 @@ if command -v systemctl >/dev/null 2>&1; then
systemctl stop ssh.socket 2>/dev/null || true
systemctl disable ssh.socket 2>/dev/null || true
fi
ensure_sshd_runtime_dir
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
systemctl restart ssh >/dev/null 2>&1 || systemctl restart sshd >/dev/null 2>&1 || true
fi
@@ -1882,9 +2008,22 @@ service ssh restart >/dev/null 2>&1 ||
/etc/init.d/sshd restart >/dev/null 2>&1 ||
true
ensure_sshd_runtime_dir
for i in 1 2 3 4 5; do
if (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
exit 0
fi
if pgrep -x sshd >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
if ! (ss -ltn 2>/dev/null || netstat -tln 2>/dev/null) | grep -Eq '(^|[[:space:]])[^[:space:]]*:22[[:space:]]'; then
pkill -x sshd >/dev/null 2>&1 || killall sshd >/dev/null 2>&1 || true
rm -f /run/sshd.pid /var/run/sshd.pid
ensure_sshd_runtime_dir
"$SSHD_BIN" -f /etc/ssh/sshd_config >/dev/null 2>&1 || exit 32
fi
@@ -1922,7 +2061,7 @@ func (m *Manager) ResetSSHPassword(id int, password string) (string, error) {
return "", err
}
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
if err := m.preconfigureSSH(rootfsPath, c.Template); err != nil {
if err := m.preconfigureSSH(rootfsPath, c.Template, ""); err != nil {
return "", fmt.Errorf("failed to configure SSH: %v", err)
}
if err := m.setRootfsPassword(rootfsPath, newPassword); err != nil {
@@ -1986,6 +2125,45 @@ func (m *Manager) setRootfsPassword(rootfsPath, password string) error {
return nil
}
func (m *Manager) installRootAuthorizedKey(rootfsPath, publicKey string) error {
key, err := NormalizeSSHPublicKey(publicKey)
if err != nil {
return err
}
if key == "" {
return nil
}
sshDir := filepath.Join(rootfsPath, "root", ".ssh")
if err := os.MkdirAll(sshDir, 0700); err != nil {
return err
}
authPath := filepath.Join(sshDir, "authorized_keys")
existing, _ := os.ReadFile(authPath)
lines := strings.Split(string(existing), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == key {
_ = os.Chmod(sshDir, 0700)
_ = os.Chmod(authPath, 0600)
_ = os.Chown(sshDir, 0, 0)
_ = os.Chown(authPath, 0, 0)
return nil
}
}
content := strings.TrimRight(string(existing), "\r\n")
if content != "" {
content += "\n"
}
content += key + "\n"
if err := os.WriteFile(authPath, []byte(content), 0600); err != nil {
return err
}
_ = os.Chmod(sshDir, 0700)
_ = os.Chmod(authPath, 0600)
_ = os.Chown(sshDir, 0, 0)
_ = os.Chown(authPath, 0, 0)
return nil
}
func safeRootfsCommandArgs(args []string) ([]string, error) {
if len(args) == 0 {
return nil, fmt.Errorf("empty rootfs command")
@@ -2383,7 +2561,7 @@ func copyRootfsContents(src, dst string) error {
}
// ReinstallContainer reinstalls the container OS
func (m *Manager) ReinstallContainer(id int, templateID string) error {
func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...ContainerConfig) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
@@ -2393,6 +2571,14 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if tmpl == nil {
return fmt.Errorf("template not found: %s", templateID)
}
authCfg := ContainerConfig{SSHAuthMode: SSHAuthKeep}
if len(authConfig) > 0 {
authCfg = authConfig[0]
}
sshAccess, err := ResolveReinstallSSHAccess(c.SSHPassword, authCfg)
if err != nil {
return err
}
lxcName := c.LxcName()
@@ -2425,14 +2611,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
NetworkBWMbps: c.NetworkBWMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps,
AssignIPv6: c.IPv6 != "",
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt,
}
if err := m.applyResourceLimits(lxcName, cfg); err != nil {
return err
}
if c.IPv6 != "" {
if err := m.applyIPv6Config(lxcName, c.IPv6); err != nil {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
c.NormalizeNetworkAssignments()
if err := m.applyIPv6Config(lxcName, c.IPv6AddressStrings()...); err != nil {
return err
}
}
@@ -2440,17 +2627,20 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
// Set root password and pre-configure network/SSH via chroot.
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
m.preconfigureNetwork(rootfsPath, templateID)
if c.IPv6 != "" {
if err := installContainerIPv6Init(rootfsPath, c.IPv6); err != nil {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s after reinstall: %v\n", lxcName, err)
}
}
if c.SSHPassword == "" {
c.SSHPassword = generateRandomString(16)
}
if err := m.preconfigureSSH(rootfsPath, templateID); err != nil {
c.SSHPassword = sshAccess.Password
if err := m.preconfigureSSH(rootfsPath, templateID, sshAccess.Mode); err != nil {
fmt.Printf("Warning: failed to pre-configure SSH in %s after reinstall: %v\n", lxcName, err)
}
if sshAccess.PublicKey != "" {
if err := m.installRootAuthorizedKey(rootfsPath, sshAccess.PublicKey); err != nil {
return fmt.Errorf("failed to install SSH public key: %v", err)
}
}
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
return err
}
@@ -2470,14 +2660,17 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
config.SaveConfig()
return err
}
logFile := filepath.Join(os.TempDir(), "clicd-"+lxcName+"-start.log")
os.Remove(logFile)
startCmd := exec.Command("lxc-start", "-n", lxcName, "-d", "--logfile", logFile, "--logpriority", "DEBUG")
if output, err := startCmd.CombinedOutput(); err != nil {
logFile, consoleLog, output, err := m.startLXCContainerDaemon(lxcName)
if err != nil {
fmt.Printf("Warning: failed to start container after reinstall: %v\n", err)
c.Status = "stopped"
config.SaveConfig()
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s", err, string(output), tailFile(logFile, 80))
return fmt.Errorf("reinstalled but failed to start: %v, output: %s, lxc log: %s, console: %s", err, string(output), tailFile(logFile, 80), tailFile(consoleLog, 80))
}
if err := m.waitForLXCStartup(lxcName, logFile, consoleLog); err != nil {
c.Status = "stopped"
config.SaveConfig()
return fmt.Errorf("reinstalled but container did not stay running: %v", err)
}
// Wait for network and install SSH
@@ -2503,7 +2696,7 @@ func (m *Manager) ReinstallContainer(id int, templateID string) error {
if c.NetworkBWMbps > 0 {
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps)
}
if c.IPv6 != "" {
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
}
+374 -25
View File
@@ -2,8 +2,10 @@ package lxc
import (
"fmt"
"net/netip"
"os/exec"
"strconv"
"strings"
"clicd/internal/config"
)
@@ -17,6 +19,7 @@ func (m *Manager) ApplyPortMappings(id int) error {
if c.IP == "" {
return fmt.Errorf("container has no IP")
}
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
tag := clicdTag(id)
bridge := "lxcbr0"
subnet := "10.0.3.0/24"
@@ -27,35 +30,229 @@ func (m *Manager) ApplyPortMappings(id int) error {
EnsureForwardRules(bridge)
m.CleanPortMappings(id)
deleteBridgeMasquerade(subnet)
for _, pm := range c.PortMappings {
cmd := exec.Command("iptables",
"-t", "nat",
"-I", "PREROUTING", "1",
"-p", pm.Protocol,
"--dport", fmt.Sprintf("%d", pm.HostPort),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
continue
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
args := []string{
"-t", "nat",
"-I", "PREROUTING", "1",
"-p", pm.Protocol,
}
if hostIP != "" {
args = append(args, "-d", hostIP)
}
args = append(args,
"--dport", fmt.Sprintf("%d", pm.HostPort),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), pm.HostPort),
)
cmd := exec.Command("iptables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply port mapping %s:%d->%s:%d: %v, output: %s\n",
displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
continue
}
fmt.Printf("Port mapping: %s:%d -> %s:%d\n", displayHostIP(hostIP), pm.HostPort, c.IP, pm.ContainerPort)
}
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
}
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
// When container has public IPv4 but no port mappings (independent IP mode),
// ensure inbound DNAT for standard service ports (SSH / RDP).
if len(c.PortMappings) == 0 && len(c.PublicIPv4s) > 0 {
ensureIndependentIPv4Ingress(c, tag)
}
applyIPv4EgressPolicy(c, bridge, subnet, tag)
return nil
}
func ensureIndependentIPv4Ingress(c *config.Container, tag string) {
if c == nil || c.IP == "" || len(c.PublicIPv4s) == 0 {
return
}
type svcPort struct {
port int
protocol string
desc string
}
servicePorts := []svcPort{{port: 22, protocol: "tcp", desc: "SSH"}}
if strings.Contains(strings.ToLower(c.Template), "windows") {
servicePorts = []svcPort{{port: 3389, protocol: "tcp", desc: "RDP"}}
}
for _, assignment := range c.PublicIPv4s {
hostIP := strings.TrimSpace(assignment.Address)
if hostIP == "" {
continue
}
for _, svc := range servicePorts {
args := []string{
"-t", "nat",
"-I", "PREROUTING", "1",
"-d", hostIP,
"-p", svc.protocol,
"--dport", fmt.Sprintf("%d", svc.port),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, svc.port),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%s-%d", tag, natRuleIPTag(hostIP), svc.port),
}
cmd := exec.Command("iptables", args...)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply %s ingress %s:%d->%s:%d: %v, output: %s\n",
svc.desc, hostIP, svc.port, c.IP, svc.port, err, string(output))
continue
}
fmt.Printf("%s ingress: %s:%d -> %s:%d\n", svc.desc, hostIP, svc.port, c.IP, svc.port)
}
}
}
func applyIPv4EgressPolicy(c *config.Container, bridge, subnet, tag string) {
if c == nil || strings.TrimSpace(c.IP) == "" {
return
}
if containerAllowsPublicIPv4Egress(c) {
if _, ok := primaryPublicIPv4Assignment(c); ok {
applyPublicIPv4SNAT(c, tag)
return
}
ensureContainerMasquerade(c, tag)
return
}
ensureIPv4EgressBlocked(c, bridge, subnet, tag)
}
func containerAllowsPublicIPv4Egress(c *config.Container) bool {
if c == nil {
return false
}
if len(c.PublicIPv4s) > 0 {
return true
}
return c.PortMappingLimit > 0 || len(c.PortMappings) > 0
}
func ensureContainerMasquerade(c *config.Container, tag string) {
args := []string{
"-s", c.IP + "/32",
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-masq", tag),
"-j", "MASQUERADE",
}
if host := DetectPublicIPv4(); strings.TrimSpace(host.Interface) != "" {
args = append([]string{"-o", strings.TrimSpace(host.Interface)}, args...)
} else {
args = append([]string{"-o", "eth+"}, args...)
}
ensureNATRule("POSTROUTING", args)
}
func ensureIPv4EgressBlocked(c *config.Container, bridge, subnet, tag string) {
args := []string{
"-i", bridge,
"-s", c.IP + "/32",
"!", "-d", subnet,
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-v4-egress-block", tag),
"-j", "REJECT",
}
ensureFilterRule("FORWARD", args)
}
func ensureNATRule(chain string, args []string) {
check := append([]string{"-t", "nat", "-C", chain}, args...)
if exec.Command("iptables", check...).Run() == nil {
return
}
add := append([]string{"-t", "nat", "-I", chain, "1"}, args...)
exec.Command("iptables", add...).Run()
}
func ensureFilterRule(chain string, args []string) {
check := append([]string{"-C", chain}, args...)
if exec.Command("iptables", check...).Run() == nil {
return
}
add := append([]string{"-I", chain, "1"}, args...)
exec.Command("iptables", add...).Run()
}
func deleteBridgeMasquerade(subnet string) {
for exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() == nil {
}
}
func applyPublicIPv4SNAT(c *config.Container, tag string) {
if c == nil || strings.TrimSpace(c.IP) == "" {
return
}
assignment, ok := primaryPublicIPv4Assignment(c)
if !ok {
return
}
hostIP := strings.TrimSpace(assignment.Address)
if hostIP == "" {
return
}
iface := strings.TrimSpace(assignment.Interface)
if iface == "" {
if info, ok := publicIPv4InfoByAddress(hostIP); ok {
iface = strings.TrimSpace(info.Interface)
}
}
if iface == "" {
if host := DetectPublicIPv4(); host.Interface != "" {
iface = host.Interface
}
}
args := []string{
"-t", "nat",
"-I", "POSTROUTING", "1",
"-s", c.IP + "/32",
}
if iface != "" {
args = append(args, "-o", iface)
}
args = append(args,
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-snat-%s", tag, natRuleIPTag(hostIP)),
"-j", "SNAT", "--to-source", hostIP,
)
if output, err := exec.Command("iptables", args...).CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to apply public IPv4 SNAT %s -> %s: %v, output: %s\n", c.IP, hostIP, err, string(output))
}
}
func primaryPublicIPv4Assignment(c *config.Container) (config.PublicIPv4Assignment, bool) {
if c == nil {
return config.PublicIPv4Assignment{}, false
}
for _, item := range c.PublicIPv4s {
if strings.TrimSpace(item.Address) != "" {
return item, true
}
}
return config.PublicIPv4Assignment{}, false
}
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
func EnsureAllRunningPortMappings() {
m := NewManager()
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" || strings.TrimSpace(c.IP) == "" {
continue
}
if err := m.ApplyPortMappings(c.ID); err != nil {
fmt.Printf("Warning: failed to restore port mappings for %s: %v\n", c.Name, err)
}
}
}
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
func EnsureForwardRules(bridge string) {
if bridge == "" {
@@ -81,8 +278,13 @@ func EnsureForwardRules(bridge string) {
// CleanPortMappings removes all iptables rules for a container
func (m *Manager) CleanPortMappings(id int) error {
tag := clicdTag(id)
for _, chain := range []string{"PREROUTING", "POSTROUTING"} {
cmd := exec.Command("sh", "-c",
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))
cmd.Run()
}
cmd := exec.Command("sh", "-c",
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
fmt.Sprintf("iptables -S FORWARD 2>/dev/null | grep 'clicd-%s-' | sed 's/^-A /-D /' | while read rule; do iptables $rule; done", tag))
cmd.Run()
return nil
}
@@ -94,12 +296,26 @@ func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
}
}
func DefaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
if len(assignments) == 1 {
return strings.TrimSpace(assignments[0].Address)
}
return ""
}
func defaultPortMappingHostIP(assignments []config.PublicIPv4Assignment) string {
return DefaultPortMappingHostIP(assignments)
}
// AddPortMapping adds a NAT rule to a container
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.PortMappingLimit <= 0 {
return nil, fmt.Errorf("container has no IPv4 NAT port quota")
}
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
}
@@ -168,6 +384,17 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
if pm.Protocol == "" {
pm.Protocol = "tcp"
}
pm.Protocol = strings.ToLower(strings.TrimSpace(pm.Protocol))
pm.HostIP = strings.TrimSpace(pm.HostIP)
if pm.HostIP != "" {
addr, err := netip.ParseAddr(pm.HostIP)
if err != nil || !addr.Is4() {
return pm, fmt.Errorf("host_ip must be a valid IPv4 address")
}
if !containerHasPublicIPv4(c, pm.HostIP) {
return pm, fmt.Errorf("host_ip %s is not assigned to this container", pm.HostIP)
}
}
if pm.Description == "" {
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
}
@@ -179,8 +406,8 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
if i == skipIndex {
continue
}
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
if portMappingsConflict(c, pm, c, existing) {
return pm, fmt.Errorf("host port %d/%s already mapped on the same IPv4 in this container", pm.HostPort, pm.Protocol)
}
}
// Check all other containers (LXC + KVM) for port conflicts
@@ -189,8 +416,9 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
continue
}
for _, existing := range oc.PortMappings {
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
oc := oc
if portMappingsConflict(c, pm, &oc, existing) {
return pm, fmt.Errorf("host port %d/%s already used on the same IPv4 by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
}
}
}
@@ -204,7 +432,9 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
used := map[int]bool{}
// Mark current container's ports
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
for _, hostIP := range expandPortMappingHostIPs(c, pm) {
used[hostPortKey(hostIP, pm.HostPort)] = true
}
used[pm.ContainerPort] = true
}
// Also mark all other containers' host ports (LXC + KVM)
@@ -213,13 +443,17 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
oc := oc
for _, hostIP := range expandPortMappingHostIPs(&oc, pm) {
used[hostPortKey(hostIP, pm.HostPort)] = true
}
}
}
ports := make([]int, 0, count)
next := 20000
for len(ports) < count {
if !used[next] {
hostIP := c.PrimaryPublicIPv4()
if !used[hostPortKey(hostIP, next)] && !used[next] {
ports = append(ports, next)
}
next++
@@ -229,3 +463,118 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
}
return ports
}
func HostPortAvailable(c *config.Container, hostIP string, hostPort int, protocol string) bool {
if c == nil || hostPort <= 0 {
return false
}
pm := config.PortMapping{HostIP: strings.TrimSpace(hostIP), HostPort: hostPort, Protocol: protocol}
for _, existing := range c.PortMappings {
if portMappingsConflict(c, pm, c, existing) {
return false
}
}
for _, oc := range config.AppConfig.Containers {
if oc.ID == c.ID {
continue
}
oc := oc
for _, existing := range oc.PortMappings {
if portMappingsConflict(c, pm, &oc, existing) {
return false
}
}
}
return true
}
func expandPortMappingHostIPs(c *config.Container, pm config.PortMapping) []string {
if strings.TrimSpace(pm.HostIP) != "" {
return []string{strings.TrimSpace(pm.HostIP)}
}
if c != nil && len(c.PublicIPv4s) > 0 {
values := make([]string, 0, len(c.PublicIPv4s))
for _, item := range c.PublicIPv4s {
if strings.TrimSpace(item.Address) != "" {
values = append(values, strings.TrimSpace(item.Address))
}
}
if len(values) > 0 {
return values
}
}
return []string{""}
}
func containerHasPublicIPv4(c *config.Container, hostIP string) bool {
if c == nil {
return false
}
for _, item := range c.PublicIPv4s {
if item.Address == hostIP {
return true
}
}
return false
}
func portMappingsConflict(aContainer *config.Container, a config.PortMapping, bContainer *config.Container, b config.PortMapping) bool {
if a.HostPort != b.HostPort || !protocolsOverlap(a.Protocol, b.Protocol) {
return false
}
aIPs := expandPortMappingHostIPs(aContainer, a)
bIPs := expandPortMappingHostIPs(bContainer, b)
for _, aIP := range aIPs {
for _, bIP := range bIPs {
if aIP == "" || bIP == "" || aIP == bIP {
return true
}
}
}
return false
}
func protocolsOverlap(a, b string) bool {
a = strings.ToLower(strings.TrimSpace(a))
b = strings.ToLower(strings.TrimSpace(b))
if a == "" {
a = "tcp"
}
if b == "" {
b = "tcp"
}
if a == b || a == "all" || b == "all" {
return true
}
return (a == "tcp+udp" && (b == "tcp" || b == "udp")) ||
(b == "tcp+udp" && (a == "tcp" || a == "udp"))
}
func natRuleIPTag(ip string) string {
ip = strings.TrimSpace(ip)
if ip == "" {
return "any"
}
return strings.ReplaceAll(ip, ".", "_")
}
func displayHostIP(ip string) string {
if strings.TrimSpace(ip) == "" {
return "host"
}
return ip
}
func hostPortKey(hostIP string, port int) int {
if hostIP == "" {
return port
}
sum := 0
for _, r := range hostIP {
sum = sum*31 + int(r)
}
if sum < 0 {
sum = -sum
}
return port + (sum % 1000000 * 100000)
}
+221
View File
@@ -0,0 +1,221 @@
package lxc
import (
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/ssh"
)
const (
SSHAuthAutoPassword = "auto_password"
SSHAuthPassword = "password"
SSHAuthKey = "key"
SSHAuthKeep = "keep"
)
type SSHAccess struct {
Mode string
Password string
PublicKey string
}
func HasSSHAuthOptions(cfg ContainerConfig) bool {
return strings.TrimSpace(cfg.SSHAuthMode) != "" ||
strings.TrimSpace(cfg.SSHPassword) != "" ||
strings.TrimSpace(cfg.SSHPublicKey) != ""
}
func ResolveCreateSSHAccess(cfg ContainerConfig) (SSHAccess, error) {
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthAutoPassword)
if err != nil {
return SSHAccess{}, err
}
if mode == SSHAuthKeep {
mode = SSHAuthAutoPassword
}
switch mode {
case SSHAuthAutoPassword:
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
case SSHAuthPassword:
password := strings.TrimSpace(cfg.SSHPassword)
if password == "" {
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
}
if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password}, nil
case SSHAuthKey:
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
if err != nil {
return SSHAccess{}, err
}
if publicKey == "" {
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
}
password := strings.TrimSpace(cfg.SSHPassword)
if password == "" {
password = generateRandomString(16)
} else if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
default:
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
}
}
func ResolveReinstallSSHAccess(currentPassword string, cfg ContainerConfig) (SSHAccess, error) {
mode, err := resolveSSHAuthMode(cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, SSHAuthKeep)
if err != nil {
return SSHAccess{}, err
}
switch mode {
case SSHAuthKeep:
password := strings.TrimSpace(currentPassword)
if password == "" {
password = generateRandomString(16)
}
if err := validateRootPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password}, nil
case SSHAuthAutoPassword:
return SSHAccess{Mode: mode, Password: generateRandomString(16)}, nil
case SSHAuthPassword:
password := strings.TrimSpace(cfg.SSHPassword)
if password == "" {
return SSHAccess{}, fmt.Errorf("请填写自定义 SSH 密码")
}
if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password}, nil
case SSHAuthKey:
publicKey, err := NormalizeSSHPublicKey(cfg.SSHPublicKey)
if err != nil {
return SSHAccess{}, err
}
if publicKey == "" {
return SSHAccess{}, fmt.Errorf("请填写 SSH 公钥")
}
password := strings.TrimSpace(cfg.SSHPassword)
if password != "" {
if err := ValidateCustomSSHPassword(password); err != nil {
return SSHAccess{}, err
}
} else {
password = strings.TrimSpace(currentPassword)
if password == "" {
password = generateRandomString(16)
}
}
if err := validateRootPassword(password); err != nil {
return SSHAccess{}, err
}
return SSHAccess{Mode: mode, Password: password, PublicKey: publicKey}, nil
default:
return SSHAccess{}, fmt.Errorf("不支持的 SSH 登录方式: %s", mode)
}
}
func ValidateCustomSSHPassword(password string) error {
if len(password) < 8 || len(password) > 64 {
return fmt.Errorf("密码长度必须为 8-64 位")
}
hasLetter := false
hasDigit := false
for _, r := range password {
if unicode.IsSpace(r) {
return fmt.Errorf("密码不能包含空白字符")
}
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return fmt.Errorf("密码至少需要包含字母和数字")
}
return validateRootPassword(password)
}
func NormalizeSSHPublicKey(publicKey string) (string, error) {
key := strings.TrimSpace(publicKey)
if key == "" {
return "", nil
}
if len(key) > 8192 {
return "", fmt.Errorf("SSH 公钥长度不能超过 8192 字符")
}
if strings.ContainsAny(key, "\r\n") || strings.ContainsRune(key, '\x00') {
return "", fmt.Errorf("SSH 公钥只能填写一行")
}
fields := strings.Fields(key)
if len(fields) < 2 {
return "", fmt.Errorf("SSH 公钥格式不正确")
}
if !isSupportedSSHKeyType(fields[0]) {
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", fields[0])
}
parsed, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key))
if err != nil {
return "", fmt.Errorf("SSH 公钥格式不正确")
}
if strings.TrimSpace(string(rest)) != "" {
return "", fmt.Errorf("一次只能填写一个 SSH 公钥")
}
if !isSupportedSSHKeyType(parsed.Type()) {
return "", fmt.Errorf("不支持的 SSH 公钥类型: %s", parsed.Type())
}
return key, nil
}
func resolveSSHAuthMode(rawMode, password, publicKey, defaultMode string) (string, error) {
mode := strings.ToLower(strings.TrimSpace(rawMode))
mode = strings.ReplaceAll(mode, "-", "_")
if mode == "" {
if strings.TrimSpace(publicKey) != "" {
return SSHAuthKey, nil
}
if strings.TrimSpace(password) != "" {
return SSHAuthPassword, nil
}
return defaultMode, nil
}
switch mode {
case "auto", "auto_password", "generated", "generate":
return SSHAuthAutoPassword, nil
case "password", "custom_password":
return SSHAuthPassword, nil
case "key", "ssh_key", "public_key":
return SSHAuthKey, nil
case "keep", "retain", "keep_password":
return SSHAuthKeep, nil
default:
return "", fmt.Errorf("不支持的 SSH 登录方式: %s", rawMode)
}
}
func isSupportedSSHKeyType(keyType string) bool {
switch keyType {
case "ssh-ed25519",
"ssh-rsa",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com":
return true
default:
return false
}
}
+3 -3
View File
@@ -46,17 +46,17 @@ func GetTemplates() []Template {
},
{
ID: "archlinux-current", Name: "Arch Linux",
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
Distro: "archlinux", Release: "current", Arch: "amd64",
Description: "Arch Linux (Rolling)",
},
{
ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
Distro: "fedora", Release: "44", Arch: "amd64",
Description: "Fedora 44",
},
{
ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
Distro: "rockylinux", Release: "10", Arch: "amd64",
Description: "Rocky Linux 10",
},
}
+43 -38
View File
@@ -4,10 +4,7 @@ import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"clicd/internal/api"
@@ -20,7 +17,7 @@ var webFS http.FileSystem
// corsMiddleware adds CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && isAllowedOrigin(origin, r.Host) {
if origin := r.Header.Get("Origin"); origin != "" && config.IsOriginAllowed(origin, r.Host) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
@@ -29,7 +26,7 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
if r.Method == http.MethodOptions {
if origin := r.Header.Get("Origin"); origin != "" && !isAllowedOrigin(origin, r.Host) {
if origin := r.Header.Get("Origin"); origin != "" && !config.IsOriginAllowed(origin, r.Host) {
w.WriteHeader(http.StatusForbidden)
return
}
@@ -41,43 +38,17 @@ func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
}
func isAllowedOrigin(origin string, requestHost string) bool {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
originHost := normalizeHost(u.Host)
host := normalizeHost(requestHost)
if originHost == host {
return true
}
return isLoopbackHost(originHost) && isLoopbackHost(host)
}
func normalizeHost(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(h)
}
return strings.ToLower(host)
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) {
// API routes
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
mux.HandleFunc("/api/language", corsMiddleware(api.HandleLanguage))
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings)))
mux.HandleFunc("/api/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
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/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
@@ -92,6 +63,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/host-report", corsMiddleware(api.AdminMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
mux.HandleFunc("/api/routing/ipv4-scan", corsMiddleware(api.AdminMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
@@ -120,6 +92,7 @@ func setupRoutes(mux *http.ServeMux) {
// Versioned external API routes
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/v1/language", corsMiddleware(api.HandleLanguage))
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
@@ -133,6 +106,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/host-info", corsMiddleware(api.AuthMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/v1/host-report", corsMiddleware(api.AuthMiddleware(api.HandleHostReport)))
mux.HandleFunc("/api/v1/snapshots", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("snapshot:read", api.HandleSnapshots))))
mux.HandleFunc("/api/v1/routing/ipv4-scan", corsMiddleware(api.AuthMiddleware(api.HandleRoutingIPv4Scan)))
mux.HandleFunc("/api/v1/routing", corsMiddleware(api.AuthMiddleware(api.HandleRouting)))
mux.HandleFunc("/api/v1/ipv6/status", corsMiddleware(api.AuthMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/v1/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
@@ -145,6 +119,7 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs)))
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/webssh-origins", corsMiddleware(api.AdminMiddleware(api.HandleWebSSHOriginSettings)))
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/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs))))
@@ -213,11 +188,26 @@ func Run() error {
}
if sslEnabled() {
certPath, keyPath, err := config.ResolveSSLConfigPaths(config.AppConfig.SSL)
if err != nil {
return err
}
server.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath)
return &cert, err
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
return nil, err
}
safeKeyPath, err := config.ResolveSSLPath(keyPath)
if err != nil {
return nil, err
}
cert, err := tls.LoadX509KeyPair(safeCertPath, safeKeyPath)
if err != nil {
return nil, err
}
return &cert, nil
},
}
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
@@ -229,14 +219,29 @@ func Run() error {
func sslEnabled() bool {
ssl := config.AppConfig.SSL
if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" {
if !ssl.Enabled {
return false
}
if _, err := os.Stat(ssl.CertPath); err != nil {
certPath, keyPath, err := config.ResolveSSLConfigPaths(ssl)
if err != nil {
log.Printf("SSL paths are invalid, falling back to HTTP: %v", err)
return false
}
safeCertPath, err := config.ResolveSSLPath(certPath)
if err != nil {
log.Printf("SSL certificate path is not allowed, falling back to HTTP: %v", err)
return false
}
safeKeyPath, err := config.ResolveSSLPath(keyPath)
if err != nil {
log.Printf("SSL private key path is not allowed, falling back to HTTP: %v", err)
return false
}
if _, err := config.ReadableFileStat(safeCertPath); err != nil {
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
return false
}
if _, err := os.Stat(ssl.KeyPath); err != nil {
if _, err := config.ReadableFileStat(safeKeyPath); err != nil {
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
return false
}
-1
View File
@@ -1 +0,0 @@

+1 -1
View File
@@ -1,7 +1,7 @@
package version
var (
Version = "1.1.7"
Version = "1.1.15"
Repo = "MengMengCode/CLICD"
)
+29 -2
View File
@@ -55,6 +55,7 @@ func main() {
// Ensure iptables FORWARD rules allow managed bridge traffic.
lxc.EnsureForwardRules("lxcbr0")
lxc.EnsureForwardRules("virbr0")
lxc.EnsureAllAssignedPublicIPv4s()
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
manager := lxc.NewManager()
@@ -74,6 +75,7 @@ func main() {
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
lxc.EnsureAllRunningPortMappings()
// Pre-warm SSH for containers already running after host boot or service restart.
manager.StartSSHWarmupScanner()
@@ -107,8 +109,33 @@ func isWebPanelSystemdRunning() bool {
func startWebPanelSystemd() {
cmd := exec.Command("systemctl", "start", "clicd")
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
fmt.Fprintf(os.Stderr, "%s: %v\n", mainT("警告: 自动启动 Web 面板失败"), err)
} else {
fmt.Println("Web 面板已自动启动")
fmt.Println(mainT("Web 面板已自动启动"))
}
}
func mainT(text string) string {
if !mainEnglish() {
return text
}
switch text {
case "警告: 自动启动 Web 面板失败":
return "Warning: failed to auto-start web panel"
case "Web 面板已自动启动":
return "Web panel auto-started"
default:
return text
}
}
func mainEnglish() bool {
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
return true
}
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
return false
}
return config.AppConfig != nil && config.NormalizeLanguage(config.AppConfig.Language) == "en"
}
+137 -48
View File
@@ -1,5 +1,105 @@
import { defineConfig } from 'vitepress'
const zhNav = [
{ text: '指南', link: '/guide/introduction' },
{ text: '功能', link: '/features/dashboard' },
{ text: '运维', link: '/operations/deployment' },
{ text: '开发', link: '/developer/architecture' },
]
const enNav = [
{ text: 'Guide', link: '/en/guide/introduction' },
{ text: 'Features', link: '/en/features/dashboard' },
{ text: 'Operations', link: '/en/operations/deployment' },
{ text: 'Developer', link: '/en/developer/architecture' },
]
const zhSidebar = [
{
text: '开始',
items: [
{ text: '项目介绍', link: '/guide/introduction' },
{ text: '安装', link: '/guide/installation' },
{ text: '升级', link: '/guide/upgrade' },
{ text: '快速上手', link: '/guide/quick-start' },
{ text: '配置说明', link: '/guide/configuration' },
],
},
{
text: '功能',
items: [
{ text: '控制面板', link: '/features/dashboard' },
{ text: '容器管理', link: '/features/containers' },
{ text: '镜像管理', link: '/features/images' },
{ text: '网络与路由', link: '/features/networking' },
{ text: '快照管理', link: '/features/snapshots' },
{ text: '安全告警', link: '/features/security' },
{ text: '子用户', link: '/features/sub-users' },
{ text: 'API 集成', link: '/features/api' },
{ text: '主机报告', link: '/features/host-report' },
],
},
{
text: '运维',
items: [
{ text: '部署建议', link: '/operations/deployment' },
{ text: '故障排查', link: '/operations/troubleshooting' },
{ text: '常见问题', link: '/operations/faq' },
],
},
{
text: '开发',
items: [
{ text: '系统架构', link: '/developer/architecture' },
{ text: '本地构建', link: '/developer/build' },
{ text: '发布流程', link: '/developer/release' },
],
},
]
const enSidebar = [
{
text: 'Get Started',
items: [
{ text: 'Introduction', link: '/en/guide/introduction' },
{ text: 'Installation', link: '/en/guide/installation' },
{ text: 'Upgrade', link: '/en/guide/upgrade' },
{ text: 'Quick Start', link: '/en/guide/quick-start' },
{ text: 'Configuration', link: '/en/guide/configuration' },
],
},
{
text: 'Features',
items: [
{ text: 'Dashboard', link: '/en/features/dashboard' },
{ text: 'Containers', link: '/en/features/containers' },
{ text: 'Images', link: '/en/features/images' },
{ text: 'Networking & Routing', link: '/en/features/networking' },
{ text: 'Snapshots', link: '/en/features/snapshots' },
{ text: 'Security Alerts', link: '/en/features/security' },
{ text: 'Sub-users', link: '/en/features/sub-users' },
{ text: 'API Integration', link: '/en/features/api' },
{ text: 'Host Report', link: '/en/features/host-report' },
],
},
{
text: 'Operations',
items: [
{ text: 'Deployment', link: '/en/operations/deployment' },
{ text: 'Troubleshooting', link: '/en/operations/troubleshooting' },
{ text: 'FAQ', link: '/en/operations/faq' },
],
},
{
text: 'Developer',
items: [
{ text: 'Architecture', link: '/en/developer/architecture' },
{ text: 'Local Build', link: '/en/developer/build' },
{ text: 'Release Process', link: '/en/developer/release' },
],
},
]
export default defineConfig({
title: 'CLICD',
description: '面向 LXC/KVM 的轻量虚拟化管理面板文档',
@@ -10,59 +110,48 @@ export default defineConfig({
head: [
['link', { rel: 'icon', href: '/favicon.svg' }],
],
locales: {
root: {
label: '简体中文',
lang: 'zh-CN',
description: '面向 LXC/KVM 的轻量虚拟化管理面板文档',
themeConfig: {
nav: zhNav,
sidebar: zhSidebar,
outline: {
label: '页面导航',
},
darkModeSwitchLabel: '外观',
sidebarMenuLabel: '菜单',
returnToTopLabel: '返回顶部',
},
},
en: {
label: 'English',
lang: 'en-US',
link: '/en/',
description: 'Documentation for the lightweight LXC/KVM virtualization management panel.',
themeConfig: {
nav: enNav,
sidebar: enSidebar,
outline: {
label: 'On This Page',
},
darkModeSwitchLabel: 'Appearance',
sidebarMenuLabel: 'Menu',
returnToTopLabel: 'Return to Top',
footer: {
message: 'CLICD documentation for deployment, usage, operations, and integration.',
copyright: 'Copyright © CLICD contributors',
},
},
},
},
themeConfig: {
logo: '/favicon.svg',
search: {
provider: 'local',
},
nav: [
{ text: '指南', link: '/guide/introduction' },
{ text: '功能', link: '/features/dashboard' },
{ text: '运维', link: '/operations/deployment' },
{ text: '开发', link: '/developer/architecture' },
],
sidebar: [
{
text: '开始',
items: [
{ text: '项目介绍', link: '/guide/introduction' },
{ text: '安装', link: '/guide/installation' },
{ text: '升级', link: '/guide/upgrade' },
{ text: '快速上手', link: '/guide/quick-start' },
{ text: '配置说明', link: '/guide/configuration' },
],
},
{
text: '功能',
items: [
{ text: '控制面板', link: '/features/dashboard' },
{ text: '容器管理', link: '/features/containers' },
{ text: '镜像管理', link: '/features/images' },
{ text: '网络与路由', link: '/features/networking' },
{ text: '快照管理', link: '/features/snapshots' },
{ text: '安全告警', link: '/features/security' },
{ text: '子用户', link: '/features/sub-users' },
{ text: 'API 集成', link: '/features/api' },
{ text: '主机报告', link: '/features/host-report' },
],
},
{
text: '运维',
items: [
{ text: '部署建议', link: '/operations/deployment' },
{ text: '故障排查', link: '/operations/troubleshooting' },
{ text: '常见问题', link: '/operations/faq' },
],
},
{
text: '开发',
items: [
{ text: '系统架构', link: '/developer/architecture' },
{ text: '本地构建', link: '/developer/build' },
{ text: '发布流程', link: '/developer/release' },
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/MengMengCode/CLICD' },
],
+42
View File
@@ -0,0 +1,42 @@
# Architecture
CLICD consists of a Go backend, a React frontend, and host virtualization capabilities.
## Backend
The backend entry point is `backend/main.go`, and HTTP routes are centralized in `backend/internal/server/server.go`. Main modules:
- `internal/api`: HTTP APIs for the web panel and `/api/v1`.
- `internal/config`: configuration and SQLite storage.
- `internal/lxc`: LXC container management.
- `internal/kvm`: KVM/libvirt virtual machine management.
- `internal/cli`: command-line management entry point.
- `internal/server`: embedded frontend assets and HTTP service.
- `internal/version`: version number.
## Frontend
The frontend entry point is `frontend/src/main.tsx`. Pages live in `frontend/src/pages`, and shared components live in `frontend/src/components`.
Main pages:
- Dashboard: `Dashboard.tsx`
- Container list: `Containers.tsx`
- Container details: `ContainerDetail.tsx`
- Image Management: `ImageManagement.tsx`
- Security Alerts: `Security.tsx`
- Snapshot Management: `Snapshots.tsx`
- Routing Management: `Routing.tsx`
- API Integration: `ApiIntegration.tsx`
- Host Report: `HostReport.tsx`
- Sub-user Management: `SubUserManagement.tsx`
## Frontend Embedding
For production builds, frontend artifacts are placed in `backend/internal/server/web`. The backend serves them through Go embed and returns the SPA entry for non-API routes.
## API Layers
- `/api/*`: web panel and compatibility APIs.
- `/api/v1/*`: versioned APIs recommended for external automation.
- WebSSH and WebVNC use short-lived tickets before opening WebSocket connections.
+42
View File
@@ -0,0 +1,42 @@
# Local Build
## Frontend Build
```bash
cd frontend
npm install
npm run build
```
Build output is written to `frontend/dist`.
## Backend Build
```bash
cd backend
go test ./...
go build -o ../build/clicd .
```
To package the embedded web panel, sync the frontend build output into the backend embed directory first.
## One-command Build
The project root provides a build script:
```bash
bash build.sh
```
The script chains frontend build, static asset sync, and Go binary build.
## Docs Build
```bash
cd docs
npm install
npm run dev
npm run build
```
`npm run dev` starts a local preview, and `npm run build` generates static documentation.
+44
View File
@@ -0,0 +1,44 @@
# Release Process
CLICD installation and upgrade rely on GitHub Release artifacts. Use semantic version tags such as `v1.1.6`.
## Version Number
Check the version in:
- `backend/internal/version/version.go`
- `frontend/package.json`
- Release tag.
## Release Artifacts
The installer first tries to download the Linux AMD64 archive:
```text
clicd-linux-amd64.tar.gz
```
In some cases, it may also try the standalone binary:
```text
clicd-linux-amd64
```
## Installer Behavior
- `CLICD_VERSION=latest`: use GitHub `releases/latest`.
- `CLICD_VERSION=vX.Y.Z`: download artifacts from the specified release tag.
Example:
```bash
CLICD_VERSION=v1.1.6 sh install.sh
```
## Post-release Verification
- The installer can download the new version.
- `systemctl status clicd` is healthy.
- `/api/version` returns the new version.
- The web panel can load frontend assets.
- Container list, task queue, and API Key pages open correctly.
+674
View File
@@ -0,0 +1,674 @@
# API Integration
CLICD remains compatible with legacy `/api` endpoints, so existing integrations do not need to change. New integrations should use `/api/v1`; the list below is all v1, and the recommended container list endpoint is `GET /api/v1/containers`.
## Authentication
API keys can be created and managed from the API Integration page. Requests support either of these headers:
```bash
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/containers
```
```bash
curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
```
## Response Shape
All APIs use the same response envelope:
```json
{
"success": true,
"message": "OK",
"data": {}
}
```
Integrations should read only the business fields they need. New capabilities are added as optional fields where possible, without requiring existing plugins to rename current fields.
## Creation and Reinstall
Container creation, batch creation, reinstall, and batch reinstall support mixed NAT, public IPv4, IPv6 networking, plus Linux SSH login configuration. Public IPv4/IPv6 pools can be viewed with `GET /api/v1/routing` and updated with `PUT /api/v1/routing`.
Create container example:
```json
{
"name": "demo-lxc-01",
"virtualization": "lxc",
"template_id": "debian-bookworm",
"vcpu": 1,
"ram_mb": 512,
"disk_gb": 10,
"assign_nat": true,
"port_mapping_count": 2,
"assign_ipv4": false,
"ipv4_count": 1,
"public_ipv4s": [],
"assign_ipv6": true,
"ipv6_count": 1,
"ipv6_addresses": [],
"ssh_auth_mode": "auto_password",
"ssh_password": "",
"ssh_public_key": "",
"expires_at": ""
}
```
Field notes:
| Field | Description |
| --- | --- |
| `assign_nat` | Whether to allocate NAT port mappings. If omitted, default NAT behavior is preserved. |
| `assign_ipv4` | Whether to allocate public IPv4. |
| `ipv4_count` | Number of public IPv4 addresses to allocate automatically. |
| `public_ipv4s` | Explicit public IPv4 address list. |
| `assign_ipv6` | Whether to allocate IPv6. |
| `ipv6_count` | Number of IPv6 addresses to allocate automatically. |
| `ipv6_addresses` | Explicit IPv6 address list. |
| `ssh_auth_mode` | Linux creation supports `auto_password`, `password`, and `key`; reinstall also supports `keep`. |
| `ssh_password` | Custom password for `password` mode. It must be 8-64 characters, include letters and digits, and contain no whitespace. |
| `ssh_public_key` | One-line SSH public key for `key` mode. |
Reinstall example:
```json
{
"template_id": "debian-bookworm",
"ssh_auth_mode": "keep",
"ssh_password": "",
"ssh_public_key": ""
}
```
`keep` is only for reinstall and keeps the current SSH password. Windows KVM images ignore Linux SSH public key fields.
## Python Example
Fetch containers:
```python
import requests
BASE_URL = "https://panel.example.com"
API_KEY = "YOUR_API_KEY"
session = requests.Session()
session.headers.update({
"X-API-Key": API_KEY,
"Content-Type": "application/json",
})
resp = session.get(f"{BASE_URL}/api/v1/containers", timeout=15)
resp.raise_for_status()
print(resp.json())
```
Create a port mapping:
```python
import requests
BASE_URL = "https://panel.example.com"
API_KEY = "YOUR_API_KEY"
CONTAINER_ID = "example-vm"
payload = {
"protocol": "tcp",
"host_port": 18080,
"container_port": 80,
"description": "web",
}
resp = requests.post(
f"{BASE_URL}/api/v1/containers/{CONTAINER_ID}/port-mappings",
headers={"X-API-Key": API_KEY},
json=payload,
timeout=15,
)
resp.raise_for_status()
print(resp.json())
```
## Endpoint List
### Overview
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/dashboard` | Dashboard statistics |
| GET | `/api/v1/host-info` | Host resources |
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 routing |
| PUT | `/api/v1/routing` | Update public IPv4/IPv6 pools |
| POST | `/api/v1/routing/ipv4-scan` | Scan a public IPv4 segment |
| GET | `/api/v1/ipv6/status` | IPv6 status |
| GET | `/api/v1/tasks` | Task queue |
| DELETE | `/api/v1/tasks/{task_id}` | Delete a task |
### Containers
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/containers` | Container list |
| POST | `/api/v1/containers/list` | Compatible POST form for container list |
| POST | `/api/v1/containers` | Create container |
| GET | `/api/v1/containers/{id|uuid|name}` | Container details |
| POST | `/api/v1/containers/{id}/start` | Start |
| POST | `/api/v1/containers/{id}/stop` | Stop |
| POST | `/api/v1/containers/{id}/restart` | Restart |
| POST | `/api/v1/containers/{id}/reinstall` | Reinstall |
| DELETE | `/api/v1/containers/{id}/delete` | Delete |
| GET | `/api/v1/containers/{id}/usage` | Resource usage |
| GET | `/api/v1/containers/{id}/traffic` | Traffic statistics |
| POST | `/api/v1/containers/{id}/traffic-reset` | Reset traffic |
| PUT | `/api/v1/containers/{id}/traffic-limit` | Update traffic limits |
| PUT | `/api/v1/containers/{id}/resource-limit` | Update resource limits |
| PUT | `/api/v1/containers/{id}/expiry` | Update expiration time |
| POST | `/api/v1/containers/{id}/reset-password` | Reset SSH password |
| POST | `/api/v1/containers/{id}/ipv6` | Assign IPv6 |
### Ports and Snapshots
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/containers/{id}/random-port` | Random available port |
| POST | `/api/v1/containers/{id}/port-mappings` | Add port mapping |
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | Update port mapping |
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | Delete port mapping |
| GET | `/api/v1/snapshots` | Snapshot overview |
| GET | `/api/v1/containers/{id}/snapshots` | Container snapshots |
| POST | `/api/v1/containers/{id}/snapshots` | Create snapshot |
| DELETE | `/api/v1/containers/{id}/snapshots/{snapshot_id}` | Delete snapshot |
| POST | `/api/v1/containers/{id}/snapshots/{snapshot_id}/restore` | Restore snapshot |
| POST | `/api/v1/containers/{id}/snapshots/schedule` | Schedule snapshots |
| PUT | `/api/v1/containers/{id}/snapshots/quota` | Snapshot quota |
### Platform Management
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/v1/templates` | Template list |
| GET | `/api/v1/images` | Image management list |
| POST | `/api/v1/images/download` | Download image |
| POST | `/api/v1/images/cancel` | Cancel image download |
| DELETE | `/api/v1/images/delete` | Delete image cache |
| PUT | `/api/v1/images/toggle` | Enable or disable image |
| GET | `/api/v1/security/alerts` | Security alerts |
| POST | `/api/v1/security/check` | Run security check |
| GET | `/api/v1/security/logs?container={name}` | Security connection logs |
| GET | `/api/v1/security/summary` | Security summary |
| GET | `/api/v1/security/settings` | Security settings |
| PUT | `/api/v1/security/settings` | Update security settings |
| GET | `/api/v1/swap` | Swap information |
| POST | `/api/v1/swap` | Adjust Swap |
| POST | `/api/v1/batch-create` | Batch create containers |
| POST | `/api/v1/batch-action` | Batch power action, delete, or reinstall |
| POST | `/api/v1/ssh-ticket` | Create WebSSH ticket |
| POST | `/api/v1/vnc-ticket` | Create WebVNC ticket |
### Accounts and Logs
| Method | Path | Description |
| --- | --- | --- |
| POST | `/api/v1/sub-user/create` | Create sub-user link |
| GET | `/api/v1/sub-users` | Sub-user list |
| POST | `/api/v1/sub-users/{id}/rotate-password` | Rotate sub-user password |
| GET | `/api/v1/sub-users/{id}/audit-logs` | Sub-user audit logs |
| GET | `/api/v1/sub-users/{id}/login-logs` | Sub-user login logs |
| GET | `/api/v1/audit-logs` | Audit logs |
| GET | `/api/v1/login-logs` | Login logs |
| GET | `/api/v1/api-keys` | API key list |
| POST | `/api/v1/api-keys` | Create API key |
| PATCH | `/api/v1/api-keys/{id}` | Update API key |
| DELETE | `/api/v1/api-keys/{id}` | Delete API key |
## Response Samples
The samples below are grouped by endpoint path. Resource numbers, task IDs, container IDs, timestamps, IP addresses, and keys will differ in real environments. Passwords, tickets, and API keys are masked.
### Overview
```json
{
"GET /api/v1/dashboard": {
"success": true,
"data": {
"running": 31,
"stopped": 0,
"total_containers": 31
}
},
"GET /api/v1/host-info": {
"success": true,
"data": {
"cpu": { "cores": 8, "usage_pct": 1.16 },
"ram": { "total_mb": 31825, "used_mb": 1275, "free_mb": 30550 },
"disk": { "total_gb": 1750.49, "used_gb": 123.98, "free_gb": 1626.51 },
"network": {
"public_ipv4": "203.0.113.10",
"public_ipv4_interface": "eth0",
"public_ipv6": "2001:db8:100::2",
"public_ipv6_interface": "eth0"
},
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
}
},
"GET /api/v1/routing": {
"success": true,
"data": {
"nat4": { "used": 62, "remaining": "45474", "total": "45536" },
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
"ipv6": { "used": 31, "remaining": "large", "total": "large" },
"public_ipv4_addresses": [
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
],
"ipv4_assignments": [
{ "container_id": 5, "container_name": "example-vm", "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
],
"nat4_mappings": [
{ "container_id": 5, "container_name": "example-vm", "status": "running", "ip": "10.0.0.10", "host_port": 22004, "container_port": 22, "protocol": "tcp" }
],
"ipv6_assignments": [
{ "container_id": 5, "container_name": "example-vm", "address": "2001:db8:100::1005", "prefix_len": 64, "interface": "eth0" }
]
}
},
"PUT /api/v1/routing": {
"success": true,
"data": {
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
"public_ipv4_addresses": [
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
],
"ipv6_prefixes": [
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
]
}
},
"POST /api/v1/routing/ipv4-scan": {
"success": true,
"data": [
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1", "status": "available", "usable": true, "reason": "" }
]
},
"GET /api/v1/ipv6/status": {
"success": true,
"data": {
"available": true,
"reachable": true,
"reason": "usable public IPv6 prefix detected",
"prefixes": [
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
]
}
},
"GET /api/v1/tasks": {
"success": true,
"data": []
},
"DELETE /api/v1/tasks/{task_id}": {
"success": true,
"message": "Task deleted"
}
}
```
### Containers
```json
{
"GET /api/v1/containers": {
"success": true,
"data": [
{
"id": 5,
"uuid": "00000000-0000-4000-8000-000000000005",
"name": "example-vm",
"virtualization": "lxc",
"template": "debian-bullseye",
"vcpu": 1,
"ram_mb": 512,
"disk_gb": 10,
"status": "running",
"ip": "10.0.0.10",
"ipv6": "2001:db8:100::1005",
"ssh_port": 22004,
"ssh_password": "***",
"port_mappings": [
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
{ "container_port": 20000, "host_port": 20000, "protocol": "tcp", "description": "Port-20000" }
]
}
]
},
"POST /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": {
"success": true,
"message": "Container created successfully"
},
"GET /api/v1/containers/{id|uuid|name}": {
"success": true,
"data": {
"id": 5,
"uuid": "00000000-0000-4000-8000-000000000005",
"name": "example-vm",
"status": "running",
"ip": "10.0.0.10",
"ipv6": "2001:db8:100::1005",
"ssh_port": 22004,
"ssh_password": "***",
"policy_blocked": false
}
},
"POST /api/v1/containers/{id}/start": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "start" }
},
"POST /api/v1/containers/{id}/stop": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "stop" }
},
"POST /api/v1/containers/{id}/restart": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "restart" }
},
"POST /api/v1/containers/{id}/reinstall": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "reinstall" }
},
"DELETE /api/v1/containers/{id}/delete": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "delete" }
},
"GET /api/v1/containers/{id}/usage": {
"success": true,
"data": {
"cpu_usage_pct": 0,
"cpu_usage_usec": 3908852,
"memory_usage_bytes": 29331456,
"disk_usage_bytes": 515100672,
"network_rx_bytes": 131232,
"network_tx_bytes": 16828,
"load1": 0.1,
"load5": 0.06,
"load15": 0.01
}
},
"GET /api/v1/containers/{id}/traffic": {
"success": true,
"data": {
"mode": "total",
"limit_gb": 0,
"in_limit_gb": 0,
"out_limit_gb": 0,
"total_used_bytes": 142082,
"rx_used_bytes": 127212,
"tx_used_bytes": 14870,
"used_pct": 0,
"reset_date": "2026-06"
}
},
"POST /api/v1/containers/{id}/traffic-reset": {
"success": true,
"message": "Traffic reset"
},
"PUT /api/v1/containers/{id}/traffic-limit": {
"success": true,
"message": "Traffic limit updated"
},
"PUT /api/v1/containers/{id}/resource-limit": {
"success": true,
"message": "Resource limits 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}/ipv6": {
"success": true,
"message": "IPv6 assigned",
"data": { "id": 5, "name": "example-vm", "ipv6": "2001:db8:100::1005" }
}
}
```
### Ports and Snapshots
```json
{
"GET /api/v1/containers/{id}/random-port": {
"success": true,
"data": { "port": 61320 }
},
"POST /api/v1/containers/{id}/port-mappings": {
"success": true,
"data": [
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
{ "container_port": 8080, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
]
},
"PUT /api/v1/containers/{id}/port-mappings/{index}": {
"success": true,
"data": [
{ "container_port": 8081, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
]
},
"DELETE /api/v1/containers/{id}/port-mappings/{index}": {
"success": true,
"data": []
},
"GET /api/v1/snapshots": {
"success": true,
"data": null
},
"GET /api/v1/containers/{id}/snapshots": {
"success": true,
"data": {
"quota": 1,
"schedule": { "enabled": false, "interval_hours": 0, "last_run": "", "next_run": "", "time": "", "created_by": "" },
"snapshots": []
}
},
"POST /api/v1/containers/{id}/snapshots": {
"success": true,
"data": {
"id": "snap-20260608-001",
"container_id": 5,
"container_name": "example-vm",
"created_at": "2026-06-08 16:00:00",
"created_by": "api:Automation",
"scheduled": false,
"size_bytes": 10485760
}
},
"DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}": {
"success": true,
"message": "Snapshot deleted"
},
"POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore": {
"success": true,
"message": "Snapshot restored"
},
"POST /api/v1/containers/{id}/snapshots/schedule": {
"success": true,
"data": {
"container": { "id": 5, "name": "example-vm", "snapshot_schedule_enabled": true, "snapshot_schedule_interval_hours": 24, "snapshot_schedule_time": "03:00" }
}
},
"PUT /api/v1/containers/{id}/snapshots/quota": {
"success": true,
"data": {
"quota": 2,
"container": { "id": 5, "name": "example-vm", "snapshot_limit": 2 }
}
}
}
```
### Platform Management
```json
{
"GET /api/v1/templates": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "description": "Ubuntu 24.04 LTS" },
{ "id": "debian-bookworm", "name": "Debian 12", "distro": "debian", "release": "bookworm", "arch": "amd64", "description": "Debian 12 (Bookworm)" }
]
},
"GET /api/v1/images": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
]
},
"POST /api/v1/images/download": {
"success": true,
"message": "Already downloaded"
},
"POST /api/v1/images/cancel": {
"success": true,
"message": "Cancel requested"
},
"DELETE /api/v1/images/delete": {
"success": true,
"message": "Deleted"
},
"PUT /api/v1/images/toggle": {
"success": true,
"message": "OK"
},
"GET /api/v1/security/alerts": {
"success": true,
"data": []
},
"POST /api/v1/security/check": {
"success": true,
"message": "Security check completed"
},
"GET /api/v1/security/logs?container={name}": {
"success": true,
"data": []
},
"GET /api/v1/security/summary": {
"success": true,
"data": { "critical": 0, "high": 0, "medium": 0, "low": 0, "total_alerts": 0 }
},
"GET /api/v1/security/settings": {
"success": true,
"data": { "auto_shutdown": false }
},
"PUT /api/v1/security/settings": {
"success": true,
"data": { "auto_shutdown": false }
},
"GET /api/v1/swap": {
"success": true,
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
},
"POST /api/v1/swap": {
"success": true,
"message": "SWAP 已调整为 16384 MB",
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
},
"POST /api/v1/batch-create": {
"success": true,
"data": ["task-12"]
},
"POST /api/v1/batch-action": {
"success": true,
"data": ["task-13"]
},
"POST /api/v1/ssh-ticket": {
"success": true,
"data": { "ticket": "***60 seconds valid***" }
},
"POST /api/v1/vnc-ticket": {
"success": true,
"data": { "ticket": "***60 seconds valid***" }
}
}
```
### Accounts and Logs
```json
{
"POST /api/v1/sub-user/create": {
"success": true,
"message": "Sub-user created",
"data": {
"id": "sub-xxxxxxxx",
"username": "user-xxxxxxxx",
"password": "***",
"container_names": ["example-vm"],
"access_code": "********",
"created_at": "2026-06-08 16:00:00"
}
},
"GET /api/v1/sub-users": {
"success": true,
"data": []
},
"POST /api/v1/sub-users/{id}/rotate-password": {
"success": true,
"data": { "username": "user-xxxxxxxx", "password": "***", "access_code": "********" }
},
"GET /api/v1/sub-users/{id}/audit-logs": {
"success": true,
"data": []
},
"GET /api/v1/sub-users/{id}/login-logs": {
"success": true,
"data": []
},
"GET /api/v1/audit-logs": {
"success": true,
"data": [
{ "time": "2026-06-08 15:44:40", "action": "apikey.create", "target": "Test", "detail": "scopes=*", "user": "admin", "success": true }
]
},
"GET /api/v1/login-logs": {
"success": true,
"data": [
{ "time": "2026-06-08 08:24:00 UTC", "username": "admin", "ip": "198.51.100.23", "user_agent": "Mozilla/5.0 ...", "success": true }
]
},
"GET /api/v1/api-keys": {
"success": true,
"data": [
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
]
},
"POST /api/v1/api-keys": {
"success": true,
"message": "API key created. Save this key now - it won't be shown again.",
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
},
"PATCH /api/v1/api-keys/{id}": {
"success": true,
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
},
"DELETE /api/v1/api-keys/{id}": {
"success": true,
"message": "API key deleted"
}
}
```
+83
View File
@@ -0,0 +1,83 @@
# Container Management
Container Management is the core CLICD module. It covers creation, lifecycle operations, resource limits, network mappings, traffic statistics, password resets, and console access.
## Container List
The list page scans container status. Administrators can view all containers. Sub-users only see containers within their authorization scope.
Common fields include:
- ID, UUID, and name.
- Virtualization type.
- Runtime status.
- IP and IPv6.
- CPU, memory, and disk limits.
- Traffic usage and traffic limits.
- Expiration time.
## Create Containers
Creation requires a template and resource quotas. Batch creation is available from the panel or API and is useful for issuing multiple containers at once.
```http
POST /api/v1/containers
POST /api/v1/batch-create
```
Linux containers and Linux KVM virtual machines support SSH login configuration during creation:
- `auto_password`: generate a root SSH password automatically.
- `password`: use a custom `ssh_password`.
- `key`: write a one-line `ssh_public_key`; a password is still kept for WebSSH.
Network allocation can combine NAT, public IPv4, and IPv6 as needed. API fields such as `assign_nat`, `assign_ipv4`, `public_ipv4s`, `assign_ipv6`, and `ipv6_addresses` are optional. If they are omitted, default behavior is preserved.
## Lifecycle Operations
```http
POST /api/v1/containers/{id}/start
POST /api/v1/containers/{id}/stop
POST /api/v1/containers/{id}/restart
POST /api/v1/containers/{id}/reinstall
DELETE /api/v1/containers/{id}/delete
```
Start, stop, reinstall, and delete actions enter the task queue. Call `GET /api/v1/tasks` afterwards to check execution status.
When reinstalling a Linux system, you may pass `ssh_auth_mode`, `ssh_password`, and `ssh_public_key`. `ssh_auth_mode=keep` keeps the current SSH password. If these fields are omitted, the old behavior is preserved.
## Resources and Traffic
The container details page supports resource usage, traffic limit changes, resource limit changes, and expiration changes.
```http
GET /api/v1/containers/{id}/usage
GET /api/v1/containers/{id}/traffic
POST /api/v1/containers/{id}/traffic-reset
PUT /api/v1/containers/{id}/traffic-limit
PUT /api/v1/containers/{id}/resource-limit
PUT /api/v1/containers/{id}/expiry
```
## NAT Port Management
The NAT port management section supports adding, editing, and deleting mappings. Add and edit actions use a dialog so name, protocol, external port, and internal port can be filled in together.
```http
GET /api/v1/containers/{id}/random-port
POST /api/v1/containers/{id}/port-mappings
PUT /api/v1/containers/{id}/port-mappings/{index}
DELETE /api/v1/containers/{id}/port-mappings/{index}
```
In sub-user mode, administrators can limit sub-users to changing only the internal port, preventing changes to the host-facing port and protocol.
## Remote Console
```http
POST /api/v1/ssh-ticket
POST /api/v1/vnc-ticket
```
Tickets are short-lived. Use them immediately for WebSSH or WebVNC and do not persist them.
+27
View File
@@ -0,0 +1,27 @@
# Dashboard
The dashboard shows the overall state of the host and virtualization resources.
## Metrics
- Total containers, running containers, and stopped containers.
- CPU, memory, disk, and Swap overview.
- Entry points for host network and routing status.
- Task queue status.
- Security alert summary.
## Related APIs
```http
GET /api/v1/dashboard
GET /api/v1/host-info
GET /api/v1/routing
GET /api/v1/ipv6/status
GET /api/v1/tasks
```
API requests must include an API key:
```bash
curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
```
+21
View File
@@ -0,0 +1,21 @@
# Host Report
The host report summarizes the host runtime environment, resource status, and virtualization dependencies. It is useful for post-installation checks, troubleshooting, or sharing environment information with maintainers.
## Contents
- System version and kernel information.
- CPU, memory, disk, and Swap.
- Network status.
- LXC/KVM dependency status.
- CLICD service status.
## Related APIs
```http
GET /api/v1/host-report
GET /api/v1/host-info
GET /api/v1/swap
```
Before sending a report externally, check whether it contains public IPs, private networks, usernames, keys, tickets, or business domains.
+29
View File
@@ -0,0 +1,29 @@
# Image Management
Image Management maintains templates used to create containers or virtual machines.
## Supported Template Types
The project includes common Linux distribution templates such as Debian, Ubuntu, Alpine, CentOS, Fedora, Arch Linux, and Rocky Linux. KVM templates use the corresponding distribution cloud image resources.
## Management Actions
```http
GET /api/v1/templates
GET /api/v1/images
POST /api/v1/images/download
POST /api/v1/images/cancel
DELETE /api/v1/images/delete
PUT /api/v1/images/toggle
```
- `templates` returns available template definitions.
- `images` returns local image status.
- `download` downloads a specific template.
- `cancel` cancels a download task.
- `delete` removes the local image cache.
- `toggle` controls whether a template can be used during creation.
## Windows Images
This project does not distribute Windows system images and does not provide features to bypass or avoid Windows activation. Windows download links should point to official Microsoft resources, and users must obtain valid licenses themselves.
+61
View File
@@ -0,0 +1,61 @@
# Networking and Routing
CLICD provides NAT4 port mapping, random available ports, public IPv4 assignment, IPv6 status checks, and IPv6 assignment. During container creation, you can use NAT only, public IPv4 only, IPv6 only, or a mixed network setup.
## NAT4
NAT4 forwards host ports to container internal ports. Common uses include:
- Forwarding SSH.
- Exposing web services.
- Assigning fixed external ports to sub-users.
Port mappings include:
| Field | Description |
| --- | --- |
| Name | A purpose label such as `ssh` or `web`. |
| Protocol | `tcp` or `udp`. |
| External port | The host port exposed to the outside. |
| Internal port | The service port inside the container. |
## IPv6
IPv6 assignment requires the host to have a routable IPv6 prefix, plus correct routing, neighbor discovery, or proxy configuration.
```http
GET /api/v1/ipv6/status
POST /api/v1/containers/{id}/ipv6
```
If the host has no public IPv6 or the upstream network is not routing the prefix correctly, assigned addresses will not be reachable from the public internet.
## Public IPv4
Public IPv4 assignment selects from public IPv4 addresses detected on the host, or from `public_ipv4s` specified through the API. Creation fields include:
| Field | Description |
| --- | --- |
| `assign_nat` | Whether to enable NAT port mappings. |
| `assign_ipv4` | Whether to assign public IPv4. |
| `ipv4_count` | Number of public IPv4 addresses to allocate automatically. |
| `public_ipv4s` | Explicit public IPv4 address list. |
| `assign_ipv6` | Whether to assign IPv6. |
| `ipv6_count` | Number of IPv6 addresses to allocate automatically. |
| `ipv6_addresses` | Explicit IPv6 address list. |
Public address pool APIs:
```http
GET /api/v1/routing
PUT /api/v1/routing
POST /api/v1/routing/ipv4-scan
```
## Routing Status
```http
GET /api/v1/routing
```
This endpoint shows runtime status for NAT, IPv4, IPv6, and port capacity.
+31
View File
@@ -0,0 +1,31 @@
# Security Alerts
CLICD includes lightweight security alerts based on connection behavior. It does not keep full normal connection logs; it focuses on abnormal behavior and high-risk patterns.
## Covered Scenarios
- Port scanning.
- Lateral scanning.
- Brute-force tendencies.
- SMTP abuse.
- UDP reflection risk.
- Suspicious ports related to mining, proxies, VPNs, Tor, and similar services.
## APIs
```http
GET /api/v1/security/alerts
POST /api/v1/security/check
GET /api/v1/security/logs?container={name}
GET /api/v1/security/summary
GET /api/v1/security/settings
PUT /api/v1/security/settings
```
## Automatic Shutdown
Security settings can enable automatic shutdown after alerts. Before enabling it, observe for a while and make sure the rules do not affect normal services.
## Logging Advice
Security alerts are risk signals. They should not replace a professional firewall, intrusion detection, or centralized logging system. For public services, still combine them with security groups, firewall rules, Fail2ban, and similar tools.
+31
View File
@@ -0,0 +1,31 @@
# Snapshot Management
Snapshots save the current state of a container so it can be rolled back before upgrades, configuration changes, or delivery.
## Global Overview
```http
GET /api/v1/snapshots
```
Use this endpoint to view snapshot summaries for all containers.
## Container Snapshots
```http
GET /api/v1/containers/{id}/snapshots
POST /api/v1/containers/{id}/snapshots
DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}
POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore
```
Restoring a snapshot changes container state. In production, confirm that the current workload can be interrupted first.
## Scheduled Snapshots and Quotas
```http
POST /api/v1/containers/{id}/snapshots/schedule
PUT /api/v1/containers/{id}/snapshots/quota
```
Scheduled snapshots are useful for long-running containers. Quotas prevent snapshots from growing without limit and filling the host disk.
+28
View File
@@ -0,0 +1,28 @@
# Sub-users
Sub-users let administrators grant specific container access to other users. They are useful for temporary delivery, shared-host allocation, teaching labs, or multi-user host scenarios.
## Create an Access Link
After selecting a container, the administrator can create a sub-user link:
```http
POST /api/v1/sub-user/create
```
The response may include a username, initial password, access code, or access link. When sharing externally, mask sensitive values and send the real values only to the intended user.
## Manage Sub-users
```http
GET /api/v1/sub-users
POST /api/v1/sub-users/{id}/rotate-password
GET /api/v1/sub-users/{id}/audit-logs
GET /api/v1/sub-users/{id}/login-logs
```
Rotating the password invalidates old credentials. Audit logs and login logs help investigate mistakes or abnormal access.
## Permission Scope
Sub-users can only manage authorized containers. Global configuration, image management, security policy, API keys, and other administrator features are not exposed to sub-users.
+30
View File
@@ -0,0 +1,30 @@
# Configuration
After installation, CLICD runs as a systemd service. Runtime configuration and the database are stored locally on the host. The exact path may vary with installer options, but the default installation should mainly be checked under `/root/.clicd/`.
## Common Settings
| Setting | Description |
| --- | --- |
| Web port | Defaults to `8999`, listening on `0.0.0.0:8999`. |
| Administrator account | Used to log in to the web panel and manage API keys. |
| Database | SQLite storage for container metadata, sub-users, audit logs, API keys, and more. |
| NAT port range | Used for random ports and port mapping allocation. |
| IPv6 prefixes | Used when the host has routable IPv6 prefixes. |
| Security alerts | Policies such as automatic shutdown can be configured. |
## Service Commands
```bash
systemctl status clicd
systemctl restart clicd
journalctl -u clicd -n 100 --no-pager
```
## Security Recommendations
- Do not expose the web panel directly to untrusted networks.
- Use a strong administrator password and rotate it regularly.
- Split API keys by purpose and avoid long-lived full-access keys.
- WebSSH and WebVNC tickets are short-lived credentials and should not be written to logs or shared publicly.
- Do not paste real IPs, passwords, API keys, or tickets into public docs, screenshots, or support tickets.
+46
View File
@@ -0,0 +1,46 @@
# Installation
CLICD provides a one-line installer. By default, it installs the latest version from GitHub Releases. You can also pin a specific version with an environment variable.
## Requirements
- Linux x86_64 host.
- Root privileges.
- systemd.
- Network access to GitHub Release downloads.
- LXC runtime support if you want to use LXC.
- KVM virtualization enabled with libvirt/QEMU installed if you want to use KVM.
## Install the Latest Version
```bash
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`.
## Install a Specific Version
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
```
Replace `v1.1.6` with the release tag you want to install.
## Open the Panel
After installation, open:
```text
http://YOUR_SERVER_IP:8999
```
Use the administrator credentials printed by the installer for the first login. In production, restrict access at the firewall or reverse proxy layer and change the default username and password as soon as possible.
## Uninstall
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
```
Before uninstalling, decide whether you need to keep containers, image cache, database files, or configuration files.
+29
View File
@@ -0,0 +1,29 @@
# Introduction
CLICD is a lightweight virtualization management panel for LXC and KVM. It brings common host operations into a web console and CLI, making it suitable for small VPS nodes, dedicated servers, and scenarios where container access needs to be distributed in batches.
## Core Capabilities
- Manage LXC containers and KVM virtual machines.
- Create, start, stop, restart, reinstall, and delete containers.
- Configure CPU, memory, disk, traffic limits, and expiration time.
- Manage NAT4 port mappings, public IPv4 assignment, and public IPv6 assignment when the host network supports it.
- Open WebSSH or WebVNC from the browser.
- Manage image downloads, enablement, and local cache.
- Create, restore, and delete snapshots, plus scheduled snapshots and quotas.
- Generate security alerts based on connection behavior and keep audit logs.
- Create sub-user access links for specific containers.
- Integrate automation through API keys and `/api/v1`.
## Use Cases
- Quickly allocate multiple Linux containers on one host.
- Give users temporary access to a container console, SSH, VNC, or NAT port management.
- Automate container creation, resource changes, password resets, or resource cleanup through the API.
- Use a panel that is clearer than pure CLI workflows without becoming a heavy platform.
## Tech Stack
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
- Release: GitHub Actions builds Linux AMD64 release artifacts. The installer fetches the latest release by default.
+36
View File
@@ -0,0 +1,36 @@
# Quick Start
This is a common path from a fresh installation to your first container.
## 1. Log In
Open `http://YOUR_SERVER_IP:8999` and sign in with the administrator account.
After entering the panel, check:
- Whether the dashboard shows host resources.
- Whether Image Management can list templates.
- Whether NAT and IPv6 status in Routing match your host network.
## 2. Download an Image
Open Image Management, choose a template, and download it. On small hosts, lightweight images such as Alpine or Debian are a good first choice.
Image downloads run asynchronously. You can watch progress in the task queue.
## 3. Create a Container
Open Container Management and click Create:
- Select virtualization type and template.
- Set CPU, memory, and disk.
- Set traffic limits and expiration time.
- If external access is required, add NAT port mappings or assign IPv6 from the container details page after creation.
## 4. Open a Terminal
After the container is created, open WebSSH from the details page. KVM virtual machines can use WebVNC for console access.
## 5. Share with a Sub-user
If another user needs to manage a container, create an access link in Sub-user Management. The sub-user only sees authorized containers and is limited by the operation scope configured by the administrator.
+43
View File
@@ -0,0 +1,43 @@
# Upgrade
The CLICD installer and CLI are built around GitHub Release artifacts. Before upgrading, check the current version and back up configuration and database files.
## Check the Version
The current version is shown at the bottom of the web panel sidebar. You can also run:
```bash
curl http://127.0.0.1:8999/api/version
```
Example response:
```json
{
"success": true,
"data": {
"version": "1.1.6"
}
}
```
## Upgrade with the Installer
The installer uses the latest release by default:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
```
Install a specific version:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
```
## Pre-upgrade Checklist
- Back up `/root/.clicd/` or the actual configuration directory.
- Make sure no critical tasks are currently running.
- If an image download or snapshot restore is running, wait for it to finish first.
- After upgrading, check `systemctl status clicd` and the version shown in the web panel.
+23
View File
@@ -0,0 +1,23 @@
---
layout: home
hero:
name: CLICD
text: Lightweight LXC/KVM Virtualization Panel
tagline: Web console, CLI, container orchestration, NAT/IPv4/IPv6 routing, snapshots, security alerts, sub-users, and API automation.
actions:
- theme: brand
text: Install
link: /en/guide/installation
- theme: alt
text: API Reference
link: /en/features/api
features:
- title: Built for Small Hosts
details: Manage LXC containers and KVM virtual machines on a single VPS or dedicated server.
- title: Web and CLI Together
details: Use the web panel for daily operations, or drop into the clicd CLI for maintenance tasks.
- title: Automation Friendly
details: /api/v1 exposes containers, images, snapshots, security, logs, sub-users, and API key management.
---
+46
View File
@@ -0,0 +1,46 @@
# Deployment
CLICD can run directly on the host or behind a reverse proxy. In production, set up access control before exposing it to administrators.
## Service Exposure
The default web port is `8999`:
```text
http://YOUR_SERVER_IP:8999
```
Recommendations:
- Allow only fixed administrator IPs.
- Use a reverse proxy with HTTPS.
- Do not expose the real login URL in public docs or screenshots.
## systemd
Common commands:
```bash
systemctl status clicd
systemctl restart clicd
systemctl enable clicd
journalctl -u clicd -f
```
## Firewall
At minimum, confirm:
- The panel port is open only to trusted sources.
- NAT mapped ports are opened only as needed.
- The SSH management port does not conflict with container mappings.
- IPv6 firewall rules are planned together with IPv4 rules.
## Backups
Back up regularly:
- CLICD configuration directory.
- SQLite database.
- Container configuration.
- Snapshots or external data backups for important containers.
+29
View File
@@ -0,0 +1,29 @@
# FAQ
## 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`.
## Can I pin a specific version?
Yes:
```bash
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo CLICD_VERSION=v1.1.6 sh
```
## Can sub-users see every container?
No. Sub-users only see containers authorized by the administrator.
## Is an API key the same as the login password?
No. API keys are created on the API Integration page for programmatic access. The login password is used for the web panel.
## What happens after a container reaches its traffic limit?
The container is automatically shut down to avoid further overage. The administrator can adjust the limit or reset traffic usage.
## Why is IPv6 unreachable after assignment?
IPv6 reachability depends on the host and upstream network. Confirm that the host has a routable IPv6 prefix and that routing, firewall, neighbor discovery, or proxy configuration is correct.
+46
View File
@@ -0,0 +1,46 @@
# Troubleshooting
## Service Not Reachable
Check service status:
```bash
systemctl status clicd
journalctl -u clicd -n 100 --no-pager
```
Check port listening:
```bash
ss -lntp | grep 8999
```
If a reverse proxy is used, check proxy logs and upstream address settings as well.
## Image Download Failed
- Make sure the host can access image sources and GitHub Releases.
- Check disk space.
- Review the failure reason in the task queue.
- If a download is stuck, cancel it and start again.
## Container Cannot Access the Network
- Check host NAT and forwarding rules.
- Confirm that the container IP was assigned successfully.
- Check whether the firewall is blocking forwarded traffic.
- For IPv6, confirm that the upstream network routes the prefix to the host.
## WebSSH or WebVNC Connection Failed
- Confirm that the container or virtual machine is running.
- WebSSH requires SSH service inside the container.
- WebVNC requires the KVM console to be reachable.
- Tickets expire quickly. Create a new ticket after expiration.
## API Returns Unauthorized
- Confirm that the API key is not disabled.
- Use `X-API-Key` or `Authorization: Bearer`.
- Confirm that the key scope covers the target endpoint.
- Do not use the panel login password as an API key.
+600 -62
View File
@@ -1,6 +1,6 @@
# API 集成
CLICD 对外推荐使用 `/api/v1` 接口。旧版未带版本号的接口主要用于 Web 面板和兼容场景,新接入请优先使用 `/api/v1`
CLICD 继续兼容旧版 `/api` 接口,已有对接无需修改。新接入推荐使用 `/api/v1` 接口,下面的清单均为 v1;容器列表推荐 `GET /api/v1/containers`
## 认证
@@ -14,8 +14,81 @@ curl -H "X-API-Key: YOUR_API_KEY" https://panel.example.com/api/v1/containers
curl -H "Authorization: Bearer YOUR_API_KEY" https://panel.example.com/api/v1/dashboard
```
## 响应结构
所有接口保持统一响应包裹:
```json
{
"success": true,
"message": "OK",
"data": {}
}
```
对接时建议只读取业务所需字段。新增能力会优先追加可选字段,不会要求已有插件改掉现有字段名。
## 创建与重装
创建容器、批量创建、重装和批量重装已支持 NAT、公网 IPv4、IPv6 混合网络,以及 Linux SSH 登录方式配置。公网 IPv4/IPv6 地址池可通过 `GET /api/v1/routing` 查看,并可通过 `PUT /api/v1/routing` 更新。
创建容器示例:
```json
{
"name": "demo-lxc-01",
"virtualization": "lxc",
"template_id": "debian-bookworm",
"vcpu": 1,
"ram_mb": 512,
"disk_gb": 10,
"assign_nat": true,
"port_mapping_count": 2,
"assign_ipv4": false,
"ipv4_count": 1,
"public_ipv4s": [],
"assign_ipv6": true,
"ipv6_count": 1,
"ipv6_addresses": [],
"ssh_auth_mode": "auto_password",
"ssh_password": "",
"ssh_public_key": "",
"expires_at": ""
}
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `assign_nat` | 是否分配 NAT 端口映射;不传时保持默认 NAT 行为。 |
| `assign_ipv4` | 是否分配公网 IPv4。 |
| `ipv4_count` | 自动分配公网 IPv4 数量。 |
| `public_ipv4s` | 指定公网 IPv4 地址列表。 |
| `assign_ipv6` | 是否分配 IPv6。 |
| `ipv6_count` | 自动分配 IPv6 数量。 |
| `ipv6_addresses` | 指定 IPv6 地址列表。 |
| `ssh_auth_mode` | Linux 创建支持 `auto_password``password``key`;重装额外支持 `keep`。 |
| `ssh_password` | `password` 模式下的自定义密码;8-64 位,至少包含字母和数字,不能包含空白字符。 |
| `ssh_public_key` | `key` 模式下的一行 SSH 公钥。 |
重装示例:
```json
{
"template_id": "debian-bookworm",
"ssh_auth_mode": "keep",
"ssh_password": "",
"ssh_public_key": ""
}
```
`keep` 仅用于重装,表示沿用当前 SSH 密码。Windows KVM 镜像会忽略 Linux SSH 公钥相关字段。
## Python 示例
获取容器列表:
```python
import requests
@@ -30,9 +103,7 @@ session.headers.update({
resp = session.get(f"{BASE_URL}/api/v1/containers", timeout=15)
resp.raise_for_status()
containers = resp.json()
print(containers)
print(resp.json())
```
创建端口映射:
@@ -45,10 +116,10 @@ API_KEY = "YOUR_API_KEY"
CONTAINER_ID = "example-vm"
payload = {
"name": "web",
"protocol": "tcp",
"host_port": 18080,
"container_port": 80,
"description": "web",
}
resp = requests.post(
@@ -61,76 +132,543 @@ resp.raise_for_status()
print(resp.json())
```
## 返回结构示例
## 接口清单
容器列表:
```json
{
"success": true,
"data": [
{
"id": 5,
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "example-vm",
"status": "running",
"ip": "10.0.3.25",
"ipv6": "2001:db8:100::1005",
"cpu_limit": 2,
"memory_limit": 2048,
"disk_limit": 20480,
"traffic_limit": 107374182400,
"expires_at": "2026-12-31 23:59:59"
}
]
}
```
任务队列:
```json
{
"success": true,
"data": [
{
"id": "task-13",
"type": "restart",
"status": "running",
"created_at": "2026-06-09T10:00:00+08:00"
}
]
}
```
WebSSH 票据:
```json
{
"success": true,
"data": {
"ticket": "***60秒有效票据***"
}
}
```
## 常用接口
### 总览
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/dashboard` | 控制面板统计 |
| GET | `/api/v1/host-info` | 主机资源 |
| GET | `/api/v1/routing` | NAT/IPv4/IPv6 路由 |
| PUT | `/api/v1/routing` | 更新公网 IPv4/IPv6 池 |
| POST | `/api/v1/routing/ipv4-scan` | 扫描公网 IPv4 段 |
| GET | `/api/v1/ipv6/status` | IPv6 状态 |
| GET | `/api/v1/tasks` | 任务队列 |
| DELETE | `/api/v1/tasks/{task_id}` | 删除任务 |
### 容器
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/containers` | 容器列表 |
| POST | `/api/v1/containers/list` | 容器列表兼容 POST 写法 |
| POST | `/api/v1/containers` | 创建容器 |
| GET | `/api/v1/containers/{id|uuid|name}` | 容器详情 |
| POST | `/api/v1/containers/{id}/start` | 开机 |
| POST | `/api/v1/containers/{id}/stop` | 关机 |
| POST | `/api/v1/containers/{id}/restart` | 重启 |
| POST | `/api/v1/containers/{id}/reinstall` | 重装 |
| DELETE | `/api/v1/containers/{id}/delete` | 删除 |
| GET | `/api/v1/tasks` | 任务队列 |
| GET | `/api/v1/containers/{id}/usage` | 资源用量 |
| GET | `/api/v1/containers/{id}/traffic` | 流量统计 |
| POST | `/api/v1/containers/{id}/traffic-reset` | 重置流量 |
| PUT | `/api/v1/containers/{id}/traffic-limit` | 调整流量限制 |
| PUT | `/api/v1/containers/{id}/resource-limit` | 调整资源限制 |
| PUT | `/api/v1/containers/{id}/expiry` | 调整到期时间 |
| POST | `/api/v1/containers/{id}/reset-password` | 重置 SSH 密码 |
| POST | `/api/v1/containers/{id}/ipv6` | 分配 IPv6 |
### 端口与快照
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/containers/{id}/random-port` | 随机可用端口 |
| POST | `/api/v1/containers/{id}/port-mappings` | 添加端口映射 |
| PUT | `/api/v1/containers/{id}/port-mappings/{index}` | 更新端口映射 |
| DELETE | `/api/v1/containers/{id}/port-mappings/{index}` | 删除端口映射 |
| GET | `/api/v1/snapshots` | 快照总览 |
| GET | `/api/v1/containers/{id}/snapshots` | 容器快照 |
| POST | `/api/v1/containers/{id}/snapshots` | 创建快照 |
| DELETE | `/api/v1/containers/{id}/snapshots/{snapshot_id}` | 删除快照 |
| POST | `/api/v1/containers/{id}/snapshots/{snapshot_id}/restore` | 恢复快照 |
| POST | `/api/v1/containers/{id}/snapshots/schedule` | 计划快照 |
| PUT | `/api/v1/containers/{id}/snapshots/quota` | 快照配额 |
### 平台管理
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/templates` | 模板列表 |
| GET | `/api/v1/images` | 镜像管理列表 |
| GET | `/api/v1/snapshots` | 快照总览 |
| POST | `/api/v1/images/download` | 下载镜像 |
| POST | `/api/v1/images/cancel` | 取消镜像下载 |
| DELETE | `/api/v1/images/delete` | 删除镜像缓存 |
| PUT | `/api/v1/images/toggle` | 启用/禁用镜像 |
| GET | `/api/v1/security/alerts` | 安全告警 |
| GET | `/api/v1/audit-logs` | 操作日志 |
| GET | `/api/v1/api-keys` | API Key 列表 |
| POST | `/api/v1/security/check` | 立即安全检查 |
| GET | `/api/v1/security/logs?container={name}` | 安全连接日志 |
| GET | `/api/v1/security/summary` | 安全汇总 |
| GET | `/api/v1/security/settings` | 安全设置 |
| PUT | `/api/v1/security/settings` | 更新安全设置 |
| GET | `/api/v1/swap` | Swap 信息 |
| POST | `/api/v1/swap` | 调整 Swap |
| POST | `/api/v1/batch-create` | 批量创建容器 |
| POST | `/api/v1/batch-action` | 批量开关机/删除/重装 |
| POST | `/api/v1/ssh-ticket` | 创建 WebSSH 票据 |
| POST | `/api/v1/vnc-ticket` | 创建 WebVNC 票据 |
完整接口清单请以面板内“API 集成”页面为准。
### 账号与日志
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/api/v1/sub-user/create` | 创建子用户链接 |
| GET | `/api/v1/sub-users` | 子用户列表 |
| POST | `/api/v1/sub-users/{id}/rotate-password` | 轮换子用户密码 |
| GET | `/api/v1/sub-users/{id}/audit-logs` | 子用户操作日志 |
| GET | `/api/v1/sub-users/{id}/login-logs` | 子用户登录日志 |
| GET | `/api/v1/audit-logs` | 操作日志 |
| GET | `/api/v1/login-logs` | 登录日志 |
| GET | `/api/v1/api-keys` | API Key 列表 |
| POST | `/api/v1/api-keys` | 创建 API Key |
| PATCH | `/api/v1/api-keys/{id}` | 更新 API Key |
| DELETE | `/api/v1/api-keys/{id}` | 删除 API Key |
## 返回样例
以下样例按接口路径分组。真实环境中的资源数值、任务 ID、容器 ID、时间、IP 和密钥会不同,示例中的密码、票据和 API Key 均已脱敏。
### 总览
```json
{
"GET /api/v1/dashboard": {
"success": true,
"data": {
"running": 31,
"stopped": 0,
"total_containers": 31
}
},
"GET /api/v1/host-info": {
"success": true,
"data": {
"cpu": { "cores": 8, "usage_pct": 1.16 },
"ram": { "total_mb": 31825, "used_mb": 1275, "free_mb": 30550 },
"disk": { "total_gb": 1750.49, "used_gb": 123.98, "free_gb": 1626.51 },
"network": {
"public_ipv4": "203.0.113.10",
"public_ipv4_interface": "eth0",
"public_ipv6": "2001:db8:100::2",
"public_ipv6_interface": "eth0"
},
"load": { "load1": 0.01, "load5": 0.03, "load15": 0.01 }
}
},
"GET /api/v1/routing": {
"success": true,
"data": {
"nat4": { "used": 62, "remaining": "45474", "total": "45536" },
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
"ipv6": { "used": 31, "remaining": "large", "total": "large" },
"public_ipv4_addresses": [
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
],
"ipv4_assignments": [
{ "container_id": 5, "container_name": "example-vm", "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
],
"nat4_mappings": [
{ "container_id": 5, "container_name": "example-vm", "status": "running", "ip": "10.0.0.10", "host_port": 22004, "container_port": 22, "protocol": "tcp" }
],
"ipv6_assignments": [
{ "container_id": 5, "container_name": "example-vm", "address": "2001:db8:100::1005", "prefix_len": 64, "interface": "eth0" }
]
}
},
"PUT /api/v1/routing": {
"success": true,
"data": {
"ipv4": { "used": 1, "remaining": "3", "total": "4" },
"public_ipv4_addresses": [
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1" }
],
"ipv6_prefixes": [
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
]
}
},
"POST /api/v1/routing/ipv4-scan": {
"success": true,
"data": [
{ "address": "203.0.113.10", "interface": "eth0", "prefix_len": 32, "gateway": "203.0.113.1", "status": "available", "usable": true, "reason": "" }
]
},
"GET /api/v1/ipv6/status": {
"success": true,
"data": {
"available": true,
"reachable": true,
"reason": "usable public IPv6 prefix detected",
"prefixes": [
{ "interface": "eth0", "address": "2001:db8:100::2", "prefix": "2001:db8:100::/64", "prefix_len": 64, "gateway": "2001:db8:100::1" }
]
}
},
"GET /api/v1/tasks": {
"success": true,
"data": []
},
"DELETE /api/v1/tasks/{task_id}": {
"success": true,
"message": "Task deleted"
}
}
```
### 容器
```json
{
"GET /api/v1/containers": {
"success": true,
"data": [
{
"id": 5,
"uuid": "00000000-0000-4000-8000-000000000005",
"name": "example-vm",
"virtualization": "lxc",
"template": "debian-bullseye",
"vcpu": 1,
"ram_mb": 512,
"disk_gb": 10,
"status": "running",
"ip": "10.0.0.10",
"ipv6": "2001:db8:100::1005",
"ssh_port": 22004,
"ssh_password": "***",
"port_mappings": [
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
{ "container_port": 20000, "host_port": 20000, "protocol": "tcp", "description": "Port-20000" }
]
}
]
},
"POST /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": {
"success": true,
"message": "Container created successfully"
},
"GET /api/v1/containers/{id|uuid|name}": {
"success": true,
"data": {
"id": 5,
"uuid": "00000000-0000-4000-8000-000000000005",
"name": "example-vm",
"status": "running",
"ip": "10.0.0.10",
"ipv6": "2001:db8:100::1005",
"ssh_port": 22004,
"ssh_password": "***",
"policy_blocked": false
}
},
"POST /api/v1/containers/{id}/start": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "start" }
},
"POST /api/v1/containers/{id}/stop": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "stop" }
},
"POST /api/v1/containers/{id}/restart": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "restart" }
},
"POST /api/v1/containers/{id}/reinstall": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "reinstall" }
},
"DELETE /api/v1/containers/{id}/delete": {
"success": true,
"message": "Task queued",
"data": { "task_id": "task-10", "container_name": "example-vm", "status": "pending", "action": "delete" }
},
"GET /api/v1/containers/{id}/usage": {
"success": true,
"data": {
"cpu_usage_pct": 0,
"cpu_usage_usec": 3908852,
"memory_usage_bytes": 29331456,
"disk_usage_bytes": 515100672,
"network_rx_bytes": 131232,
"network_tx_bytes": 16828,
"load1": 0.1,
"load5": 0.06,
"load15": 0.01
}
},
"GET /api/v1/containers/{id}/traffic": {
"success": true,
"data": {
"mode": "total",
"limit_gb": 0,
"in_limit_gb": 0,
"out_limit_gb": 0,
"total_used_bytes": 142082,
"rx_used_bytes": 127212,
"tx_used_bytes": 14870,
"used_pct": 0,
"reset_date": "2026-06"
}
},
"POST /api/v1/containers/{id}/traffic-reset": {
"success": true,
"message": "Traffic reset"
},
"PUT /api/v1/containers/{id}/traffic-limit": {
"success": true,
"message": "Traffic limit updated"
},
"PUT /api/v1/containers/{id}/resource-limit": {
"success": true,
"message": "Resource limits 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}/ipv6": {
"success": true,
"message": "IPv6 assigned",
"data": { "id": 5, "name": "example-vm", "ipv6": "2001:db8:100::1005" }
}
}
```
### 端口与快照
```json
{
"GET /api/v1/containers/{id}/random-port": {
"success": true,
"data": { "port": 61320 }
},
"POST /api/v1/containers/{id}/port-mappings": {
"success": true,
"data": [
{ "container_port": 22, "host_port": 22004, "protocol": "tcp", "description": "SSH" },
{ "container_port": 8080, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
]
},
"PUT /api/v1/containers/{id}/port-mappings/{index}": {
"success": true,
"data": [
{ "container_port": 8081, "host_port": 61320, "protocol": "tcp", "description": "HTTP" }
]
},
"DELETE /api/v1/containers/{id}/port-mappings/{index}": {
"success": true,
"data": []
},
"GET /api/v1/snapshots": {
"success": true,
"data": null
},
"GET /api/v1/containers/{id}/snapshots": {
"success": true,
"data": {
"quota": 1,
"schedule": { "enabled": false, "interval_hours": 0, "last_run": "", "next_run": "", "time": "", "created_by": "" },
"snapshots": []
}
},
"POST /api/v1/containers/{id}/snapshots": {
"success": true,
"data": {
"id": "snap-20260608-001",
"container_id": 5,
"container_name": "example-vm",
"created_at": "2026-06-08 16:00:00",
"created_by": "api:Automation",
"scheduled": false,
"size_bytes": 10485760
}
},
"DELETE /api/v1/containers/{id}/snapshots/{snapshot_id}": {
"success": true,
"message": "Snapshot deleted"
},
"POST /api/v1/containers/{id}/snapshots/{snapshot_id}/restore": {
"success": true,
"message": "Snapshot restored"
},
"POST /api/v1/containers/{id}/snapshots/schedule": {
"success": true,
"data": {
"container": { "id": 5, "name": "example-vm", "snapshot_schedule_enabled": true, "snapshot_schedule_interval_hours": 24, "snapshot_schedule_time": "03:00" }
}
},
"PUT /api/v1/containers/{id}/snapshots/quota": {
"success": true,
"data": {
"quota": 2,
"container": { "id": 5, "name": "example-vm", "snapshot_limit": 2 }
}
}
}
```
### 平台管理
```json
{
"GET /api/v1/templates": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "distro": "ubuntu", "release": "noble", "arch": "amd64", "description": "Ubuntu 24.04 LTS" },
{ "id": "debian-bookworm", "name": "Debian 12", "distro": "debian", "release": "bookworm", "arch": "amd64", "description": "Debian 12 (Bookworm)" }
]
},
"GET /api/v1/images": {
"success": true,
"data": [
{ "id": "ubuntu-noble", "name": "Ubuntu 24.04", "type": "lxc", "downloaded": true, "enabled": true, "downloading": false, "progress": 0, "size_bytes": 135005452 }
]
},
"POST /api/v1/images/download": {
"success": true,
"message": "Already downloaded"
},
"POST /api/v1/images/cancel": {
"success": true,
"message": "Cancel requested"
},
"DELETE /api/v1/images/delete": {
"success": true,
"message": "Deleted"
},
"PUT /api/v1/images/toggle": {
"success": true,
"message": "OK"
},
"GET /api/v1/security/alerts": {
"success": true,
"data": []
},
"POST /api/v1/security/check": {
"success": true,
"message": "Security check completed"
},
"GET /api/v1/security/logs?container={name}": {
"success": true,
"data": []
},
"GET /api/v1/security/summary": {
"success": true,
"data": { "critical": 0, "high": 0, "medium": 0, "low": 0, "total_alerts": 0 }
},
"GET /api/v1/security/settings": {
"success": true,
"data": { "auto_shutdown": false }
},
"PUT /api/v1/security/settings": {
"success": true,
"data": { "auto_shutdown": false }
},
"GET /api/v1/swap": {
"success": true,
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
},
"POST /api/v1/swap": {
"success": true,
"message": "SWAP 已调整为 16384 MB",
"data": { "total_mb": 16383, "used_mb": 0, "free_mb": 16383, "enabled": true, "swap_file": "/swapfile" }
},
"POST /api/v1/batch-create": {
"success": true,
"data": ["task-12"]
},
"POST /api/v1/batch-action": {
"success": true,
"data": ["task-13"]
},
"POST /api/v1/ssh-ticket": {
"success": true,
"data": { "ticket": "***60秒有效票据***" }
},
"POST /api/v1/vnc-ticket": {
"success": true,
"data": { "ticket": "***60秒有效票据***" }
}
}
```
### 账号与日志
```json
{
"POST /api/v1/sub-user/create": {
"success": true,
"message": "Sub-user created",
"data": {
"id": "sub-xxxxxxxx",
"username": "user-xxxxxxxx",
"password": "***",
"container_names": ["example-vm"],
"access_code": "********",
"created_at": "2026-06-08 16:00:00"
}
},
"GET /api/v1/sub-users": {
"success": true,
"data": []
},
"POST /api/v1/sub-users/{id}/rotate-password": {
"success": true,
"data": { "username": "user-xxxxxxxx", "password": "***", "access_code": "********" }
},
"GET /api/v1/sub-users/{id}/audit-logs": {
"success": true,
"data": []
},
"GET /api/v1/sub-users/{id}/login-logs": {
"success": true,
"data": []
},
"GET /api/v1/audit-logs": {
"success": true,
"data": [
{ "time": "2026-06-08 15:44:40", "action": "apikey.create", "target": "Test", "detail": "scopes=*", "user": "admin", "success": true }
]
},
"GET /api/v1/login-logs": {
"success": true,
"data": [
{ "time": "2026-06-08 08:24:00 UTC", "username": "admin", "ip": "198.51.100.23", "user_agent": "Mozilla/5.0 ...", "success": true }
]
},
"GET /api/v1/api-keys": {
"success": true,
"data": [
{ "id": "c271023f", "name": "Test", "prefix": "clicd_sk_dd9d...", "ip_whitelist": "", "created_at": "2026-06-08 15:44:40", "last_used": "2026-06-08 15:46:10", "scopes": ["*"], "last_used_ip": "198.51.100.23" }
]
},
"POST /api/v1/api-keys": {
"success": true,
"message": "API key created. Save this key now - it won't be shown again.",
"data": { "id": "a1b2c3d4", "name": "Automation", "key": "clicd_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"] }
},
"PATCH /api/v1/api-keys/{id}": {
"success": true,
"data": { "id": "a1b2c3d4", "name": "Automation", "prefix": "clicd_sk_xxxx...", "scopes": ["dashboard:read", "container:read"], "disabled": false }
},
"DELETE /api/v1/api-keys/{id}": {
"success": true,
"message": "API key deleted"
}
}
```
+10
View File
@@ -25,6 +25,14 @@ POST /api/v1/containers
POST /api/v1/batch-create
```
Linux 容器和 Linux KVM 虚拟机创建时支持配置 SSH 登录方式:
- `auto_password`:自动生成 root SSH 密码。
- `password`:使用自定义 `ssh_password`
- `key`:写入一行 `ssh_public_key`,仍会保留可用于 WebSSH 的密码。
网络分配可以按需组合 NAT、公网 IPv4 和 IPv6。API 字段保持为 `assign_nat``assign_ipv4``public_ipv4s``assign_ipv6``ipv6_addresses` 等可选字段,未传时沿用默认行为。
## 生命周期操作
```http
@@ -37,6 +45,8 @@ DELETE /api/v1/containers/{id}/delete
开关机、重装、删除等操作会进入任务队列。调用后可通过 `GET /api/v1/tasks` 查看执行状态。
重装 Linux 系统时可传 `ssh_auth_mode``ssh_password``ssh_public_key``ssh_auth_mode=keep` 表示沿用当前 SSH 密码;不传这些字段时保持旧行为。
## 资源与流量
容器详情页支持查看资源用量,调整流量限制、资源限制和到期时间。
+23 -1
View File
@@ -1,6 +1,6 @@
# 网络与路由
CLICD 提供 NAT4 端口映射、随机可用端口、IPv6 状态检查和 IPv6 分配能力。
CLICD 提供 NAT4 端口映射、随机可用端口、公网 IPv4 分配、IPv6 状态检查和 IPv6 分配能力。创建容器时可以只分配 NAT、只分配公网 IPv4、只分配 IPv6,或按需混合使用。
## NAT4
@@ -30,6 +30,28 @@ POST /api/v1/containers/{id}/ipv6
如果宿主机没有公网 IPv6 或上游没有正确路由,面板中分配出的地址也无法从公网访问。
## 公网 IPv4
公网 IPv4 分配会从主机检测到的可用公网 IPv4 中选择地址,或使用 API 指定的 `public_ipv4s`。创建容器时可使用:
| 字段 | 说明 |
| --- | --- |
| `assign_nat` | 是否启用 NAT 端口映射。 |
| `assign_ipv4` | 是否分配公网 IPv4。 |
| `ipv4_count` | 自动分配公网 IPv4 数量。 |
| `public_ipv4s` | 指定公网 IPv4 地址列表。 |
| `assign_ipv6` | 是否分配 IPv6。 |
| `ipv6_count` | 自动分配 IPv6 数量。 |
| `ipv6_addresses` | 指定 IPv6 地址列表。 |
公网地址池相关接口:
```http
GET /api/v1/routing
PUT /api/v1/routing
POST /api/v1/routing/ipv4-scan
```
## 路由状态
```http
+245 -128
View File
@@ -369,9 +369,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
@@ -382,13 +382,13 @@
"aix"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
@@ -399,13 +399,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
@@ -416,13 +416,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
@@ -433,13 +433,13 @@
"android"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
@@ -450,13 +450,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
@@ -467,13 +467,13 @@
"darwin"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
@@ -484,13 +484,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
@@ -501,13 +501,13 @@
"freebsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
@@ -518,13 +518,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
@@ -535,13 +535,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
@@ -552,13 +552,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
@@ -569,13 +569,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
@@ -586,13 +586,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
@@ -603,13 +603,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
@@ -620,13 +620,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
@@ -637,13 +637,13 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@@ -654,13 +654,30 @@
"linux"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
@@ -671,13 +688,30 @@
"netbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
@@ -688,13 +722,30 @@
"openbsd"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
@@ -705,13 +756,13 @@
"sunos"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
@@ -722,13 +773,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
@@ -739,13 +790,13 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@@ -756,7 +807,7 @@
"win32"
],
"engines": {
"node": ">=12"
"node": ">=18"
}
},
"node_modules/@iconify-json/simple-icons": {
@@ -1706,9 +1757,9 @@
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1716,32 +1767,35 @@
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/estree-walker": {
@@ -1751,6 +1805,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/focus-trap": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
@@ -2037,6 +2109,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -2258,6 +2343,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -2373,21 +2475,24 @@
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -2396,19 +2501,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || >=20.0.0",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
@@ -2429,6 +2540,12 @@
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
+3
View File
@@ -9,5 +9,8 @@
},
"devDependencies": {
"vitepress": "^1.6.4"
},
"overrides": {
"vite": "6.4.2"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLICD - LXC Container Manager</title>
<title>CLICD - Container Manager</title>
<script>
(function() {
var theme = localStorage.getItem('clicd_theme');
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.1.7",
"version": "1.1.15",
"type": "module",
"scripts": {
"dev": "vite",
+182
View File
@@ -0,0 +1,182 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useLanguage } from '../contexts/LanguageContext'
import { shouldTranslateText, translateText } from '../utils/i18n'
const translatedTitleAttr = 'data-i18n-title-original'
const translatedPlaceholderAttr = 'data-i18n-placeholder-original'
const translatedAriaLabelAttr = 'data-i18n-aria-label-original'
const attributeNames = ['title', 'placeholder', 'aria-label'] as const
const translatedTextNodes = new Set<Text>()
const textOriginals = new WeakMap<Text, string>()
const wholeTextSelector = 'button,a,span,label,option,th,td,p,h1,h2,h3,h4,small'
export default function AutoTranslate() {
const { language } = useLanguage()
const location = useLocation()
useEffect(() => {
if (language === 'zh') {
restoreTranslatedNodes(document.body)
return
}
translateNode(document.body)
const pending = new Set<Node>()
let scheduled = false
const flush = () => {
scheduled = false
const nodes = Array.from(pending)
pending.clear()
for (const node of nodes) {
if (node.isConnected) translateNode(node)
}
}
const schedule = (node: Node) => {
pending.add(node)
if (scheduled) return
scheduled = true
window.requestAnimationFrame(flush)
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(schedule)
} else {
schedule(mutation.target)
}
}
})
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: [...attributeNames],
})
return () => observer.disconnect()
}, [language, location.pathname, location.search])
return null
}
function translateNode(root: Node) {
if (root.nodeType === Node.TEXT_NODE) {
translateTextNode(root as Text)
return
}
if (!(root instanceof Element)) return
if (shouldSkipElement(root)) return
translateWholeTextElement(root)
root.querySelectorAll<HTMLElement>(wholeTextSelector).forEach(translateWholeTextElement)
translateElementAttributes(root)
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node.textContent || !shouldTranslateText(node.textContent)) return NodeFilter.FILTER_REJECT
const parent = node.parentElement
if (!parent || shouldSkipElement(parent)) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
},
})
const nodes: Text[] = []
while (walker.nextNode()) nodes.push(walker.currentNode as Text)
for (const node of nodes) translateTextNode(node)
root.querySelectorAll<HTMLElement>('[title], [placeholder], [aria-label]').forEach(translateElementAttributes)
}
function translateTextNode(node: Text) {
const original = node.textContent || ''
if (!shouldTranslateText(original)) return
const parent = node.parentElement
if (!parent || shouldSkipElement(parent)) return
const translated = translateText(original)
if (translated === original) return
textOriginals.set(node, original)
translatedTextNodes.add(node)
node.textContent = translated
}
function translateWholeTextElement(el: Element) {
if (!(el instanceof HTMLElement) || shouldSkipElement(el) || !isSimpleTextElement(el)) return
const original = el.textContent || ''
if (!shouldTranslateText(original)) return
const translated = translateText(original)
if (translated === original) return
const textNodes = directTextNodes(el)
if (textNodes.length === 0) return
textNodes.forEach((node, index) => {
textOriginals.set(node, node.textContent || '')
translatedTextNodes.add(node)
node.textContent = index === 0 ? translated : ''
})
}
function directTextNodes(el: HTMLElement) {
return Array.from(el.childNodes).filter((node): node is Text => node.nodeType === Node.TEXT_NODE)
}
function translateElementAttributes(el: Element) {
if (!(el instanceof HTMLElement)) return
translateAttribute(el, 'title', translatedTitleAttr)
translateAttribute(el, 'placeholder', translatedPlaceholderAttr)
translateAttribute(el, 'aria-label', translatedAriaLabelAttr)
}
function restoreTranslatedNodes(root: ParentNode) {
for (const node of Array.from(translatedTextNodes)) {
if (!node.isConnected) {
translatedTextNodes.delete(node)
continue
}
if (root instanceof Document || root.contains(node)) {
node.textContent = textOriginals.get(node) || node.textContent
translatedTextNodes.delete(node)
}
}
root.querySelectorAll<HTMLElement>(`[${translatedTitleAttr}]`).forEach((el) => {
el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '')
el.removeAttribute(translatedTitleAttr)
})
root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[${translatedPlaceholderAttr}]`).forEach((el) => {
el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '')
el.removeAttribute(translatedPlaceholderAttr)
})
root.querySelectorAll<HTMLElement>(`[${translatedAriaLabelAttr}]`).forEach((el) => {
el.setAttribute('aria-label', el.getAttribute(translatedAriaLabelAttr) || '')
el.removeAttribute(translatedAriaLabelAttr)
})
}
function translateAttribute(el: HTMLElement, attr: 'title' | 'placeholder' | 'aria-label', originalAttr: string) {
const storedOriginal = el.getAttribute(originalAttr)
const original = storedOriginal || el.getAttribute(attr) || ''
if (!shouldTranslateText(original)) return
const translated = translateText(original)
if (translated === original) return
if (!storedOriginal) {
el.setAttribute(originalAttr, original)
}
if (el.getAttribute(attr) !== translated) {
el.setAttribute(attr, translated)
}
}
function shouldSkipElement(el: Element) {
return !!el.closest('script, style, code, pre, textarea, [data-no-translate]')
}
function isSimpleTextElement(el: HTMLElement) {
if (!el.matches(wholeTextSelector)) return false
if (el.querySelector('input, textarea, select, button, table, pre, code, canvas, iframe')) return false
const textNodes = directTextNodes(el)
if (textNodes.length === 0) return false
return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'svg')
}
@@ -0,0 +1,19 @@
import { useEffect } from 'react'
import { useLanguage } from '../contexts/LanguageContext'
export default function BrowserDialogTranslator() {
const { t } = useLanguage()
useEffect(() => {
const originalAlert = window.alert
const originalConfirm = window.confirm
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
return () => {
window.alert = originalAlert
window.confirm = originalConfirm
}
}, [t])
return null
}
+337 -74
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, X } from 'lucide-react'
import { CalendarClock, RefreshCw, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
import { useDialog } from './Dialog'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type SSHAuthMode } from '../utils/sshAuth'
interface CreateContainerModalProps {
isOpen: boolean
@@ -26,13 +28,24 @@ const defaultForm: CreateContainerRequest = {
io_speed_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
assign_nat: true,
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
assign_ipv6: false,
ipv6_count: 1,
ipv6_addresses: [],
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const dialog = useDialog()
const { language } = useLanguage()
const networkText = createNetworkText[language]
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
const [batchCount, setBatchCount] = useState(1)
@@ -74,16 +87,25 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
}, [isOpen, form.virtualization])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const ipv6Prefixes = ipv6Status?.prefixes || []
const ipv6Prefix = ipv6Prefixes.length > 1 ? `${ipv6Prefixes.length} prefixes configured` : (ipv6Prefixes[0]?.prefix || '')
const publicIPv4s = hostInfo?.network.public_ipv4_addresses || []
const ipv4Available = publicIPv4s.length > 0
const manualIPv4s = form.public_ipv4s || []
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
const natEnabled = form.assign_nat !== false
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0
const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
const autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count)
if (!natEnabled) return []
const count = natPortCount
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
}, [form.port_mapping_count])
}, [natEnabled, natPortCount])
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
@@ -127,7 +149,19 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return
}
if (!form.assign_ipv4 && !form.assign_ipv6 && form.assign_nat === false) {
dialog.alert('提示', '请勾选任意一个可用网络')
return
}
const authError = validateSSHAuthInputs(form)
if (authError) {
dialog.alert('登录方式有误', authError)
return
}
const boundedForm = normalizeCreateForm(form)
const wantsNAT = boundedForm.assign_nat !== false
// Build batch of containers
const containers: CreateContainerRequest[] = []
@@ -137,8 +171,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
containers.push({
...boundedForm,
name,
port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2),
assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0,
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
extra_ports: [],
})
}
@@ -229,21 +266,206 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<input
type="checkbox"
checked={!!form.assign_ipv6}
disabled={!ipv6Available}
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">Public IPv6</span>
<span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
{linuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800"></div>
<div className="grid grid-cols-3 gap-2">
{([
['auto_password', '自动生成密码'],
['password', '自定义密码'],
['key', 'SSH Key'],
] as Array<[SSHAuthMode, string]>).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setForm({ ...form, ssh_auth_mode: mode })}
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${sshAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{label}
</button>
))}
</div>
{sshAuthMode === 'password' && (
<div className="mt-3 flex gap-2">
<input
type="text"
value={form.ssh_password || ''}
onChange={(event) => setForm({ ...form, ssh_password: event.target.value })}
className={inputClass}
placeholder="RootPass123"
/>
<button
type="button"
onClick={() => setForm({ ...form, ssh_password: generateSSHPassword() })}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
title="生成密码"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{sshAuthMode === 'key' && (
<textarea
value={form.ssh_public_key || ''}
onChange={(event) => setForm({ ...form, ssh_public_key: event.target.value })}
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
placeholder="ssh-ed25519 AAAA..."
/>
)}
</div>
)}
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={!!form.assign_ipv4}
disabled={!ipv4Available}
onChange={(event) => setForm({ ...form, assign_ipv4: event.target.checked, public_ipv4s: event.target.checked ? form.public_ipv4s : [] })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv4}</span>
<span className="block text-xs text-gray-500">
{ipv4Available ? formatAllocatableIPv4Count(publicIPv4s.length, language) : networkText.noAllocatableIPv4}
</span>
</span>
</span>
</label>
</label>
{form.assign_ipv4 && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={manualIPv4s.length === 0}
onChange={() => setForm({ ...form, public_ipv4s: [] })}
/>
Auto assign
</label>
<Field label="IPv4 count">
<NumberInput
value={form.ipv4_count || 1}
min={1}
max={Math.max(1, publicIPv4s.length)}
onChange={(value) => setForm({ ...form, ipv4_count: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
</div>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={manualIPv4s.length > 0}
onChange={() => setForm({ ...form, public_ipv4s: publicIPv4s[0]?.address ? [publicIPv4s[0].address] : [], ipv4_count: 1 })}
/>
Manual select
</label>
{manualIPv4s.length > 0 && (
<div className="grid gap-1.5 sm:grid-cols-2">
{publicIPv4s.map((ip) => (
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded border border-gray-200 px-2 py-1.5 text-xs text-gray-700">
<input
type="checkbox"
checked={manualIPv4s.includes(ip.address)}
onChange={(event) => {
const next = event.target.checked
? [...manualIPv4s, ip.address]
: manualIPv4s.filter((value) => value !== ip.address)
setForm({ ...form, public_ipv4s: next, ipv4_count: 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>
<div className={`rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={!!form.assign_ipv6}
disabled={!ipv6Available}
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
<span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
</span>
</span>
</label>
{form.assign_ipv6 && (
<span className="block w-24 shrink-0">
<NumberInput
value={form.ipv6_count || 1}
min={1}
max={64}
onChange={(value) => setForm({ ...form, ipv6_count: Math.max(1, Math.round(value || 1)) })}
/>
</span>
)}
</div>
</div>
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
<div className="flex items-start justify-between gap-3">
<label className="flex min-w-0 flex-1 items-start gap-3">
<input
type="checkbox"
checked={natEnabled}
onChange={(event) => {
const checked = event.target.checked
setForm({
...form,
assign_nat: checked,
port_mapping_count: checked ? Math.max(2, form.port_mapping_count || 2) : 0,
extra_ports: [],
})
}}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">{networkText.publicNAT}</span>
<span className="block text-xs text-gray-500">
{natEnabled ? formatNATPortCount(natPortCount, language) : networkText.noNATPorts}
</span>
</span>
</label>
{natEnabled && (
<span className="block w-24 shrink-0">
<NumberInput
value={natPortCount}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })}
/>
</span>
)}
</div>
{natEnabled && (
<div className="mt-2 pl-6">
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span>
{autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="vCPU">
@@ -289,64 +511,47 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field>
</div>
{/* Traffic control */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-gray-700"></label>
<select
value={form.traffic_mode}
onChange={(e) => setForm({ ...form, traffic_mode: e.target.value })}
className="h-8 px-2 border border-gray-300 rounded text-xs text-gray-600 bg-white"
>
<option value="total"></option>
<option value="in_out">/</option>
</select>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Traffic control */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-gray-700"></label>
<select
value={form.traffic_mode}
onChange={(e) => setForm({ ...form, traffic_mode: e.target.value })}
className="h-8 px-2 border border-gray-300 rounded text-xs text-gray-600 bg-white"
>
<option value="total"></option>
<option value="in_out">/</option>
</select>
</div>
{form.traffic_mode === 'total' ? (
<div className="flex items-center gap-2">
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
<span className="text-xs text-gray-400">GB (0=)</span>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label="入站 (GB)">
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
</Field>
<Field label="出站 (GB)">
<NumberInput value={form.traffic_out_gb} min={0} onChange={(value) => setForm({ ...form, traffic_out_gb: value || 0 })} />
</Field>
</div>
)}
</div>
{form.traffic_mode === 'total' ? (
<div className="flex items-center gap-2">
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
<span className="text-xs text-gray-400">GB (0=)</span>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label="入站 (GB)">
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
</Field>
<Field label="出站 (GB)">
<NumberInput value={form.traffic_out_gb} min={0} onChange={(value) => setForm({ ...form, traffic_out_gb: value || 0 })} />
</Field>
</div>
)}
<Field label="子用户快照上限">
<NumberInput
value={form.snapshot_limit}
min={1}
max={999}
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
</div>
<Field label="NAT 端口映射数量">
<NumberInput
value={form.port_mapping_count}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
/>
<div className="mt-2 flex flex-wrap gap-1.5">
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span>
{autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
</div>
</Field>
<Field label="子用户快照上限">
<NumberInput
value={form.snapshot_limit}
min={1}
max={999}
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
/>
</Field>
<Field label="到期时间">
<div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
@@ -475,15 +680,40 @@ function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, m
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
const normalized = applyTemplateDefaults(form)
const wantsNAT = normalized.assign_nat !== false
const wantsIPv4 = !!normalized.assign_ipv4
const wantsIPv6 = !!normalized.assign_ipv6
const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return {
...normalized,
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
ram_mb: Math.round(normalized.ram_mb),
disk_gb: Math.round(normalized.disk_gb),
assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0,
assign_ipv4: wantsIPv4,
ipv4_count: wantsIPv4 ? clampInt(normalized.ipv4_count || 1, 1, 64, 1) : 0,
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
assign_ipv6: wantsIPv6,
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [],
ssh_auth_mode: sshAuthMode,
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
}
}
function validateSSHAuthInputs(form: CreateContainerRequest) {
if (isWindowsTemplate(form.template_id)) return ''
const mode = form.ssh_auth_mode || 'auto_password'
if (mode === 'password') return sshPasswordError((form.ssh_password || '').trim())
if (mode === 'key') return sshPublicKeyError(form.ssh_public_key || '')
if (mode !== 'auto_password') return '请选择登录方式'
return ''
}
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
if (!isWindowsTemplate(form.template_id)) return form
return {
@@ -509,5 +739,38 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
return Math.min(Math.max(next, min), max ?? next)
}
const createNetworkText = {
zh: {
publicIPv4: '公网 IPv4',
noAllocatableIPv4: '未检测到可分配公网 IPv4',
publicIPv6: '公网 IPv6',
use: '使用',
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
publicNAT: '公网 NAT',
noNATPorts: '不分配 NAT 端口',
},
en: {
publicIPv4: 'Public IPv4',
noAllocatableIPv4: 'No allocatable public IPv4 detected',
publicIPv6: 'Public IPv6',
use: 'Use',
checkingIPv6Prefix: 'Checking IPv6 prefix...',
publicNAT: 'Public NAT',
noNATPorts: 'No NAT ports will be assigned',
},
} as const
function formatAllocatableIPv4Count(count: number, language: Language) {
return language === 'en'
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
: `检测到 ${count} 个可分配地址`
}
function formatNATPortCount(count: number, language: Language) {
return language === 'en'
? `${count} NAT ports will be assigned`
: `将分配 ${count} 个 NAT 端口`
}
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
+6 -4
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
import { useLanguage } from '../contexts/LanguageContext'
type DialogType = 'confirm' | 'alert'
@@ -20,6 +21,7 @@ const DialogContext = createContext<DialogContextType | undefined>(undefined)
export function DialogProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
const { t } = useLanguage()
const confirm = useCallback((title: string, message: string) => {
return new Promise<boolean>((resolve) => {
@@ -50,7 +52,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
}`}>
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</div>
<h3 className="text-sm font-semibold text-black flex-1">{dialog.title}</h3>
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
{dialog.type === 'alert' && (
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
<X className="w-4 h-4" />
@@ -58,7 +60,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
)}
</div>
<div className="px-5 py-4">
<p className="text-sm text-gray-600">{dialog.message}</p>
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
</div>
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
{dialog.type === 'confirm' && (
@@ -66,7 +68,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
onClick={() => close(false)}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
>
{t('取消')}
</button>
)}
<button
@@ -77,7 +79,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
: 'bg-black text-white hover:bg-gray-800'
}`}
>
{dialog.type === 'confirm' ? '确认' : '确定'}
{dialog.type === 'confirm' ? t('确认') : t('确定')}
</button>
</div>
</div>
+4
View File
@@ -1,12 +1,16 @@
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import { useState } from 'react'
import AutoTranslate from './AutoTranslate'
import BrowserDialogTranslator from './BrowserDialogTranslator'
export default function Layout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
<AutoTranslate />
<BrowserDialogTranslator />
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6">
+37 -13
View File
@@ -19,6 +19,7 @@ import {
UserCog,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { useTheme } from '../contexts/ThemeContext'
import { getVersion } from '../services/api'
import AppIcon from './AppIcon'
@@ -45,11 +46,23 @@ function GitHubIcon({ className = '' }: { className?: string }) {
)
}
function LanguageIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M128 170.6496A42.6496 42.6496 0 0 0 128 256V170.6496zM640 256a42.6496 42.6496 0 1 0 0-85.3504V256zM426.6496 128a42.6496 42.6496 0 0 0-85.2992 0h85.2992zM341.3504 213.3504a42.6496 42.6496 0 0 0 85.2992 0H341.3504z m56.6784 434.944a42.6496 42.6496 0 0 0 61.44-59.2896l-61.44 59.2896zM312.8832 367.4112a42.6496 42.6496 0 0 0-78.592 33.1776l78.592-33.1776z m220.4672 357.888a42.6496 42.6496 0 1 0 0 85.3504v-85.2992z m298.6496 85.3504a42.6496 42.6496 0 1 0 0-85.2992v85.2992z m-400.8448 66.2528a42.6496 42.6496 0 1 0 76.3392 38.1952l-76.288-38.1952z m251.4944-407.552l38.1952-19.0976a42.6496 42.6496 0 0 0-76.3392 0l38.144 19.0976z m175.2064 445.7472a42.6496 42.6496 0 1 0 76.288-38.1952l-76.288 38.1952zM586.1376 220.3648a42.6496 42.6496 0 1 0-84.1728-14.08l84.1728 14.08zM109.0048 735.2832a42.6496 42.6496 0 0 0 37.9904 76.4416l-37.9904-76.4416zM128 256h512V170.6496h-512V256z m213.3504-128v85.3504h85.2992V128H341.3504z m118.0672 461.0048a726.3232 726.3232 0 0 1-146.5344-221.5936l-78.592 33.1776a811.6224 811.6224 0 0 0 163.7376 247.7056l61.44-59.2896z m73.9328 221.696h298.6496v-85.3504h-298.6496v85.2992z m-25.856 104.3968l213.3504-426.7008-76.3392-38.144-213.3504 426.6496 76.3392 38.1952z m137.0112-426.7008l213.3504 426.7008 76.288-38.1952-213.2992-426.6496-76.3392 38.144zM501.9648 206.336C463.0016 438.6304 313.3952 633.7536 109.056 735.232l37.9904 76.4416c228.2496-113.4592 395.52-331.3152 439.1424-591.36L501.9648 206.336z"
fill="currentColor"
/>
</svg>
)
}
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const { logout, isSubUser } = useAuth()
const { theme, toggleTheme } = useTheme()
const { toggleLanguage, t } = useLanguage()
const [version, setVersion] = useState('')
useEffect(() => {
@@ -99,7 +112,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<button
onClick={onToggle}
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
title="切换侧边栏"
title={t('切换侧边栏')}
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
@@ -253,18 +266,29 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
</button>
<div className={collapsed ? 'space-y-1' : 'flex items-center gap-1'}>
<button
onClick={toggleTheme}
className={`${collapsed ? 'w-full justify-center' : 'flex-1'} flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
title={t(theme === 'dark' ? '切换亮色模式' : '切换暗黑模式')}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
</button>
<button
onClick={() => { void toggleLanguage() }}
className={`${collapsed ? 'w-full justify-center' : 'flex-1 justify-center'} flex items-center gap-2 rounded-md px-3 py-2.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
title="Language"
>
<LanguageIcon className="h-4 w-4 shrink-0" />
{!collapsed && <span>Language</span>}
</button>
</div>
{/* Version */}
{version && (
+49 -23
View File
@@ -1,8 +1,39 @@
import { useEffect, useRef, useState } from 'react'
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
import RFB from '@novnc/novnc'
import RFBModule from '@novnc/novnc/lib/rfb'
import { createVNCTicket, getWebVNCUrl } from '../services/api'
type RFBConstructor = new (
target: HTMLElement,
url: string,
options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }
) => RFBInstance
interface RFBInstance extends EventTarget {
scaleViewport: boolean
resizeSession: boolean
focusOnClick: boolean
viewOnly: boolean
qualityLevel: number
compressionLevel: number
background: string
disconnect(): void
sendCtrlAltDel(): void
}
const RFB = resolveRFBConstructor(RFBModule)
function resolveRFBConstructor(moduleValue: unknown): RFBConstructor {
if (typeof moduleValue === 'function') {
return moduleValue as RFBConstructor
}
const maybeDefault = (moduleValue as { default?: unknown })?.default
if (typeof maybeDefault === 'function') {
return maybeDefault as RFBConstructor
}
throw new Error('noVNC RFB constructor is unavailable')
}
interface WebVNCViewerProps {
containerName: string
onClose: () => void
@@ -10,7 +41,7 @@ interface WebVNCViewerProps {
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
const screenRef = useRef<HTMLDivElement>(null)
const rfbRef = useRef<RFB | null>(null)
const rfbRef = useRef<RFBInstance | null>(null)
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
@@ -80,6 +111,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
return
}
if (!ticket) {
setStatus('error')
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
@@ -97,9 +129,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
rfb.qualityLevel = 6
rfb.compressionLevel = 2
rfb.background = '#050505'
rfb.addEventListener('connect', () => {
setStatus('connected')
})
rfb.addEventListener('connect', () => setStatus('connected'))
rfb.addEventListener('disconnect', (event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
setStatus((current) => current === 'error' ? current : 'disconnected')
@@ -133,35 +163,31 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
}, [containerName])
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4 text-gray-600" />
<Monitor className="h-4 w-4 text-gray-600" />
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700"></span>}
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">...</span>}
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"></span>}
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700"></span>}
{status === 'connected' && <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700"></span>}
{status === 'connecting' && <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700">...</span>}
{status === 'disconnected' && <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600"></span>}
{status === 'error' && <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700"></span>}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => rfbRef.current?.sendCtrlAltDel()}
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
title="发送 Ctrl+Alt+Del"
>
<Send className="w-3.5 h-3.5" />
<button onClick={() => rfbRef.current?.sendCtrlAltDel()} className="inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-500 hover:bg-gray-200" title="发送 Ctrl+Alt+Del">
<Send className="h-3.5 w-3.5" />
Ctrl+Alt+Del
</button>
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
<RefreshCw className="w-3.5 h-3.5" />
<button onClick={connect} className="rounded p-1.5 text-xs text-gray-500 hover:bg-gray-200" title="重新连接">
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
<X className="w-4 h-4" />
<button onClick={onClose} className="rounded p-1.5 text-gray-500 hover:bg-gray-200" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
+72
View File
@@ -0,0 +1,72 @@
import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react'
import { translateText } from '../utils/i18n'
import { getLanguage, updateLanguage } from '../services/api'
export type Language = 'zh' | 'en'
interface LanguageContextValue {
language: Language
setLanguage: (language: Language) => void
toggleLanguage: () => Promise<void>
t: (value: string) => string
}
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined)
function initialLanguage(): Language {
return 'zh'
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(initialLanguage)
const setLanguageLocal = (next: Language) => {
setLanguageState(next)
}
const setLanguage = (next: Language) => {
setLanguageLocal(next)
updateLanguage(next).catch(() => {})
}
const value = useMemo<LanguageContextValue>(() => ({
language,
setLanguage,
toggleLanguage: async () => {
const next = language === 'zh' ? 'en' : 'zh'
setLanguageLocal(next)
try {
const res = await updateLanguage(next)
setLanguageLocal(res.data.data?.language || next)
} catch {
setLanguageLocal(language)
}
},
t: (text: string) => language === 'en' ? translateText(text) : text,
}), [language])
useEffect(() => {
getLanguage()
.then((res) => {
const serverLanguage = res.data.data?.language
if (serverLanguage === 'zh' || serverLanguage === 'en') {
setLanguageLocal(serverLanguage)
}
})
.catch(() => {})
}, [])
useEffect(() => {
document.documentElement.lang = language === 'en' ? 'en' : 'zh-CN'
document.documentElement.dataset.language = language
}, [language])
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
}
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within LanguageProvider')
}
return context
}
+8 -5
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { LanguageProvider } from './contexts/LanguageContext'
import { DialogProvider } from './components/Dialog'
import './index.css'
@@ -11,11 +12,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
<LanguageProvider>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
</LanguageProvider>
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
+1 -1
View File
@@ -1,4 +1,4 @@
declare module '@novnc/novnc' {
declare module '@novnc/novnc/lib/rfb' {
export default class RFB extends EventTarget {
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
scaleViewport: boolean
+103 -9
View File
@@ -63,8 +63,10 @@ const scopeGroups = [
['dashboard:read', '控制面板'],
['host:read', '主机资源'],
['routing:read', '路由信息'],
['routing:write', '路由配置'],
['ipv6:read', 'IPv6 状态'],
['task:read', '任务列表'],
['task:delete', '删除任务'],
['image:read', '镜像列表'],
],
},
@@ -137,7 +139,9 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
endpoints: [
['GET', '/api/v1/dashboard', '控制面板统计'],
['GET', '/api/v1/host-info', '主机资源'],
['GET', '/api/v1/routing', 'NAT/IPv6 路由'],
['GET', '/api/v1/routing', 'NAT/IPv4/IPv6 路由'],
['PUT', '/api/v1/routing', '更新公网 IPv4/IPv6 池'],
['POST', '/api/v1/routing/ipv4-scan', '扫描公网 IPv4 段'],
['GET', '/api/v1/ipv6/status', 'IPv6 状态'],
['GET', '/api/v1/tasks', '任务队列'],
['DELETE', '/api/v1/tasks/{task_id}', '删除任务'],
@@ -147,7 +151,7 @@ const endpointGroups: Array<{ title: string; endpoints: EndpointTuple[] }> = [
title: '容器',
endpoints: [
['GET', '/api/v1/containers', '容器列表'],
['POST', '/api/v1/containers/list', '容器列表(兼容旧接口'],
['POST', '/api/v1/containers/list', '容器列表(兼容 POST 写法'],
['POST', '/api/v1/containers', '创建容器'],
['GET', '/api/v1/containers/{id|uuid|name}', '容器详情'],
['POST', '/api/v1/containers/{id}/start', '开机'],
@@ -501,7 +505,6 @@ export default function ApiIntegration() {
<div className="rounded-lg bg-gray-900 p-4 font-mono text-xs text-gray-100">
<div>curl -X GET {BASE_URL}/api/v1/containers -H "X-API-Key: clicd_sk_xxxx"</div>
<div className="mt-2 text-gray-400">curl -X GET {BASE_URL}/api/v1/dashboard -H "Authorization: Bearer clicd_sk_xxxx"</div>
<div className="mt-2 text-amber-300"> /api/containers/list 使 GET /api/v1/containers</div>
</div>
{endpointGroups.map(group => (
@@ -732,11 +735,25 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
io_speed_mbps: 0,
extra_ports: [8080],
port_mapping_count: 2,
assign_nat: true,
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
assign_ipv6: true,
ipv6_count: 1,
ipv6_addresses: [],
ssh_auth_mode: 'auto_password',
ssh_password: '',
ssh_public_key: '',
expires_at: '',
},
'POST /api/v1/containers/{id}/reinstall': { template_id: 'debian-bookworm' },
'POST /api/v1/containers/{id}/reinstall': {
template_id: 'debian-bookworm',
ssh_auth_mode: 'keep',
ssh_password: '',
ssh_public_key: '',
},
'PUT /api/v1/containers/{id}/traffic-limit': {
traffic_mode: 'total',
monthly_traffic_gb: 100,
@@ -773,6 +790,32 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
'POST /api/v1/images/cancel': { template_id: 'debian-bookworm' },
'DELETE /api/v1/images/delete': { template_id: 'debian-bookworm' },
'PUT /api/v1/images/toggle': { template_id: 'debian-bookworm', enabled: true },
'PUT /api/v1/routing': {
items: [
{
address: '203.0.113.10',
interface: 'eth0',
prefix_len: 32,
gateway: '203.0.113.1',
},
],
ipv6_prefixes: [
{
address: '2001:db8:100::2',
prefix: '2001:db8:100::/64',
prefix_len: 64,
interface: 'eth0',
gateway: '2001:db8:100::1',
},
],
},
'POST /api/v1/routing/ipv4-scan': {
cidr: '203.0.113.0/29',
interface: 'eth0',
gateway: '203.0.113.1',
verify: true,
limit: 64,
},
'POST /api/v1/security/check': { container_name: 'example-vm' },
'PUT /api/v1/security/settings': { auto_shutdown: false },
'POST /api/v1/swap': { action: 'resize', size_mb: 16384 },
@@ -785,13 +828,28 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
vcpu: 1,
ram_mb: 512,
disk_gb: 10,
assign_nat: true,
port_mapping_count: 2,
snapshot_limit: 1,
assign_ipv4: false,
ipv4_count: 1,
public_ipv4s: [],
assign_ipv6: true,
ipv6_count: 1,
ipv6_addresses: [],
ssh_auth_mode: 'key',
ssh_public_key: 'ssh-ed25519 AAAA... user@example',
},
],
},
'POST /api/v1/batch-action': { action: 'restart', containers: [5], template_id: '' },
'POST /api/v1/batch-action': {
action: 'reinstall',
containers: [5],
template_id: 'debian-bookworm',
ssh_auth_mode: 'keep',
ssh_password: '',
ssh_public_key: '',
},
'POST /api/v1/ssh-ticket': { container_name: 'example-vm' },
'POST /api/v1/vnc-ticket': { container_name: 'kvm-demo' },
'POST /api/v1/sub-user/create': { container_name: 'example-vm' },
@@ -835,13 +893,30 @@ const responseSamples: Record<string, unknown> = {
success: true,
data: {
nat4: { used: 62, remaining: '45474', total: '45536' },
ipv4: { used: 1, remaining: '3', total: '4' },
ipv6: { used: 31, remaining: 'large', total: 'large' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
ipv4_assignments: [{ container_id: 5, container_name: 'example-vm', address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
nat4_mappings: [
{ container_id: 5, container_name: 'example-vm', status: 'running', ip: '10.0.0.10', host_port: 22004, container_port: 22, protocol: 'tcp' },
],
ipv6_assignments: [{ container_id: 5, container_name: 'example-vm', address: '2001:db8:100::1005', prefix_len: 64, interface: 'eth0' }],
},
},
'PUT /api/v1/routing': {
success: true,
data: {
ipv4: { used: 1, remaining: '3', total: '4' },
public_ipv4_addresses: [{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1' }],
ipv6_prefixes: [{ interface: 'eth0', address: '2001:db8:100::2', prefix: '2001:db8:100::/64', prefix_len: 64, gateway: '2001:db8:100::1' }],
},
},
'POST /api/v1/routing/ipv4-scan': {
success: true,
data: [
{ address: '203.0.113.10', interface: 'eth0', prefix_len: 32, gateway: '203.0.113.1', status: 'available', usable: true, reason: '' },
],
},
'GET /api/v1/ipv6/status': {
success: true,
data: {
@@ -1054,10 +1129,29 @@ function examplePathFor(path: string) {
}
function endpointNoteFor(key: string) {
if (key.includes('/vnc-ticket')) return 'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。'
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) return '该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。'
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) return '样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。'
return ''
const notes: string[] = []
if (key === 'POST /api/v1/containers') {
notes.push('Linux 创建支持 ssh_auth_mode=auto_password|password|key;公网 IPv4、IPv6 与 NAT 可通过 assign_nat、assign_ipv4、assign_ipv6 组合使用。')
}
if (key === 'POST /api/v1/containers/{id}/reinstall') {
notes.push('重装支持 ssh_auth_mode=keep|auto_password|password|keykeep 仅用于重装,未传 SSH 字段时保持原有行为。')
}
if (key === 'POST /api/v1/batch-create') {
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。')
}
if (key === 'POST /api/v1/batch-action') {
notes.push('action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。')
}
if (key === 'PUT /api/v1/routing') {
notes.push('更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。')
}
if (key === 'POST /api/v1/routing/ipv4-scan') {
notes.push('扫描公网 IPv4 段需要 routing:writeverify=true 时会尝试校验地址可用性。')
}
if (key.includes('/vnc-ticket')) notes.push('WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。')
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。')
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。')
return notes.join(' ')
}
function defaultResponseFor(method: HttpMethod) {
+170 -58
View File
@@ -78,6 +78,7 @@ import ResourceStatsPanel, {
StatsRangeKey,
statsRanges,
} from '../components/ResourceStatsPanel'
import { generateSSHPassword, sshPasswordError, sshPublicKeyError, type ReinstallSSHAuthMode } from '../utils/sshAuth'
const PUBLIC_HOST = window.location.hostname
const inputClass = 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
@@ -93,6 +94,7 @@ type MappingDraft = {
index: number | null
description: string
host_port: string
host_ip: string
container_port: string
protocol: string
}
@@ -101,6 +103,7 @@ const emptyDraft: MappingDraft = {
index: null,
description: '',
host_port: '',
host_ip: '',
container_port: '',
protocol: 'all',
}
@@ -133,6 +136,9 @@ export default function ContainerDetail() {
const [showReinstall, setShowReinstall] = useState(false)
const [templates, setTemplates] = useState<Template[]>([])
const [selectedTemplate, setSelectedTemplate] = useState('')
const [reinstallAuthMode, setReinstallAuthMode] = useState<ReinstallSSHAuthMode>('keep')
const [reinstallPasswordDraft, setReinstallPasswordDraft] = useState('')
const [reinstallPublicKeyDraft, setReinstallPublicKeyDraft] = useState('')
const [reinstalling, setReinstalling] = useState(false)
const [traffic, setTraffic] = useState<TrafficInfo | null>(null)
const [subUser, setSubUser] = useState<SubUser | null>(null)
@@ -411,6 +417,9 @@ export default function ContainerDetail() {
setTemplates(res.data.data)
setSelectedTemplate(res.data.data[0]?.id || '')
}
setReinstallAuthMode('keep')
setReinstallPasswordDraft('')
setReinstallPublicKeyDraft('')
setShowReinstall(true)
} catch (err) {
console.error(err)
@@ -432,9 +441,28 @@ export default function ContainerDetail() {
const handleReinstall = async () => {
if (!containerIdentifier || !selectedTemplate) return
const linuxTemplate = !isWindowsTemplate(selectedTemplate)
if (linuxTemplate && reinstallAuthMode === 'password') {
const validationError = sshPasswordError(reinstallPasswordDraft.trim())
if (validationError) {
await dialog.alert('密码格式不正确', validationError)
return
}
}
if (linuxTemplate && reinstallAuthMode === 'key') {
const validationError = sshPublicKeyError(reinstallPublicKeyDraft)
if (validationError) {
await dialog.alert('SSH Key 格式不正确', validationError)
return
}
}
setReinstalling(true)
try {
await reinstallContainer(containerIdentifier, selectedTemplate)
await reinstallContainer(containerIdentifier, selectedTemplate, linuxTemplate ? {
ssh_auth_mode: reinstallAuthMode,
ssh_password: reinstallAuthMode === 'password' ? reinstallPasswordDraft.trim() : '',
ssh_public_key: reinstallAuthMode === 'key' ? reinstallPublicKeyDraft.trim() : '',
} : undefined)
setShowReinstall(false)
setShowSSH(false)
setShowVNC(false)
@@ -448,23 +476,12 @@ export default function ContainerDetail() {
}
const generateResetPassword = () => {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
setResetPasswordDraft(secureShuffle(password.split('')).join(''))
setResetPasswordDraft(generateSSHPassword())
setResetPasswordResult('')
}
const resetPasswordError = (password: string) => {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
return sshPasswordError(password)
}
const handleResetPassword = async () => {
@@ -526,6 +543,7 @@ export default function ContainerDetail() {
index,
description: pm.description,
host_port: String(pm.host_port),
host_ip: pm.host_ip || '',
container_port: String(pm.container_port),
protocol: pm.protocol || 'all',
})
@@ -538,7 +556,11 @@ export default function ContainerDetail() {
if (!(await ensureSubUserCanOperate())) return false
if (draft.index === null && container) {
const currentCount = container.port_mappings?.length || 0
const limit = container.port_mapping_limit || Math.max(currentCount, 2)
const limit = Math.max(container.port_mapping_limit || 0, currentCount)
if (limit <= 0) {
dialog.alert('未分配 IPv4 NAT', '该容器未分配 IPv4 NAT 端口配额。')
return false
}
if (currentCount >= limit) {
dialog.alert('端口配额已满', '已达到管理员分配的 NAT 端口配额。')
return false
@@ -563,6 +585,7 @@ export default function ContainerDetail() {
const payload: PortMapping = {
container_port: containerPort,
host_port: hostPortVal,
host_ip: isSubUser ? undefined : (draft.host_ip || undefined),
protocol: protocolVal,
description: draft.description.trim() || `Port-${containerPort}`,
}
@@ -730,17 +753,44 @@ export default function ContainerDetail() {
}
const isRunning = container.status === 'running'
const isInitializing = container.status === 'initializing'
const isKVM = (container.virtualization || 'lxc') === 'kvm'
const isWindows = container.template?.includes('windows')
const reinstallLinuxTemplate = !isWindowsTemplate(selectedTemplate)
const canOpenVNC = isKVM && isRunning
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
const isPolicyBlocked = !!container.policy_blocked
const isSubUserPolicyBlocked = isSubUser && isPolicyBlocked
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
const publicIPv4s = container.public_ipv4s || []
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
const ipv6List = (container.ipv6_addresses || [])
.map((item) => item.address)
.filter(Boolean)
if (ipv6List.length === 0 && container.ipv6) ipv6List.push(container.ipv6)
const maxVCPU = hostInfo?.cpu.cores || 64
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
const hasIndependentIPv4 = assignedIPv4List.length > 0
const hasIndependentIPv6 = ipv6List.length > 0
const hasIndependentIP = hasIndependentIPv4 || hasIndependentIPv6
const defaultConnPort = isWindows ? 3389 : 22
let publicEndpoint = '-'
let sshCommand = ''
if (container.ssh_port > 0) {
// NAT port mapping mode
publicEndpoint = `${publicHost}:${container.ssh_port}`
sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
} else if (hasIndependentIP) {
// Direct connection via independent IPv4 or IPv6
const connIP = hasIndependentIPv4 ? assignedIPv4List[0] : `[${ipv6List[0]}]`
publicEndpoint = `${connIP}:${defaultConnPort}`
if (!isWindows) {
sshCommand = `ssh root@${connIP}`
}
}
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
@@ -758,8 +808,9 @@ export default function ContainerDetail() {
const netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100)
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
const mappingCount = container.port_mappings?.length || 0
const mappingLimit = container.port_mapping_limit || Math.max(mappingCount, 2)
const canAddMapping = isSubUser ? mappingCount < mappingLimit && !isSubUserPolicyBlocked : true
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
const hasNATQuota = mappingLimit > 0
const canAddMapping = hasNATQuota && mappingCount < mappingLimit && !isSubUserPolicyBlocked
const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: ''
@@ -822,14 +873,14 @@ export default function ContainerDetail() {
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-black">{container.name}</h1>
<StatusBadge running={isRunning} />
<StatusBadge running={isRunning} initializing={isInitializing} />
</div>
<div className="flex items-center gap-2 flex-wrap mt-2">
<InfoTag color="blue"> {container.template}</InfoTag>
<InfoTag color="slate"> {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
<InfoTag color="emerald"> {container.ip || '-'}</InfoTag>
<InfoTag color="amber">NAT {mappingCount} </InfoTag>
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
<InfoTag color="amber">IPv4 NAT {hasNATQuota ? `${mappingCount}` : '未分配'}</InfoTag>
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicEndpoint}</InfoTag>
{isPolicyBlocked && <InfoTag color="red"></InfoTag>}
</div>
</div>
@@ -874,7 +925,7 @@ export default function ContainerDetail() {
<>
<ActionButton disabled={isSubUserPolicyBlocked} onClick={() => setShowNat(true)}>
<Settings className="w-3.5 h-3.5" />
NAT
IPv4 NAT
</ActionButton>
</>
<ActionButton onClick={() => setShowSnapshots(true)} disabled={!!taskStatus || !!snapshotBusy || isSubUserPolicyBlocked}>
@@ -926,7 +977,7 @@ export default function ContainerDetail() {
</div>
) : isWindows ? (
<>
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
<PlainRow label="RDP 地址" value={publicEndpoint} mono />
<PlainRow label="用户名" value="Administrator" mono />
<div className="flex items-center justify-between gap-3">
<span className="text-gray-500"></span>
@@ -951,7 +1002,7 @@ export default function ContainerDetail() {
</>
) : (
<>
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
<PlainRow label="SSH 地址" value={publicEndpoint} mono copyValue={sshCommand} onCopy={copyText} />
<PlainRow label="用户名" value="root" mono />
<div className="flex items-center justify-between gap-3">
<span className="text-gray-500">SSH </span>
@@ -993,8 +1044,9 @@ export default function ContainerDetail() {
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
<PlainRow label="IPv6" value={container.ipv6 || '-'} mono copyValue={container.ipv6} onCopy={copyText}>
{!isSubUser && !container.ipv6 && (
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
{!isSubUser && ipv6List.length === 0 && (
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
Assign
</button>
@@ -1386,7 +1438,7 @@ export default function ContainerDetail() {
)}
{showNat && (
<Modal title="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 && (
<button onClick={openAddMapping} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
<Plus className="w-3.5 h-3.5" />
@@ -1396,10 +1448,14 @@ export default function ContainerDetail() {
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="text-xs text-gray-500">
<span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span>
{hasNATQuota ? (
<><span className="font-mono text-gray-800">{mappingCount}/{mappingLimit}</span></>
) : (
<span> IPv4 NAT </span>
)}
</div>
{!isSubUser && !canAddMapping && (
<div className="text-xs text-amber-600"> NAT </div>
{!isSubUser && hasNATQuota && !canAddMapping && (
<div className="text-xs text-amber-600"> IPv4 NAT </div>
)}
</div>
<MappingTable mappings={container.port_mappings || []} publicHost={publicHost} onEdit={openEditMapping} onDelete={isSubUser ? () => {} : removeMapping} isSubUser={isSubUser} />
@@ -1419,6 +1475,7 @@ export default function ContainerDetail() {
canAddMapping={canAddMapping}
saving={savingMapping}
containerIdentifier={containerIdentifier}
publicIPv4s={publicIPv4s}
onCancel={() => { setShowMappingEditor(false); setDraft(emptyDraft) }}
onSubmit={async () => {
if (await submitMapping()) {
@@ -1462,6 +1519,55 @@ export default function ContainerDetail() {
))}
</select>
</Field>
{reinstallLinuxTemplate && (
<div className="rounded-md border border-gray-200 bg-white px-3 py-3 text-sm">
<div className="mb-2 font-medium text-gray-800"></div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{([
['keep', '保留当前密码'],
['auto_password', '生成新密码'],
['password', '自定义密码'],
['key', 'SSH Key'],
] as Array<[ReinstallSSHAuthMode, string]>).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setReinstallAuthMode(mode)}
className={`rounded-md border px-3 py-2 text-xs font-medium transition-colors ${reinstallAuthMode === mode ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{label}
</button>
))}
</div>
{reinstallAuthMode === 'password' && (
<div className="mt-3 flex gap-2">
<input
type="text"
value={reinstallPasswordDraft}
onChange={(event) => setReinstallPasswordDraft(event.target.value)}
className={inputClass}
placeholder="RootPass123"
/>
<button
type="button"
onClick={() => setReinstallPasswordDraft(generateSSHPassword())}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50"
title="生成密码"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{reinstallAuthMode === 'key' && (
<textarea
value={reinstallPublicKeyDraft}
onChange={(event) => setReinstallPublicKeyDraft(event.target.value)}
className={`${inputClass} mt-3 min-h-20 resize-y font-mono text-xs`}
placeholder="ssh-ed25519 AAAA..."
/>
)}
</div>
)}
<div className="flex justify-end gap-3">
<button onClick={() => setShowReinstall(false)} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md"></button>
<button onClick={handleReinstall} disabled={reinstalling} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
@@ -1565,7 +1671,15 @@ function RangeSwitch({ value, onChange }: { value: StatsRangeKey; onChange: (val
)
}
function StatusBadge({ running }: { running: boolean }) {
function StatusBadge({ running, initializing }: { running: boolean; initializing?: boolean }) {
if (initializing) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap bg-amber-50 text-amber-700">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-amber-500 animate-pulse"></span>
</span>
)
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap ${running ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-emerald-500' : 'bg-rose-500'}`}></span>
@@ -1739,6 +1853,7 @@ function MappingEditor({
canAddMapping,
saving,
containerIdentifier,
publicIPv4s,
onCancel,
onSubmit,
}: {
@@ -1748,6 +1863,7 @@ function MappingEditor({
canAddMapping: boolean
saving: boolean
containerIdentifier: string
publicIPv4s: { address: string; interface?: string }[]
onCancel: () => void
onSubmit: () => void
}) {
@@ -1757,7 +1873,8 @@ function MappingEditor({
const fillRandomPort = async () => {
try {
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`)
const params = draft.host_ip ? { host_ip: draft.host_ip } : undefined
const res = await api.get<APIResponse<{ port: number }>>(`/containers/${containerIdentifier}/random-port`, { params })
const port = res.data.data?.port || 0
if (port > 0) updateDraft({ host_port: String(port) })
} catch {
@@ -1815,6 +1932,21 @@ function MappingEditor({
)}
</Field>
<Field label="Host IPv4">
{isSubUser ? (
<input value={draft.host_ip || 'All IPv4'} disabled className={disabledInputClass} />
) : (
<select value={draft.host_ip} onChange={(e) => updateDraft({ host_ip: e.target.value })} className={inputClass}>
<option value="">All assigned IPv4</option>
{publicIPv4s.map((ip) => (
<option key={`${ip.interface}-${ip.address}`} value={ip.address}>
{ip.address}{ip.interface ? ` (${ip.interface})` : ''}
</option>
))}
</select>
)}
</Field>
<Field label="内部端口">
<input
value={draft.container_port}
@@ -1854,6 +1986,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
<tr>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Host IPv4</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{!compact && <th className="text-right px-3 py-2 text-xs font-medium text-gray-500"></th>}
@@ -1869,7 +2002,8 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
{isSSH && <span className="ml-2 px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 text-xs"></span>}
</td>
<td className="px-3 py-2 text-xs text-gray-500">{pm.protocol.toUpperCase()}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{publicHost}:{pm.host_port}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_ip || publicHost || 'All IPv4'}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.host_port}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-800">{pm.container_port}</td>
{!compact && (
<td className="px-3 py-2">
@@ -2093,30 +2227,8 @@ function TrafficBar({ container }: { container: Container }) {
)
}
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
function isWindowsTemplate(templateID: string) {
return templateID.toLowerCase().includes('windows')
}
function getTemplateIcon(id: string): ReactNode {
+16 -4
View File
@@ -390,6 +390,7 @@ export default function Containers() {
<tbody className="divide-y divide-gray-100">
{pageContainers.map((container) => {
const isRunning = container.status === 'running'
const isInitializing = container.status === 'initializing'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder
const isPolicyBlocked = !!container.policy_blocked
@@ -437,7 +438,7 @@ export default function Containers() {
</button>
</td>
<td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
<StatusBadge running={isRunning} initializing={isInitializing} task={task} placeholder={isPlaceholder} policyBlocked={isPolicyBlocked} />
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
@@ -483,7 +484,7 @@ export default function Containers() {
try {
const { default: api } = await import('../services/api')
await api.delete(`/tasks/${task.id}`)
fetchData()
await Promise.all([fetchData(), fetchTasks()])
} catch { /* ignore */ }
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-red-200 text-[11px] text-red-600 hover:bg-red-50 transition-colors whitespace-nowrap"
@@ -581,7 +582,7 @@ type DisplayContainer = Container & {
createTask?: Task
}
function StatusBadge({ running, task, placeholder, policyBlocked }: { running: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
function StatusBadge({ running, initializing, task, placeholder, policyBlocked }: { running: boolean; initializing?: boolean; task?: Task; placeholder?: boolean; policyBlocked?: boolean }) {
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (policyBlocked) {
return (
@@ -640,6 +641,15 @@ function StatusBadge({ running, task, placeholder, policyBlocked }: { running: b
)
}
if (initializing) {
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
</span>
)
}
return (
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
@@ -704,14 +714,16 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
io_speed_mbps: cfg.io_speed_mbps,
status: 'creating',
ip: '',
public_ipv4s: [],
ipv6: '',
ipv6_prefix_len: 0,
ipv6_interface: '',
ipv6_addresses: [],
vnc_port: 0,
ssh_port: 0,
ssh_password: '',
port_mappings: [],
port_mapping_limit: 2,
port_mapping_limit: cfg.assign_nat === false ? 0 : (cfg.port_mapping_count || 0),
snapshot_limit: cfg.snapshot_limit || 3,
created_at: '',
expires_at: cfg.expires_at,
+273 -74
View File
@@ -9,8 +9,12 @@ import {
XCircle,
} from 'lucide-react'
import { getHostReport, HostProbeReport } from '../services/api'
import { useLanguage, type Language } from '../contexts/LanguageContext'
import { translateText } from '../utils/i18n'
export default function HostReport() {
const { language } = useLanguage()
const text = hostReportText[language]
const [report, setReport] = useState<HostProbeReport | null>(null)
const [loading, setLoading] = useState(true)
@@ -31,61 +35,61 @@ export default function HostReport() {
}, [fetchReport])
return (
<div className="space-y-6">
<div className="space-y-6" data-no-translate>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-black">宿</h1>
<p className="mt-1 text-sm text-gray-500"></p>
<h1 className="text-2xl font-bold text-black">{text.title}</h1>
<p className="mt-1 text-sm text-gray-500">{text.subtitle}</p>
</div>
<button onClick={fetchReport} disabled={loading} className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
{text.refresh}
</button>
</div>
{loading && !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿...</div>
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.loading}</div>
) : !report ? (
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">宿</div>
<div className="rounded-lg border border-gray-200 bg-white py-14 text-center text-sm text-gray-400">{text.emptyReport}</div>
) : (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={`${report.cpu.cores} 核 / ${report.cpu.threads} 线程`} />
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={`${formatMB(report.memory.used_mb)} 已用`} />
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={`${report.disks.length} 块硬盘`} sub={report.disks.map(d => d.type).filter(Boolean).join(' / ') || 'Unknown'} />
<ProbeMetric icon={<Activity className="h-4 w-4" />} label="运行状态" value={report.system.uptime_text} sub={`${report.system.process_count} 个进程`} />
<ProbeMetric icon={<Cpu className="h-4 w-4" />} label="CPU" value={report.cpu.model || 'Unknown'} sub={formatCPUThreads(report.cpu.cores, report.cpu.threads, language)} />
<ProbeMetric icon={<MemoryStick className="h-4 w-4" />} label="RAM" value={formatMB(report.memory.total_mb)} sub={formatUsedMemory(report.memory.used_mb, language)} />
<ProbeMetric icon={<HardDrive className="h-4 w-4" />} label="DISK" value={formatDiskCount(report.disks.length, language)} sub={report.disks.map(d => diskTypeLabel(d, language)).filter(Boolean).join(' / ') || 'Unknown'} />
<ProbeMetric icon={<Activity className="h-4 w-4" />} label={text.runtimeStatus} value={translateDynamic(report.system.uptime_text, language)} sub={formatProcessCount(report.system.process_count, language)} />
</div>
<ProbeSection title="系统概览">
<ProbeSection title={text.systemOverview}>
<ProbeRows rows={[
['主机名', report.hostname],
['操作系统', report.os],
['内核', report.kernel],
['生成时间', report.generated_at],
['CPU 架构', report.cpu.architecture],
['CPU 虚拟化指令', report.cpu.virtualization ? `支持 (${report.cpu.virtualization_key})` : '未检测到'],
['CPU 核显', report.cpu.has_integrated_gpu ? '检测到' : '未检测到'],
['显卡', report.gpus.length ? `${report.gpus.length}` : '未检测到'],
['运行能力', runtimeModeLabel(report.runtime.support_mode)],
['KVM 嵌套虚拟化', `${report.runtime.nested_virtualization ? '支持' : '未检测到'} (${report.runtime.nested_detail || '-'})`],
[text.hostname, report.hostname],
[text.os, report.os],
[text.kernel, report.kernel],
[text.generatedAt, report.generated_at],
[text.cpuArch, report.cpu.architecture],
[text.cpuVirtualization, report.cpu.virtualization ? `${text.supported} (${report.cpu.virtualization_key})` : text.notDetected],
[text.cpuIntegratedGPU, report.cpu.has_integrated_gpu ? text.detected : text.notDetected],
[text.gpu, report.gpus.length ? formatItemCount(report.gpus.length, language) : text.notDetected],
[text.runtimeCapability, runtimeModeLabel(report.runtime.support_mode, language)],
[text.kvmNested, `${report.runtime.nested_virtualization ? text.supported : text.notDetected} (${translateDynamic(report.runtime.nested_detail || '-', language)})`],
]} />
</ProbeSection>
<ProbeSection title="公网与路由">
<ProbeSection title={text.publicNetwork}>
<ProbeRows rows={[
['公网 IPv4', report.public_ipv4.length ? report.public_ipv4.join('\n') : '未检测到'],
['IPv4 地址', report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : '未检测到'],
['IPv4 段', report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : '未检测到'],
['IPv6 地址', report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : '未检测到'],
['IPv6 段', report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : '未检测到'],
['网关', report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : '未检测到'],
[text.publicIPv4, report.public_ipv4.length ? report.public_ipv4.join('\n') : text.notDetected],
[text.ipv4Address, report.ipv4_addresses?.length ? report.ipv4_addresses.map(formatIPv4Address).join('\n') : text.notDetected],
[text.ipv4Prefix, report.ipv4_prefixes?.length ? report.ipv4_prefixes.map(formatIPv4Prefix).join('\n') : text.notDetected],
[text.ipv6Address, report.ipv6_addresses.length ? report.ipv6_addresses.map(ip => `${ip.address}/${ip.prefix_len} (${ip.interface})`).join('\n') : text.notDetected],
[text.ipv6Prefix, report.ipv6_prefixes?.length ? report.ipv6_prefixes.map(formatIPv6Prefix).join('\n') : text.notDetected],
[text.gateway, report.gateways.length ? report.gateways.map(g => `${g.family}: ${g.gateway || '-'} dev ${g.interface || '-'}`).join('\n') : text.notDetected],
]} />
</ProbeSection>
<ProbeTable
title="内存条"
empty="未检测到内存条明细,可能缺少 dmidecode 或权限受限"
headers={['插槽', '容量', '类型', '频率', '厂商', '型号/序列号']}
title={text.memoryModules}
empty={text.noMemoryModules}
headers={[text.slot, text.capacity, text.type, text.frequency, text.vendor, text.modelSerial]}
rows={(report.memory.modules || []).map(m => [
m.locator || '-',
m.size || '-',
@@ -97,29 +101,29 @@ export default function HostReport() {
/>
<ProbeTable
title="硬盘与健康"
empty="未检测到硬盘"
headers={['设备', '型号', '容量', '类型', '挂载点', '健康', '寿命', '通电', '读取', '写入', '命令数', '擦写']}
title={text.disksHealth}
empty={text.noDisks}
headers={[text.device, text.model, text.capacity, text.type, text.mountPoint, text.health, text.lifetime, text.powerOn, text.reads, text.writes, text.commands, text.eraseCount]}
rows={report.disks.map(d => [
`${d.path || d.name}\n${d.serial || ''}`,
d.model || '-',
formatBytes(d.size_bytes),
d.type || (d.rotational ? 'HDD' : 'SSD'),
diskTypeLabel(d, language),
d.mountpoints?.length ? d.mountpoints.join('\n') : '-',
`${diskHealthLabel(d.health)}\n${d.health_detail || ''}`,
formatLifeUsed(d.smart?.life_used_percent),
d.smart?.power_on_hours ? `${d.smart.power_on_hours} 小时\n${formatPowerOnDays(d.smart.power_on_hours)}` : '-',
formatBytes(d.smart?.read_data_bytes || 0),
formatBytes(d.smart?.written_data_bytes || 0),
formatCommands(d.smart?.read_commands, d.smart?.write_commands),
formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count),
`${diskHealthLabel(d.health, language)}\n${diskHealthDetail(d, language)}`,
d.virtual ? text.unsupported : formatLifeUsed(d.smart?.life_used_percent, language),
d.virtual ? text.unsupported : (d.smart?.power_on_hours ? `${d.smart.power_on_hours} ${text.hours}\n${formatPowerOnDays(d.smart.power_on_hours, language)}` : '-'),
d.virtual ? text.unsupported : formatBytes(d.smart?.read_data_bytes || 0),
d.virtual ? text.unsupported : formatBytes(d.smart?.written_data_bytes || 0),
d.virtual ? text.unsupported : formatCommands(d.smart?.read_commands, d.smart?.write_commands, language),
d.virtual ? text.unsupported : formatWear(d.smart?.wear_leveling_count, d.smart?.erase_count, d.smart?.power_cycle_count, language),
])}
/>
<ProbeTable
title="网卡"
empty="未检测到网卡"
headers={['网卡', '状态', '驱动/速率', 'MAC', 'IPv4', 'IPv6']}
title={text.networkInterfaces}
empty={text.noNetworkInterfaces}
headers={[text.nic, text.status, text.driverSpeed, 'MAC', 'IPv4', 'IPv6']}
rows={report.network_interfaces.map(n => [
`${n.name}\n${n.model || ''}`,
n.state || '-',
@@ -131,25 +135,25 @@ export default function HostReport() {
/>
<ProbeTable
title="显卡"
empty="未检测到显卡"
headers={['名称', '厂商', '类型', '驱动']}
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type), g.driver || '-'])}
title={text.gpus}
empty={text.noGPUs}
headers={[text.name, text.vendor, text.type, text.driver]}
rows={report.gpus.map(g => [g.name, g.vendor || '-', gpuTypeLabel(g.type, language), g.driver || '-'])}
/>
<ProbeSection title="环境支持">
<ProbeSection title={text.environmentSupport}>
<div className="grid gap-2 md:grid-cols-2">
{report.environment.map(item => (
<div key={item.key} className="flex items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2">
{item.ok ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-green-600" /> : <XCircle className={`mt-0.5 h-4 w-4 shrink-0 ${item.required ? 'text-red-600' : 'text-amber-600'}`} />}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-gray-800">
<span>{item.label}</span>
<span>{translateDynamic(item.label, language)}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${item.required ? 'bg-gray-100 text-gray-600' : 'bg-blue-50 text-blue-700'}`}>
{item.required ? '必要' : '可选'}
{item.required ? text.required : text.optional}
</span>
</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{item.detail || '-'}</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">{translateDynamic(item.detail || '-', language)}</div>
</div>
</div>
))}
@@ -174,6 +178,153 @@ function ProbeMetric({ icon, label, value, sub }: { icon: ReactNode; label: stri
)
}
const hostReportText = {
zh: {
title: '宿主机信息',
subtitle: '硬件、网络、磁盘健康与运行环境探测报告',
refresh: '刷新',
loading: '正在探测宿主机环境...',
emptyReport: '暂未获取到宿主机信息',
runtimeStatus: '运行状态',
systemOverview: '系统概览',
hostname: '主机名',
os: '操作系统',
kernel: '内核',
generatedAt: '生成时间',
cpuArch: 'CPU 架构',
cpuVirtualization: 'CPU 虚拟化指令',
cpuIntegratedGPU: 'CPU 核显',
gpu: '显卡',
runtimeCapability: '运行能力',
kvmNested: 'KVM 嵌套虚拟化',
supported: '支持',
detected: '检测到',
notDetected: '未检测到',
publicNetwork: '公网与路由',
publicIPv4: '公网 IPv4',
ipv4Address: 'IPv4 地址',
ipv4Prefix: 'IPv4 段',
ipv6Address: 'IPv6 地址',
ipv6Prefix: 'IPv6 段',
gateway: '网关',
memoryModules: '内存条',
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
slot: '插槽',
capacity: '容量',
type: '类型',
frequency: '频率',
vendor: '厂商',
modelSerial: '型号/序列号',
disksHealth: '硬盘与健康',
noDisks: '未检测到硬盘',
device: '设备',
model: '型号',
mountPoint: '挂载点',
health: '健康',
lifetime: '寿命',
powerOn: '通电',
reads: '读取',
writes: '写入',
commands: '命令数',
eraseCount: '擦写',
virtualDisk: '虚拟磁盘',
virtualDiskDetail: '虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看',
unsupported: '不支持',
hours: '小时',
used: '已用',
remaining: '剩余',
read: '读',
write: '写',
wear: '磨损',
erase: '擦写',
powerCycles: '启停',
networkInterfaces: '网卡',
noNetworkInterfaces: '未检测到网卡',
nic: '网卡',
status: '状态',
driverSpeed: '驱动/速率',
gpus: '显卡',
noGPUs: '未检测到显卡',
name: '名称',
driver: '驱动',
environmentSupport: '环境支持',
required: '必要',
optional: '可选',
},
en: {
title: 'Host Info',
subtitle: 'Hardware, network, disk health, and runtime environment report',
refresh: 'Refresh',
loading: 'Probing host environment...',
emptyReport: 'No host information available',
runtimeStatus: 'Runtime Status',
systemOverview: 'System Overview',
hostname: 'Hostname',
os: 'Operating System',
kernel: 'Kernel',
generatedAt: 'Generated At',
cpuArch: 'CPU Architecture',
cpuVirtualization: 'CPU Virtualization',
cpuIntegratedGPU: 'CPU Integrated GPU',
gpu: 'GPU',
runtimeCapability: 'Runtime Capability',
kvmNested: 'KVM Nested Virtualization',
supported: 'Supported',
detected: 'Detected',
notDetected: 'Not detected',
publicNetwork: 'Public Network & Routing',
publicIPv4: 'Public IPv4',
ipv4Address: 'IPv4 Addresses',
ipv4Prefix: 'IPv4 Prefixes',
ipv6Address: 'IPv6 Addresses',
ipv6Prefix: 'IPv6 Prefixes',
gateway: 'Gateway',
memoryModules: 'Memory Modules',
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
slot: 'Slot',
capacity: 'Capacity',
type: 'Type',
frequency: 'Frequency',
vendor: 'Vendor',
modelSerial: 'Model / Serial',
disksHealth: 'Disks & Health',
noDisks: 'No disks detected',
device: 'Device',
model: 'Model',
mountPoint: 'Mount Point',
health: 'Health',
lifetime: 'Lifetime',
powerOn: 'Power-on',
reads: 'Reads',
writes: 'Writes',
commands: 'Commands',
eraseCount: 'Erase Count',
virtualDisk: 'Virtual Disk',
virtualDiskDetail: 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
unsupported: 'Unsupported',
hours: 'hours',
used: 'used',
remaining: 'remaining',
read: 'Read',
write: 'Write',
wear: 'Wear',
erase: 'Erase',
powerCycles: 'Power cycles',
networkInterfaces: 'Network Interfaces',
noNetworkInterfaces: 'No network interfaces detected',
nic: 'NIC',
status: 'Status',
driverSpeed: 'Driver / Speed',
gpus: 'GPUs',
noGPUs: 'No GPUs detected',
name: 'Name',
driver: 'Driver',
environmentSupport: 'Environment Support',
required: 'Required',
optional: 'Optional',
},
} as const
function ProbeSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
@@ -255,6 +406,26 @@ function formatMB(value: number) {
return `${value} MB`
}
function formatCPUThreads(cores: number, threads: number, language: Language) {
return language === 'en' ? `${cores} cores / ${threads} threads` : `${cores} 核 / ${threads} 线程`
}
function formatUsedMemory(usedMB: number, language: Language) {
return language === 'en' ? `${formatMB(usedMB)} used` : `${formatMB(usedMB)} 已用`
}
function formatDiskCount(count: number, language: Language) {
return language === 'en' ? `${count} disk${count === 1 ? '' : 's'}` : `${count} 块硬盘`
}
function formatProcessCount(count: number, language: Language) {
return language === 'en' ? `${count} process${count === 1 ? '' : 'es'}` : `${count} 个进程`
}
function formatItemCount(count: number, language: Language) {
return language === 'en' ? `${count} item${count === 1 ? '' : 's'}` : `${count}`
}
function formatBytes(value: number) {
if (!value) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
@@ -267,20 +438,24 @@ function formatBytes(value: number) {
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
}
function formatLifeUsed(value?: number) {
function formatLifeUsed(value: number | undefined, language: Language) {
if (value === undefined || value === null) return '-'
return `${value}% 已用\n${Math.max(0, 100 - value)}% 剩余`
const text = hostReportText[language]
return `${value}% ${text.used}\n${Math.max(0, 100 - value)}% ${text.remaining}`
}
function formatPowerOnDays(hours: number) {
function formatPowerOnDays(hours: number, language: Language) {
const days = Math.floor(hours / 24)
const rest = hours % 24
return days > 0 ? `${days}${rest} 小时` : `${hours} 小时`
return language === 'en'
? (days > 0 ? `${days} days ${rest} hours` : `${hours} hours`)
: (days > 0 ? `${days}${rest} 小时` : `${hours} 小时`)
}
function formatCommands(read?: number, write?: number) {
function formatCommands(read: number | undefined, write: number | undefined, language: Language) {
if (!read && !write) return '-'
return `${formatCount(read || 0)}\n写 ${formatCount(write || 0)}`
const text = hostReportText[language]
return `${text.read} ${formatCount(read || 0)}\n${text.write} ${formatCount(write || 0)}`
}
function formatCount(value: number) {
@@ -291,38 +466,62 @@ function formatCount(value: number) {
return `${value}`
}
function formatWear(wear?: string, erase?: string, powerCycles?: number) {
function formatWear(wear: string | undefined, erase: string | undefined, powerCycles: number | undefined, language: Language) {
const text = hostReportText[language]
const rows: string[] = []
if (wear) rows.push(`磨损 ${wear}`)
if (erase) rows.push(`擦写 ${erase}`)
if (powerCycles) rows.push(`启停 ${powerCycles}`)
if (wear) rows.push(`${text.wear} ${wear}`)
if (erase) rows.push(`${text.erase} ${erase}`)
if (powerCycles) rows.push(`${text.powerCycles} ${powerCycles}`)
return rows.length ? rows.join('\n') : '-'
}
function runtimeModeLabel(value: string) {
function runtimeModeLabel(value: string, language: Language) {
switch (value) {
case 'kvm_lxc':
return '支持 KVM + LXC'
return language === 'en' ? 'KVM + LXC supported' : '支持 KVM + LXC'
case 'lxc_only':
return '仅支持 LXC'
return language === 'en' ? 'LXC only' : '仅支持 LXC'
default:
return '未满足运行环境'
return language === 'en' ? 'Runtime requirements not met' : '未满足运行环境'
}
}
function diskHealthLabel(value: string) {
function diskHealthLabel(value: string, language: Language) {
const text = hostReportText[language]
switch (value) {
case 'ok':
return '健康'
return language === 'en' ? 'Healthy' : '健康'
case 'failed':
return '异常'
return language === 'en' ? 'Failed' : '异常'
case 'virtual':
return text.virtualDisk
default:
return '未知'
return language === 'en' ? 'Unknown' : '未知'
}
}
function gpuTypeLabel(value: string) {
if (value === 'integrated') return '核显'
if (value === 'discrete') return '独显'
function diskHealthDetail(d: { virtual?: boolean; health_detail?: string }, language: Language) {
if (d.virtual) return hostReportText[language].virtualDiskDetail
return translateDynamic(d.health_detail || '', language)
}
function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boolean }, language: Language) {
if (d.virtual || d.type === 'Virtual') return hostReportText[language].virtualDisk
return d.type || (d.rotational ? 'HDD' : 'SSD')
}
function gpuTypeLabel(value: string, language: Language) {
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
return value || '-'
}
function translateDynamic(value: string, language: Language) {
if (language !== 'en' || !value) return value
return translateText(value)
.replace(/寿命已用\s*(\d+)%/g, 'Lifetime used $1%')
.replace(/通电\s*(\d+)h/g, 'Power-on $1h')
.replace(/写入\s*([^|]+)/g, 'Written $1')
.replace(/读取\s*([^|]+)/g, 'Read $1')
.replace(/介质错误\s*(\d+)/g, 'Media errors $1')
}
+25 -3
View File
@@ -2,9 +2,21 @@ import { FormEvent, useState } from 'react'
import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import AutoTranslate from '../components/AutoTranslate'
import BrowserDialogTranslator from '../components/BrowserDialogTranslator'
function LanguageIcon({ className = '' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z" fill="currentColor" />
</svg>
)
}
export default function Login() {
const { login, accessCodeLogin } = useAuth()
const { language, toggleLanguage, t } = useLanguage()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
@@ -29,7 +41,7 @@ export default function Login() {
}
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || '登录失败,请检查用户名和密码')
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
} finally {
setLoading(false)
}
@@ -37,6 +49,16 @@ export default function Login() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<AutoTranslate />
<BrowserDialogTranslator />
<button
type="button"
onClick={() => { void toggleLanguage() }}
className="absolute right-4 top-4 inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50"
>
<LanguageIcon className="h-3.5 w-3.5" />
{language === 'en' ? '中文' : 'English'}
</button>
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8">
@@ -44,7 +66,7 @@ export default function Login() {
<AppIcon className="w-10 h-10" />
</div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'LXC Container Manager'}</p>
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'Container Manager'}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
@@ -106,7 +128,7 @@ export default function Login() {
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.7</p>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.15</p>
</div>
</div>
)
File diff suppressed because it is too large Load Diff
+114 -22
View File
@@ -1,13 +1,16 @@
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react'
import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Terminal, Upload, UserCog } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
getSSLSettings,
getWebSSHOriginSettings,
LoginLog,
SSLSettings,
updateSSLSettings,
updateWebSSHOriginSettings,
WebSSHOriginSettings,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
@@ -33,6 +36,9 @@ export default function Settings() {
const [keyPEM, setKeyPEM] = useState('')
const [applyNow, setApplyNow] = useState(true)
const [savingSSL, setSavingSSL] = useState(false)
const [webSSHOrigins, setWebSSHOrigins] = useState<WebSSHOriginSettings | null>(null)
const [webSSHOriginsText, setWebSSHOriginsText] = useState('')
const [savingWebSSHOrigins, setSavingWebSSHOrigins] = useState(false)
const fetchLogs = useCallback(async () => {
try {
@@ -60,12 +66,25 @@ export default function Settings() {
}
}, [])
const fetchWebSSHOrigins = useCallback(async () => {
try {
const res = await getWebSSHOriginSettings()
const data = res.data.data
if (!data) return
setWebSSHOrigins(data)
setWebSSHOriginsText((data.origins || []).join('\n'))
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
fetchLogs()
fetchSSL()
fetchWebSSHOrigins()
const timer = setInterval(fetchLogs, 15000)
return () => clearInterval(timer)
}, [fetchLogs, fetchSSL])
}, [fetchLogs, fetchSSL, fetchWebSSHOrigins])
const handleSSLModeChange = (mode: SSLSettings['mode']) => {
setSSLMode(mode)
@@ -101,6 +120,25 @@ export default function Settings() {
}
}
const handleSaveWebSSHOrigins = async () => {
setSavingWebSSHOrigins(true)
try {
const origins = webSSHOriginsText.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
const res = await updateWebSSHOriginSettings(origins)
const data = res.data.data
if (data) {
setWebSSHOrigins(data)
setWebSSHOriginsText((data.origins || []).join('\n'))
}
dialog.alert('完成', 'Origin 白名单已保存')
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || 'Origin 白名单保存失败')
} finally {
setSavingWebSSHOrigins(false)
}
}
const handleSaveAccount = async () => {
if (!oldPwd) {
dialog.alert('提示', '请输入当前密码以确认修改')
@@ -159,26 +197,37 @@ export default function Settings() {
</div>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<div className="space-y-6">
<SSLCard
ssl={ssl}
sslEnabled={sslEnabled}
sslMode={sslMode}
sslTarget={sslTarget}
sslEmail={sslEmail}
certPEM={certPEM}
keyPEM={keyPEM}
applyNow={applyNow}
savingSSL={savingSSL}
onRefresh={fetchSSL}
onEnabledChange={setSSLEnabled}
onModeChange={handleSSLModeChange}
onTargetChange={setSSLTarget}
onEmailChange={setSSLEmail}
onCertChange={setCertPEM}
onKeyChange={setKeyPEM}
onApplyNowChange={setApplyNow}
onSave={handleSaveSSL}
/>
<WebSSHOriginCard
settings={webSSHOrigins}
originsText={webSSHOriginsText}
saving={savingWebSSHOrigins}
onOriginsTextChange={setWebSSHOriginsText}
onRefresh={fetchWebSSHOrigins}
onSave={handleSaveWebSSHOrigins}
/>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-5">
<h2 className="mb-4 flex items-center gap-2 text-sm font-semibold text-black">
@@ -232,6 +281,49 @@ interface SSLCardProps {
onSave: () => void
}
interface WebSSHOriginCardProps {
settings: WebSSHOriginSettings | null
originsText: string
saving: boolean
onOriginsTextChange: (value: string) => void
onRefresh: () => void
onSave: () => void
}
function WebSSHOriginCard(props: WebSSHOriginCardProps) {
return (
<div className="rounded-lg border border-gray-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-black">
<Terminal className="h-4 w-4" />WebSSH Origin
</h2>
<button onClick={props.onRefresh} className="rounded-md border border-gray-200 p-1.5 text-gray-500 hover:bg-gray-50" title="刷新">
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-gray-500"> Origin</label>
<textarea
value={props.originsText}
onChange={(e) => props.onOriginsTextChange(e.target.value)}
rows={5}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono text-xs text-black"
/>
</div>
<div className="rounded-md border border-gray-100 bg-gray-50 p-3 text-xs text-gray-600">
<div className="truncate font-mono" title={props.settings?.current_origin || ''}>{props.settings?.current_origin || '-'}</div>
<div className="mt-1"> Origin</div>
</div>
<button onClick={props.onSave} disabled={props.saving} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Upload className="h-4 w-4" />
{props.saving ? '保存中...' : '保存 Origin 白名单'}
</button>
</div>
</div>
)
}
function SSLCard(props: SSLCardProps) {
const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode]
const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [
+100 -2
View File
@@ -40,10 +40,24 @@ export type ContainerIdentifier = number | string
export interface PortMapping {
container_port: number
host_port: number
host_ip?: string
protocol: string
description: string
}
export interface PublicIPv4Assignment {
address: string
interface?: string
prefix_len?: number
gateway?: string
}
export interface IPv6Assignment {
address: string
prefix_len: number
interface?: string
}
export interface Container {
id: number
uuid: string
@@ -64,9 +78,11 @@ export interface Container {
io_speed_mbps: number
status: string
ip: string
public_ipv4s?: PublicIPv4Assignment[]
ipv6: string
ipv6_prefix_len: number
ipv6_interface: string
ipv6_addresses?: IPv6Assignment[]
vnc_port: number
ssh_port: number
ssh_password: string
@@ -114,11 +130,26 @@ export interface CreateContainerRequest {
io_speed_mbps: number
extra_ports: number[]
port_mapping_count: number
assign_nat?: boolean
snapshot_limit: number
assign_ipv4?: boolean
ipv4_count?: number
public_ipv4s?: string[]
assign_ipv6: boolean
ipv6_count?: number
ipv6_addresses?: string[]
ssh_auth_mode?: string
ssh_password?: string
ssh_public_key?: string
expires_at: string
}
export interface ReinstallContainerOptions {
ssh_auth_mode?: string
ssh_password?: string
ssh_public_key?: string
}
export interface IPv6PrefixInfo {
interface: string
address: string
@@ -136,6 +167,17 @@ export interface IPv6Status {
prefixes: IPv6PrefixInfo[]
}
export interface PublicIPv4Info {
interface: string
address: string
prefix: string
prefix_len?: number
subnet_mask?: string
gateway?: string
is_tunnel?: boolean
source?: string
}
export interface IPv4PrefixInfo {
interface: string
address: string
@@ -163,6 +205,7 @@ export interface HostInfo {
tx_bps: number
public_ipv4?: string
public_ipv4_interface?: string
public_ipv4_addresses?: PublicIPv4Info[]
public_ipv6?: string
public_ipv6_interface?: string
ipv6_prefixes?: IPv6PrefixInfo[]
@@ -207,6 +250,7 @@ export interface HostProbeReport {
serial: string
size_bytes: number
type: string
virtual?: boolean
rotational: boolean
mountpoints: string[]
health: string
@@ -358,6 +402,17 @@ export const getSSLSettings = () =>
export const updateSSLSettings = (data: UpdateSSLSettingsRequest) =>
api.put<APIResponse<SSLSettings>>('/ssl', data)
export interface WebSSHOriginSettings {
origins: string[]
current_origin?: string
}
export const getWebSSHOriginSettings = () =>
api.get<APIResponse<WebSSHOriginSettings>>('/webssh-origins')
export const updateWebSSHOriginSettings = (origins: string[]) =>
api.put<APIResponse<WebSSHOriginSettings>>('/webssh-origins', { origins })
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')
@@ -380,8 +435,8 @@ export const stopContainer = (id: ContainerIdentifier) =>
export const restartContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/restart`)
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const reinstallContainer = (id: ContainerIdentifier, templateId: string, options?: ReinstallContainerOptions) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId, ...(options || {}) })
export const resetSSHPassword = (id: ContainerIdentifier, password?: string) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`, password ? { password } : {})
@@ -453,12 +508,24 @@ export interface NAT4Route {
lxc_name: string
status: string
ip: string
host_ip: string
host_port: number
container_port: number
protocol: string
description: string
}
export interface IPv4Route {
container_id: number
container_name: string
lxc_name: string
status: string
address: string
interface: string
prefix_len?: number
gateway?: string
}
export interface IPv6Route {
container_id: number
container_name: string
@@ -471,15 +538,37 @@ export interface IPv6Route {
export interface RoutingInfo {
nat4: RouteCapacity
ipv4: RouteCapacity
ipv6: RouteCapacity
host_public_ipv4?: PublicIPv4Info
public_ipv4_addresses: PublicIPv4Info[]
ipv4_assignments: IPv4Route[]
nat4_mappings: NAT4Route[]
ipv6_assignments: IPv6Route[]
ipv6_prefixes: IPv6PrefixInfo[]
}
export interface PublicIPv4ScanResult extends PublicIPv4Info {
status: string
usable: boolean
reason: string
}
export const getRoutingInfo = () =>
api.get<APIResponse<RoutingInfo>>('/routing')
export const updateRoutingPools = (payload: { items?: PublicIPv4Info[]; ipv6_prefixes?: IPv6PrefixInfo[] }) =>
api.put<APIResponse<RoutingInfo>>('/routing', payload)
export const updateRoutingIPv4Pool = (items: PublicIPv4Info[]) =>
updateRoutingPools({ items })
export const updateRoutingIPv6Prefixes = (ipv6_prefixes: IPv6PrefixInfo[]) =>
updateRoutingPools({ ipv6_prefixes })
export const scanRoutingIPv4Segment = (payload: { cidr: string; interface: string; gateway: string; verify: boolean; limit?: number }) =>
api.post<APIResponse<PublicIPv4ScanResult[]>>('/routing/ipv4-scan', payload)
// Templates
export const getTemplates = () =>
api.get<APIResponse<Template[]>>('/templates')
@@ -714,6 +803,15 @@ export const createWebSSHTicket = (containerName: string) =>
export const createVNCTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
// Language
export type PanelLanguage = 'zh' | 'en'
export const getLanguage = () =>
api.get<APIResponse<{ language: PanelLanguage }>>('/language')
export const updateLanguage = (language: PanelLanguage) =>
api.post<APIResponse<{ language: PanelLanguage }>>('/language', { language })
// Version
export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version')
+940
View File
@@ -0,0 +1,940 @@
const exact: Record<string, string> = {
'控制面板': 'Dashboard',
'共': 'Total',
'第': 'Page',
'页': 'page',
'个': 'items',
'条': 'records',
'核': 'cores',
'个容器': 'containers',
'条操作记录': 'audit records',
'个地址': 'addresses',
'列表': 'List',
'主机资源': 'Host Resources',
'主机资源状态': 'Host Resource Status',
'容器管理': 'Containers',
'镜像管理': 'Images',
'安全告警': 'Security Alerts',
'快照管理': 'Snapshots',
'路由管理': 'Routing',
'操作日志': 'Audit Logs',
'子用户管理': 'Sub Users',
'API 集成': 'API Integration',
'宿主机信息': 'Host Info',
'面板设置': 'Panel Settings',
'退出登录': 'Log out',
'亮色模式': 'Light Mode',
'暗黑模式': 'Dark Mode',
'切换亮色模式': 'Switch to light mode',
'切换暗黑模式': 'Switch to dark mode',
'切换侧边栏': 'Toggle sidebar',
'刷新': 'Refresh',
'搜索': 'Search',
'复制': 'Copy',
'编辑': 'Edit',
'删除': 'Delete',
'保存': 'Save',
'提交': 'Submit',
'应用': 'Apply',
'查看': 'View',
'详情': 'Details',
'返回': 'Back',
'返回列表': 'Back to list',
'取消': 'Cancel',
'确认': 'Confirm',
'确定': 'OK',
'完成': 'Done',
'失败': 'Failed',
'成功': 'Success',
'提示': 'Notice',
'警告': 'Warning',
'开机': 'Start',
'关机': 'Stop',
'重启': 'Restart',
'重装': 'Reinstall',
'创建': 'Create',
'在线': 'Online',
'离线': 'Offline',
'永久': 'Permanent',
'长期有效': 'No expiration',
'长期': 'No expiration',
'不限制': 'Unlimited',
'未设置流量限制': 'No traffic limit set',
'未设置': 'Not set',
'已选': 'Selected',
',已选': ', selected',
'筛选后': 'Filtered',
',筛选后': ', filtered',
'每页数量': 'Items per page',
'任务中': 'In task',
'1周': '1 week',
'1 周': '1 week',
'资源配置': 'Resource Configuration',
'实时状态': 'Live Status',
'连接信息': 'Connection Info',
'管理链接': 'Management Link',
'NAT 管理': 'NAT Management',
'快照': 'Snapshots',
'系统': 'System',
'全部类型': 'All types',
'全部系统': 'All systems',
'全部状态': 'All statuses',
'类型筛选': 'Type filter',
'系统筛选': 'System filter',
'状态筛选': 'Status filter',
'内网': 'Private IP',
'内网 IP': 'Private IP',
'策略封禁': 'Policy Blocked',
'已封禁': 'Blocked',
'已到期': 'Expired',
'识别码': 'Identifier',
'CPU 累计时间': 'CPU Total Time',
'创建时间': 'Created At',
'网络速率': 'Network Speed',
'IO 速度': 'IO Speed',
'月流量': 'Monthly Traffic',
'统计信息': 'Statistics',
'CPU 使用率': 'CPU Usage',
'内存使用': 'Memory Usage',
'网络流量': 'Network Traffic',
'磁盘IO': 'Disk IO',
'磁盘 IO': 'Disk IO',
'负载': 'Load',
'平均': 'Average',
'峰值': 'Peak',
'容量': 'Capacity',
'累计': 'Total',
'读': 'Read',
'写': 'Write',
'入': 'In',
'出': 'Out',
'运行中': 'Running',
'已停止': 'Stopped',
'已完成': 'Completed',
'等待中': 'Pending',
'执行中': 'Running',
'未知': 'Unknown',
'必要': 'Required',
'可选': 'Optional',
'用户名': 'Username',
'密码': 'Password',
'输入用户名': 'Enter username',
'输入密码': 'Enter password',
'登录': 'Log in',
'登录中...': 'Logging in...',
'登录失败,请检查用户名和密码': 'Login failed. Check your username and password.',
'Authentication required': 'Authentication required',
'Administrator permission required': 'Administrator permission required',
'Method not allowed': 'Method not allowed',
'Invalid request body': 'Invalid request body',
'Invalid credentials': 'Invalid credentials',
'Access denied': 'Access denied',
'Access denied to this container': 'Access denied to this container',
'Container not found': 'Container not found',
'Template not found': 'Template not found',
'Template is required': 'Template is required',
'Template is not enabled or downloaded': 'Template is not enabled or downloaded',
'Container name is required': 'Container name is required',
'Container created successfully': 'Container created successfully',
'Password changed successfully': 'Password changed successfully',
'SSL settings saved': 'SSL settings saved',
'Save SSL settings failed': 'Save SSL settings failed',
'Task deleted': 'Task deleted',
'Snapshot deleted': 'Snapshot deleted',
'Snapshot restored': 'Snapshot restored',
'Security check completed': 'Security check completed',
'当前密码不正确': 'Current password is incorrect',
'密码不正确': 'Password is incorrect',
'新密码至少 6 位': 'New password must be at least 6 characters',
'用户名至少 3 位': 'Username must be at least 3 characters',
'密码加密失败': 'Failed to hash password',
'保存配置失败': 'Failed to save configuration',
'密码修改成功': 'Password changed successfully',
'用户名修改成功': 'Username changed successfully',
'容器已到期,不允许此操作': 'Container has expired. This action is not allowed.',
'容器管理登录': 'Container Access Login',
'操作失败': 'Action failed',
'错误': 'Error',
'保存失败': 'Save failed',
'重装失败': 'Reinstall failed',
'密码重置失败': 'Password reset failed',
'端口配额已满': 'Port quota reached',
'输入错误': 'Input error',
'请输入有效的内部端口': 'Enter a valid internal port',
'密码长度必须为 8-64 位': 'Password length must be 8-64 characters',
'密码不能包含空白字符': 'Password cannot contain whitespace',
'密码至少需要包含字母': 'Password must contain at least one letter',
'密码至少需要包含数字': 'Password must contain at least one number',
'密码格式不正确': 'Invalid password format',
'策略临时封禁': 'Temporarily blocked by policy',
'虚拟机被策略临时封禁,暂不能执行操作。': 'This VM is temporarily blocked by policy and cannot perform actions.',
'确定要删除容器': 'Delete container',
'吗?此操作不可撤销。': '? This action cannot be undone.',
'容器名称不能包含空格': 'Container name cannot contain spaces',
'该容器名称已存在': 'Container name already exists',
'请填写容器名称并选择系统模板': 'Enter a container name and select a system template',
'资源配置有误': 'Invalid resource configuration',
'请按红色提示修改 vCPU、内存或磁盘配置': 'Fix the vCPU, memory, or disk fields marked in red',
'创建失败': 'Create failed',
'创建新容器': 'Create New Container',
'批量创建数量': 'Batch Count',
'虚拟化架构': 'Virtualization',
'LXC 容器': 'LXC Container',
'KVM 虚拟机': 'KVM VM',
'系统模板': 'System Template',
'搜索名称、ID、UUID、IP': 'Search name, ID, UUID, IP',
'带宽 (Mbps)': 'Bandwidth (Mbps)',
'双向统计': 'Total In+Out',
'入/出分离': 'Separate In/Out',
'GB (0=不限制)': 'GB (0=unlimited)',
'入站 (GB)': 'Inbound (GB)',
'出站 (GB)': 'Outbound (GB)',
'NAT 端口映射数量': 'NAT Port Mapping Count',
'子用户快照上限': 'Sub-user Snapshot Limit',
'到期时间': 'Expiration Time',
'不选择则长期有效;选择日期后,到期会自动关机。': 'Leave blank for no expiration. If a date is selected, the container will shut down automatically when it expires.',
'创建中...': 'Creating...',
'请输入 vCPU': 'Enter vCPU',
'内存 (MB)': 'Memory (MB)',
'磁盘 (GB)': 'Disk (GB)',
'IO 速度 (MB/s)': 'IO Speed (MB/s)',
'将创建': 'Will create',
'暂无可用的': 'No available',
'不能小于': 'Cannot be less than',
'不能大于': 'Cannot be greater than',
'KVM vCPU 必须是整数': 'KVM vCPU must be an integer',
'请输入内存': 'Enter memory',
'请输入磁盘': 'Enter disk',
'轮换失败': 'Rotation failed',
'获取操作日志失败': 'Failed to load audit logs',
'获取登录日志失败': 'Failed to load login logs',
'暂无子用户': 'No sub-users',
'容器名称': 'Container Name',
'最后登录': 'Last Login',
'从未登录': 'Never logged in',
'查看密码': 'View Password',
'查看操作日志': 'View Audit Logs',
'查看登录日志': 'View Login Logs',
'轮换密码': 'Rotate Password',
'轮换中...': 'Rotating...',
'用户': 'User',
'未保存,请轮换生成新密码': 'Not saved. Rotate to generate a new password.',
'操作时间': 'Action Time',
'登录时间': 'Login Time',
'登录 IP': 'Login IP',
'请输入当前密码以确认修改': 'Enter current password to confirm changes',
'至少填写新密码或新用户名中的一项': 'Enter at least a new password or a new username',
'用户名已修改': 'Username changed',
'用户名修改失败': 'Username change failed',
'密码已修改': 'Password changed',
'密码修改失败': 'Password change failed',
'下次登录生效': 'Takes effect at next login',
'修改失败': 'Change failed',
'账号、安全证书与登录日志': 'Account, certificates, and login logs',
'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。': 'SSL settings saved. The service is restarting. Reopen the panel with the new protocol shortly.',
'SSL 设置已保存,重启 clicd 服务后生效。': 'SSL settings saved. Restart the clicd service to apply them.',
'SSL 设置保存失败': 'Failed to save SSL settings',
'纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。': 'Pure IP certificates require Certbot 5.4+ on the server, and port 80 must be reachable by Lets Encrypt during validation. IP certificates are short-lived, so certbot auto-renewal must remain enabled.',
'自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。': 'Self-signed certificates can encrypt the panel and VNC, but browsers will show an untrusted certificate warning. The system will renew them automatically before expiration.',
'上传来源还没有保存证书,请粘贴证书和私钥后保存。': 'No certificate has been saved for the uploaded source. Paste the certificate and private key, then save.',
'当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。': 'No certificate has been saved for the current source. It will be generated or requested when SSL settings are saved.',
'暂无容器': 'No containers',
'暂无快照': 'No snapshots',
'暂无操作日志': 'No audit logs',
'暂无登录日志': 'No login logs',
'暂无登录记录': 'No login records',
'暂无 NAT4 端口映射': 'No NAT4 port mappings',
'暂无 IPv6 地址分配': 'No IPv6 assignments',
'暂无可分配 IPv6 前缀': 'No allocatable IPv6 prefixes',
'暂无镜像': 'No images',
'暂无数据': 'No data',
'容器': 'Container',
'名称': 'Name',
'状态': 'Status',
'剩余时间': 'Time Left',
'配置': 'Config',
'镜像': 'Image',
'内存': 'Memory',
'磁盘': 'Disk',
'流量': 'Traffic',
'操作': 'Actions',
'类型': 'Type',
'创建者': 'Creator',
'管理员密码': 'Admin Password',
'SSH 密码': 'SSH Password',
'SSH 地址': 'SSH Address',
'RDP 地址': 'RDP Address',
'VNC 端口': 'VNC Port',
'点击隐藏': 'Click to hide',
'点击显示': 'Click to show',
'编辑资源限制': 'Edit resource limits',
'编辑流量限制': 'Edit traffic limit',
'修改到期时间': 'Change expiration time',
'新 SSH 密码': 'New SSH Password',
'生成随机密码': 'Generate random password',
'密码已修改成功': 'Password changed successfully',
'修改中...': 'Changing...',
'确认修改': 'Confirm Change',
'容器不存在': 'Container not found',
'容器未运行,请先开机': 'Container is not running. Start it first.',
'VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面': 'VNC console is unavailable. Make sure the KVM VM is running and refresh the page.',
'虚拟机被策略临时封禁': 'VM temporarily blocked by policy',
'虚拟机被策略临时封禁,连接信息暂不可用。': 'This VM is temporarily blocked by policy. Connection info is unavailable.',
'已达到管理员分配的 NAT 端口配额。': 'The NAT port quota assigned by the administrator has been reached.',
'保存端口映射失败': 'Failed to save port mapping',
'删除端口映射失败': 'Failed to delete port mapping',
'删除映射': 'Delete Mapping',
'确定要删除这条映射规则吗?': 'Delete this mapping rule?',
'快照配额已满': 'Snapshot quota reached',
'已达到管理员设置的快照配额,请先删除旧快照。': 'The snapshot quota set by the administrator has been reached. Delete old snapshots first.',
'拍摄快照': 'Take Snapshot',
'拍摄快照需要先关机,完成后会自动重启容器': 'Taking a snapshot requires shutdown first. The container will restart automatically afterward',
'是否继续?': 'Continue?',
'创建快照失败': 'Failed to create snapshot',
'参数错误': 'Invalid parameters',
'自动快照周期最低是 1 天一次。': 'The minimum automatic snapshot interval is once per day.',
'定时快照失败': 'Scheduled snapshot failed',
'保存快照配额失败': 'Failed to save snapshot quota',
'删除快照失败': 'Failed to delete snapshot',
'恢复快照失败': 'Failed to restore snapshot',
'确定恢复到': 'Restore to',
'当前容器数据会被覆盖。': 'Current container data will be overwritten.',
'确定删除': 'Delete',
'的快照吗?': 'snapshot?',
'新建快照': 'New Snapshot',
'定时设置': 'Schedule Settings',
'定时快照': 'Scheduled Snapshot',
'处理中...': 'Processing...',
'快照数量:': 'Snapshot count:',
'子用户配额:': 'Sub-user quota:',
'定时状态:': 'Schedule status:',
'下次执行:': 'Next run:',
'未开启': 'Off',
'已开启': 'On',
'每': 'Every',
'执行': 'run',
'子用户每台容器快照上限': 'Sub-user snapshot limit per container',
'自动快照周期': 'Automatic snapshot interval',
'天': 'days',
'小时': 'hours',
'分钟': 'minutes',
'秒': 'seconds',
'大小': 'Size',
'手动': 'Manual',
'定时': 'Scheduled',
'时间': 'Time',
'设备': 'Device',
'结果': 'Result',
'地址': 'Address',
'前缀': 'Prefix',
'出口网卡': 'Uplink',
'宿主地址': 'Host Address',
'协议': 'Protocol',
'说明': 'Description',
'端口': 'Port',
'容器端口': 'Container Port',
'宿主机端口': 'Host Port',
'容器 IPv4': 'Container IPv4',
'IPv6 地址': 'IPv6 Address',
'IPv6 前缀': 'IPv6 Prefix',
'可分配 IPv6 前缀': 'Allocatable IPv6 Prefixes',
'编辑前缀': 'Edit Prefixes',
'添加 IPv6 前缀': 'Add IPv6 Prefix',
'保存前缀': 'Save Prefixes',
'服务商面板里的额外 IPv6 段不会自动出现在网卡里,请把可分配的前缀手动填入这里,例如 2401:b60:26:5e::2/64。': 'Extra IPv6 prefixes from the provider panel will not automatically appear on the NIC. Enter allocatable prefixes here manually, for example 2401:b60:26:5e::2/64.',
'LXC 名称': 'LXC Name',
'快照时间': 'Snapshot Time',
'删除快照': 'Delete Snapshot',
'全局快照列表': 'Global snapshot list',
'主机名': 'Hostname',
'操作系统': 'Operating System',
'内核': 'Kernel',
'生成时间': 'Generated At',
'系统概览': 'System Overview',
'公网与路由': 'Public Network & Routing',
'内存条': 'Memory Modules',
'硬盘与健康': 'Disks & Health',
'网卡': 'Network Interfaces',
'显卡': 'GPUs',
'环境支持': 'Environment Support',
'服务管理器 systemd/OpenRC': 'Service Manager systemd/OpenRC',
'LXC 创建工具': 'LXC Create Tool',
'LXC 启动工具': 'LXC Start Tool',
'iptables 网络规则': 'iptables Network Rules',
'iproute2 网络工具': 'iproute2 Network Tool',
'conntrack 安全扫描': 'conntrack Security Scan',
'QEMU/KVM 虚拟机': 'QEMU/KVM Virtualization',
'KVM cloud-init ISO 工具': 'KVM cloud-init ISO Tool',
'ISO 备用工具': 'ISO Fallback Tool',
'硬盘健康检测': 'Disk Health Check',
'Certbot 证书工具 >= 5.4': 'Certbot Certificate Tool >= 5.4',
'/dev/kvm 硬件虚拟化': '/dev/kvm Hardware Virtualization',
'IPv4 转发': 'IPv4 Forwarding',
'lxcfs 服务': 'lxcfs Service',
'libvirt 服务': 'libvirt Service',
'正在探测宿主机环境...': 'Probing host environment...',
'暂未获取到宿主机信息': 'No host information available',
'面板资源状态与容器概览': 'Panel resource status and container overview',
'宿主机资源状态与容器概览': 'Host resource status and container overview',
'账号设置': 'Account Settings',
'当前用户名': 'Current Username',
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
'新密码,留空则不修改': 'New Password, leave blank to keep unchanged',
'当前密码,验证身份': 'Current Password, for verification',
'至少 3 位': 'At least 3 characters',
'至少 6 位': 'At least 6 characters',
'输入当前密码以确认修改': 'Enter current password to confirm changes',
'保存修改': 'Save Changes',
'SSL 证书': 'SSL Certificate',
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
'IP / 域名': 'IP / Domain',
'服务器公网 IP 或域名': 'Server public IP or domain',
'邮箱,可选': 'Email, optional',
'自签证书': 'Self-signed Certificate',
'上传证书': 'Uploaded Certificate',
'证书 PEM / fullchain.pem': 'Certificate PEM / fullchain.pem',
'私钥 PEM / privkey.pem': 'Private Key PEM / privkey.pem',
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
'保存中...': 'Saving...',
'保存 SSL 设置': 'Save SSL Settings',
'WebSSH Origin 白名单': 'WebSSH Origin Allowlist',
'允许的 Origin': 'Allowed Origins',
'当前面板来源:': 'Current panel origin:',
'默认允许当前面板来源和本机回环来源;额外域名每行填写一个完整 Origin。': 'The current panel origin and local loopback origins are allowed by default. Add one full Origin per line.',
'保存 Origin 白名单': 'Save Origin Allowlist',
'Origin 白名单已保存': 'Origin allowlist saved',
'Origin 白名单保存失败': 'Failed to save Origin allowlist',
'登录日志': 'Login Logs',
'首页': 'First',
'上一页': 'Previous',
'下一页': 'Next',
'末页': 'Last',
'搜索端口/容器...': 'Search port/container...',
'搜索地址/容器...': 'Search address/container...',
'NAT4 端口': 'NAT4 Ports',
'NAT4 端口分配': 'NAT4 Port Allocation',
'IPv6 地址分配': 'IPv6 Address Allocation',
'剩余端口 / 端口总数': 'Available Ports / Total Ports',
'剩余地址 / 地址总数': 'Available Addresses / Total Addresses',
'已分配': 'Allocated',
'充足': 'Enough',
'容器列表': 'Container List',
'刷新列表': 'Refresh list',
'创建容器': 'Create Container',
'点击"创建容器"开始': 'Click "Create Container" to start',
'创建中': 'Creating',
'批量创建': 'Batch Create',
'导入容器': 'Import Container',
'重置密码': 'Reset Password',
'WebSSH': 'WebSSH',
'WebVNC': 'WebVNC',
'发送 Ctrl+Alt+Del': 'Send Ctrl+Alt+Del',
'重新连接': 'Reconnect',
'关闭': 'Close',
'已连接': 'Connected',
'连接中...': 'Connecting...',
'已断开': 'Disconnected',
'连接失败': 'Connection Failed',
'正在连接 KVM VNC 控制台...': 'Connecting to KVM VNC console...',
'WebVNC 已断开': 'WebVNC disconnected',
'下载': 'Download',
'下载中': 'Downloading',
'启用': 'Enable',
'停用': 'Disable',
'已启用': 'Enabled',
'未启用': 'Disabled',
'系统镜像': 'System Images',
'安全检查': 'Security Check',
'告警列表': 'Alert List',
'自动关机已开': 'Auto-stop on',
'自动关机已关': 'Auto-stop off',
'暂无安全告警': 'No security alerts',
'严重': 'Critical',
'高': 'High',
'中': 'Medium',
'低': 'Low',
'管理员': 'Admin',
'子用户': 'Sub User',
'公网 IPv4': 'Public IPv4',
'IPv4 地址': 'IPv4 Addresses',
'IPv4 段': 'IPv4 Prefixes',
'IPv6 段': 'IPv6 Prefixes',
'网关': 'Gateways',
'CPU 架构': 'CPU Architecture',
'CPU 虚拟化指令': 'CPU Virtualization Flags',
'CPU 核显': 'Integrated GPU',
'运行能力': 'Runtime Capability',
'KVM 嵌套虚拟化': 'KVM Nested Virtualization',
'支持': 'Supported',
'未检测到': 'Not detected',
'检测到': 'Detected',
'有效': 'Valid',
'已过期或未生效': 'Expired or not active',
'是': 'Yes',
'否': 'No',
'开启': 'On',
'已关闭': 'Off',
'自动': 'Auto',
'默认': 'Default',
'全部': 'All',
'无': 'None',
'根目录': 'Root',
'版本': 'Version',
'当前': 'Current',
'最近': 'Recent',
'来源': 'Source',
'目标': 'Target',
'描述': 'Description',
'备注': 'Notes',
'搜索容器...': 'Search containers...',
'搜索镜像...': 'Search images...',
'搜索日志...': 'Search logs...',
'复制成功': 'Copied',
'复制失败': 'Copy failed',
'请稍后重试': 'Please try again later',
'请稍后重试。': 'Please try again later.',
'开机中...': 'Starting...',
'关机中...': 'Stopping...',
'重启中...': 'Restarting...',
'删除中...': 'Deleting...',
'重装中...': 'Reinstalling...',
'开机中': 'Starting',
'关机中': 'Stopping',
'重启中': 'Restarting',
'删除中': 'Deleting',
'重装中': 'Reinstalling',
'正在初始化': 'Initializing',
'容器总数': 'Total Containers',
'驱动/速率': 'Driver / Speed',
'支持 KVM + LXC': 'KVM + LXC supported',
'仅支持 LXC': 'LXC only',
'未满足运行环境': 'Runtime requirements not met',
'健康': 'Healthy',
'异常': 'Abnormal',
'核显': 'Integrated',
'独显': 'Discrete',
'获取镜像列表失败': 'Failed to load image list',
'下载失败': 'Download failed',
'删除失败': 'Delete failed',
'取消失败': 'Cancel failed',
'删除镜像': 'Delete Image',
'确定要删除该镜像缓存吗?删除后需要重新下载才能使用。': 'Delete this image cache? You must download it again before using it.',
'取消下载并清理临时文件': 'Cancel download and clean temporary files',
'删除镜像缓存': 'Delete image cache',
'取消中': 'Cancelling',
'取消中...': 'Cancelling...',
'下载中...': 'Downloading...',
'阶段:': 'Stage:',
'转换中': 'Converting',
'端口扫描': 'Port scan',
'横向扫描': 'Lateral scan',
'暴力破解': 'Brute force',
'DDoS/大规模扫描': 'DDoS / large-scale scan',
'垃圾邮件': 'Spam',
'恶意软件': 'Malware',
'挖矿连接': 'Mining connection',
'代理/VPN/Tor': 'Proxy / VPN / Tor',
'UDP反射放大': 'UDP reflection amplification',
'高危': 'High risk',
'中危': 'Medium risk',
'低危': 'Low risk',
'告警自动关机': 'Auto shutdown on alerts',
'相关连接记录': 'Related Connection Records',
'查看相关记录': 'View related records',
'告警原始记录': 'Raw Alert Record',
'正在加载连接记录...': 'Loading connection records...',
'暂无可用连接记录。历史告警对应的 conntrack 记录可能已经过期。': 'No connection records available. Conntrack records for historical alerts may have expired.',
'源地址': 'Source Address',
'目标地址': 'Target Address',
'源IP': 'Source IP',
'次数': 'Count',
'等级': 'Severity',
'总览与只读': 'Overview & Read-only',
'路由信息': 'Routing Info',
'IPv6 状态': 'IPv6 Status',
'镜像列表': 'Image List',
'查看容器': 'View Container',
'开关机/重启': 'Power / Restart',
'重装系统': 'Reinstall OS',
'资源/到期': 'Resources / Expiration',
'流量管理': 'Traffic Management',
'端口映射': 'Port Mappings',
'分配 IPv6': 'Assign IPv6',
'快照与终端': 'Snapshots & Terminal',
'查看快照': 'View Snapshots',
'创建快照': 'Create Snapshot',
'恢复快照': 'Restore Snapshot',
'计划/配额': 'Schedule / Quota',
'平台管理': 'Platform Management',
'下载镜像': 'Download Image',
'启停镜像': 'Enable / Disable Image',
'安全数据': 'Security Data',
'安全扫描': 'Security Scan',
'安全设置': 'Security Settings',
'Swap 信息': 'Swap Info',
'Swap 管理': 'Swap Management',
'Key 列表': 'Key List',
'创建 Key': 'Create Key',
'更新 Key': 'Update Key',
'删除 Key': 'Delete Key',
'总览': 'Overview',
'NAT/IPv6 路由': 'NAT / IPv6 Routing',
'任务队列': 'Task Queue',
'任务列表': 'Task List',
'操作记录': 'audit records',
'子用户列表': 'Sub-user List',
'创建子用户': 'Create Sub-user',
'更新子用户': 'Update Sub-user',
'管理员接口': 'Admin API',
'控制面板统计': 'Dashboard Stats',
'立即安全检查': 'Run Security Check',
'返回响应样例': 'Response Example',
'请求参数': 'Request Parameters',
'响应字段': 'Response Fields',
'接口地址': 'Endpoint',
'请求方法': 'Method',
'权限范围': 'Scopes',
'绑定容器': 'Bound Containers',
'全部容器': 'All Containers',
'全权限': 'Full Access',
'取消全权限': 'Remove Full Access',
'禁用这个 Key': 'Disable this key',
'过期时间': 'Expiration Time',
'永不过期': 'Never expires',
'IP 白名单': 'IP Whitelist',
'密钥名称': 'Key Name',
'删除任务': 'Delete Task',
'容器详情': 'Container Details',
'资源用量': 'Resource Usage',
'流量统计': 'Traffic Stats',
'重置流量': 'Reset Traffic',
'调整流量限制': 'Adjust Traffic Limit',
'调整资源限制': 'Adjust Resource Limit',
'重置 SSH 密码': 'Reset SSH Password',
'端口与快照': 'Ports & Snapshots',
'随机可用端口': 'Random Available Port',
'添加端口映射': 'Add Port Mapping',
'更新端口映射': 'Update Port Mapping',
'删除端口映射': 'Delete Port Mapping',
'快照总览': 'Snapshot Overview',
'容器快照': 'Container Snapshots',
'计划快照': 'Scheduled Snapshots',
'快照配额': 'Snapshot Quota',
'模板列表': 'Template List',
'取消镜像下载': 'Cancel Image Download',
'启用/禁用镜像': 'Enable / Disable Image',
'安全连接日志': 'Security Connection Logs',
'安全汇总': 'Security Summary',
'更新安全设置': 'Update Security Settings',
'调整 Swap': 'Adjust Swap',
'批量开关机/删除/重装': 'Batch power/delete/reinstall',
'账号与日志': 'Account & Logs',
'API Key 列表': 'API Key List',
'创建 API Key': 'Create API Key',
'更新 API Key': 'Update API Key',
'删除 API Key': 'Delete API Key',
'30分钟': '30 minutes',
'1小时': '1 hour',
'1天': '1 day',
'切换中文': 'Switch to Chinese',
'WebSSH ticket 创建失败,请重新登录后再试': 'Failed to create WebSSH ticket. Log in again and retry.',
'WebSSH ticket 为空,请重新登录后再试': 'WebSSH ticket is empty. Log in again and retry.',
'WebSSH 连接失败,请确认容器已运行且 SSH 服务可用': 'WebSSH connection failed. Make sure the container is running and SSH is available.',
'WebVNC ticket 创建失败,请重新登录后再试': 'Failed to create WebVNC ticket. Log in again and retry.',
'WebVNC ticket 为空,请重新登录后再试': 'WebVNC ticket is empty. Log in again and retry.',
'WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用': 'WebVNC disconnected. Make sure the VM is running and the VNC console is available.',
'VNC 安全协商失败': 'VNC security negotiation failed',
'当前 VNC 控制台要求密码,暂不支持自动输入': 'This VNC console requires a password. Automatic input is not supported yet.',
'删除容器': 'Delete Container',
'WebSSH 票据': 'WebSSH Ticket',
'WebVNC 票据': 'WebVNC Ticket',
'容器列表(兼容 POST 写法)': 'Container List (compatible POST form)',
'调整到期时间': 'Adjust Expiration Time',
'镜像管理列表': 'Image Management List',
'批量创建容器': 'Batch Create Containers',
'创建 WebSSH 票据': 'Create WebSSH Ticket',
'创建 WebVNC 票据': 'Create WebVNC Ticket',
'创建子用户链接': 'Create Sub-user Link',
'轮换子用户密码': 'Rotate Sub-user Password',
'子用户操作日志': 'Sub-user Audit Logs',
'子用户登录日志': 'Sub-user Login Logs',
'确定删除这个 API Key 吗?': 'Delete this API Key?',
'管理外部调用凭据、权限范围与平台 API 文档': 'Manage external credentials, permission scopes, and platform API docs',
'新的 API Key 已生成': 'New API Key generated',
'已复制': 'Copied',
'加载中...': 'Loading...',
'暂无 API Key': 'No API Keys',
'权限': 'Permissions',
'限制': 'Limits',
'最后使用': 'Last Used',
'已禁用': 'Disabled',
'不限 IP': 'Any IP',
'从未使用': 'Never used',
'API 文档': 'API Docs',
'查看使用范例': 'View examples',
'Python 使用范例': 'Python example',
'编辑 API Key': 'Edit API Key',
'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts',
'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB',
'***60秒有效票据***': '***60-second valid ticket***',
'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".',
'该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.',
'样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.',
'编辑月流量限制': 'Edit Monthly Traffic Limit',
'流量统计模式': 'Traffic Accounting Mode',
'双向合并统计': 'Combined In+Out',
'入站/出站分开统计': 'Separate Inbound/Outbound',
'月流量上限 (GB0=不限制)': 'Monthly traffic limit (GB, 0=unlimited)',
'入站上限 (GB0=不限制)': 'Inbound limit (GB, 0=unlimited)',
'出站上限 (GB0=不限制)': 'Outbound limit (GB, 0=unlimited)',
'请输入 8-64 位,至少包含字母和数字': 'Enter 8-64 characters, including at least letters and numbers',
'Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。': 'Changing the root SSH password for Linux LXC/KVM usually does not require a restart. KVM requires the VM to be running and guest agent or SSH to be available.',
'退出全屏': 'Exit Fullscreen',
'全屏显示': 'Fullscreen',
'全屏': 'Fullscreen',
'修改': 'Change',
'关闭定时': 'Disable Schedule',
'执行时间': 'Run Time',
'NAT 端口管理': 'NAT Port Management',
'添加映射': 'Add Mapping',
'端口配额:': 'Port quota:',
'已达到管理员分配的 NAT 端口配额': 'The NAT port quota assigned by the administrator has been reached',
'修改端口映射': 'Edit Port Mapping',
'重装系统会删除容器内所有数据,请谨慎操作。': 'Reinstalling the OS will delete all data in the container. Proceed carefully.',
'选择新系统模板': 'Select New System Template',
'确认重装': 'Confirm Reinstall',
'当前:': 'Current:',
'新到期日期(留空为长期有效)': 'New expiration date (leave blank for no expiration)',
'vCPU 核数': 'vCPU Cores',
'网络速率 (Mbps0=不限制)': 'Network speed (Mbps, 0=unlimited)',
'IO 速度 (MB/s0=不限制)': 'IO speed (MB/s, 0=unlimited)',
'磁盘容量不支持动态修改。修改后运行中的容器会立即应用新的 cgroup 限制。': 'Disk capacity cannot be changed dynamically. Running containers apply the new cgroup limits immediately.',
'恢复': 'Restore',
'全部 (ALL)': 'All (ALL)',
'外部端口': 'External Port',
'默认同内部': 'Same as internal by default',
'随机空闲端口': 'Random Free Port',
'随机': 'Random',
'内部端口': 'Internal Port',
'例如 80': 'e.g. 80',
'暂无端口映射': 'No port mappings',
'默认 SSH 映射不能删除': 'Default SSH mapping cannot be deleted',
'入站 (RX)': 'Inbound (RX)',
'(不限制)': '(unlimited)',
'出站 (TX)': 'Outbound (TX)',
'已用': 'Used',
'重置': 'Reset',
'执行中...': 'Running...',
'点击': 'Click',
'开始': 'Start',
'没有匹配的容器': 'No matching containers',
'显示': 'Showing',
'初始化失败': 'Initialization failed',
'初始化完成': 'Initialization complete',
'排队等待': 'Queued',
'处理中': 'Processing',
'未知系统': 'Unknown system',
'处理失败': 'Failed',
'暂无任务': 'No tasks',
'取消任务': 'Cancel Task',
'硬件、网络、磁盘健康与运行环境探测报告': 'Hardware, network, disk health, and runtime environment report',
'运行状态': 'Runtime Status',
'未检测到内存条明细,可能缺少 dmidecode 或权限受限': 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
'插槽': 'Slot',
'频率': 'Frequency',
'厂商': 'Vendor',
'型号/序列号': 'Model / Serial',
'未检测到硬盘': 'No disks detected',
'虚拟磁盘': 'Virtual Disk',
'不支持': 'Unsupported',
'虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
'虚拟Disk,真实 SMART/Lifetime/Power-on数据需在物理宿主机View': 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.',
'型号': 'Model',
'挂载点': 'Mount Point',
'寿命': 'Lifetime',
'通电': 'Power-on',
'读取': 'Reads',
'写入': 'Writes',
'命令数': 'Commands',
'擦写': 'Erase Count',
'未检测到网卡': 'No network interfaces detected',
'未检测到显卡': 'No GPUs detected',
'驱动': 'Driver',
'管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。': 'Manage LXC / KVM system images. Downloaded images can be used to create containers/VMs.',
'已下载': 'Downloaded',
'LXC 容器镜像': 'LXC Container Images',
'KVM 虚拟机镜像': 'KVM VM Images',
'发行版': 'Distribution',
'架构': 'Architecture',
'禁用': 'Disable',
'可用': 'Available',
'未下载': 'Not downloaded',
'中文': 'Chinese',
'宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址': 'NAT4 ports and IPv6 addresses assigned to LXC by the host',
'确认删除容器': 'Delete container',
'剩余地址 / 地址总数 ·': 'Available Addresses / Total Addresses ·',
'结果 ': 'Result ',
'告警列表 (': 'Alert List (',
'当前证书:': 'Current certificate:',
'到期时间:': 'Expires:',
'证书路径:': 'Certificate path:',
'最近错误:': 'Last error:',
'1 天': '1 day',
'3 天': '3 days',
'7 天': '7 days',
'14 天': '14 days',
'10 / 页': '10 / page',
'20 / 页': '20 / page',
'50 / 页': '50 / page',
'全局快照列表,共': 'Global snapshot list, total',
'容器分配的子用户列表,共': 'Sub-user list assigned to containers, total',
}
const artifactPatterns: RegExp[] = [
/Back\s*列表/,
/SearchName、ID、UUID、IP/,
/All(Type|Status|系统)/,
/AutoStop\s*已[开关]/,
/暂\s*(None|无)\s*Security Alerts/,
/Memory\s*使用/,
/网络\s*Traffic/,
/实时\s*Status/,
/Create\s*Time/,
/长期\s*Valid/,
/虚拟Disk/,
/宿主机View/,
/SMART\/Lifetime\/Power-on数据/,
]
const replacements: Array<[RegExp, string]> = [
[/Back\s*列表/g, 'Back to list'],
[/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
[/虚拟\s*Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机\s*View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.'],
[/真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Real SMART, lifetime, and power-on data must be checked on the physical host'],
[/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
[/All\s*类型/g, 'All types'],
[/All\s*系统/g, 'All systems'],
[/All\s*状态/g, 'All statuses'],
[/AllType/g, 'All types'],
[/All系统/g, 'All systems'],
[/AllStatus/g, 'All statuses'],
[/SearchName、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
[/AutoStop\s*已关/g, 'Auto-stop off'],
[/AutoStop\s*已开/g, 'Auto-stop on'],
[/暂\s*None\s*Security Alerts/g, 'No security alerts'],
[/暂\s*无\s*Security Alerts/g, 'No security alerts'],
[/WebVNC\s*初始化失败(.+)$/g, 'WebVNC initialization failed$1'],
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
[/拍摄快照需要先关机,完成后会自动重启容器\s*(.+?)。是否继续?/g, 'Taking a snapshot requires shutdown first. Container $1 will restart automatically afterward. Continue?'],
[/确定删除\s*(.+?)\s*的快照吗?/g, 'Delete snapshot $1?'],
[/确定恢复到\s*(.+?)\s*的快照吗?当前容器数据会被覆盖。/g, 'Restore to snapshot $1? Current container data will be overwritten.'],
[/到期\s*(.+)$/g, 'Expires $1'],
[/支持\s*\((.+?)\)/g, 'Supported ($1)'],
[/下载中\s*(.+)$/g, 'Downloading $1'],
[/结果\s*(.+)$/g, 'Result $1'],
[/磨损\s*(.+)$/g, 'Wear $1'],
[/擦写\s*(.+)$/g, 'Erase $1'],
[/启停\s*(.+)$/g, 'Power cycles $1'],
[/每\s*(.+)$/g, 'Every $1'],
[/已开启,每\s*(.+)$/g, 'Enabled, every $1'],
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
[/(\d+)\s*个前缀,(\d+)\s*个地址已分配/g, '$1 prefixes, $2 addresses assigned'],
[/共\s*(\d+)\s*条/g, 'Total $1'],
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
[/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'],
[/,已选\s*(\d+)\s*个/g, ', selected $1 items'],
[/第\s*(\d+)\/(\d+)\s*页/g, 'Page $1/$2'],
[/显示\s*(\d+)-(\d+)\s*\/\s*(\d+)/g, 'Showing $1-$2 / $3'],
[/显示\s*(\d+)-(\d+),共\s*(\d+)\s*条/g, 'Showing $1-$2 of $3'],
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*条,/g, 'Search "$1" returned $2 results, '],
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '],
[/(\d+)\s*个/g, '$1 items'],
[/(\d+)\s*条/g, '$1 records'],
[/(\d+)\s*核/g, '$1 cores'],
[/(\d+)\s*线程/g, '$1 threads'],
[/已用/g, 'used'],
[/未设置\s*Traffic\s*限制/g, 'No traffic limit set'],
[/Memory\s*使用/g, 'Memory Usage'],
[/网络\s*Traffic/g, 'Network Traffic'],
[/实时\s*Status/g, 'Live Status'],
[/Expiration Time\s*长期\s*Valid/g, 'Expiration Time No expiration'],
[/长期\s*Valid/g, 'No expiration'],
[/Create\s*Time/g, 'Created At'],
[/CPU\s*累计\s*Time/g, 'CPU Total Time'],
[/(\d+(?:\.\d+)?)\s*cores\s*\/\s*(\d+)\s*核/g, '$1 cores / $2 cores'],
[/(\d+)\s*核\/(.+?)\/(\d+)\s*GB/g, '$1 cores / $2 / $3 GB'],
[/(\d+)\s*\/\s*页/g, '$1 / page'],
[/到期时间:/g, 'Expires: '],
[/证书路径:/g, 'Certificate path: '],
[/最近错误:/g, 'Last error: '],
[/当前证书:/g, 'Current certificate: '],
[/第\s*(\d+)\s*页/g, 'Page $1'],
[/入\s*([^/]+)\s*\/\s*出\s*([^]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'],
[/读\s*([^/]+)\s*\/\s*写\s*([^]+),累计\s*([^]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'],
[/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'],
[/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'],
[/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'],
[/暂无可用的\s*(KVM|LXC)\s*系统镜像,请先在「镜像管理」中下载镜像模板。/g, 'No available $1 system images. Download image templates in Images first.'],
[/不能小于\s*(.+)$/g, 'Cannot be less than $1'],
[/不能大于\s*(.+)$/g, 'Cannot be greater than $1'],
[/^(.+?)\s*-\s*操作日志$/g, '$1 - Audit Logs'],
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
[/阶段:(.+)$/g, 'Stage: $1'],
[/\$\{days\}天/g, '${days} days'],
[/\$\{hours\}小时/g, '${hours} hours'],
[/\$\{hours\}\s*小时/g, '${hours} hours'],
[/\$\{Math\.floor\(diff \/ 60000\)\}分钟/g, '${Math.floor(diff / 60000)} minutes'],
[/(\d+)分钟/g, '$1 minutes'],
[/(\d+)小时/g, '$1 hours'],
[/(\d+)\s*周/g, '$1 weeks'],
[/(\d+)天/g, '$1 days'],
[/确认删除容器\s*(.+?)\s*的快照吗?此操作不可恢复。/g, 'Delete the snapshot for container $1? This cannot be undone.'],
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
[/容器\s*(.+?)\s*已开机/g, 'Container $1 started'],
[/容器\s*(.+?)\s*已关机/g, 'Container $1 stopped'],
[/容器\s*(.+?)\s*已重启/g, 'Container $1 restarted'],
]
export function translateText(value: string): string {
if (!shouldTranslateText(value)) return value
const leading = value.match(/^\s*/)?.[0] || ''
const trailing = value.match(/\s*$/)?.[0] || ''
const body = value.trim()
if (!body) return value
if (exact[body]) return leading + exact[body] + trailing
let translated = body
for (const [pattern, replacement] of replacements) {
translated = translated.replace(pattern, replacement)
}
for (const [source, target] of Object.entries(exact).sort((a, b) => b[0].length - a[0].length)) {
translated = translated.split(source).join(target)
}
translated = cleanupTranslatedText(translated)
return leading + translated + trailing
}
export function shouldTranslateText(value: string): boolean {
return /[\u3400-\u9fff]/.test(value) || artifactPatterns.some((pattern) => pattern.test(value))
}
function cleanupTranslatedText(value: string): string {
return value
.replace(/虚拟Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理Host View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
.replace(/Virtual Disk,真实 SMART\/Lifetime\/Power-on数据需在物理宿主机View/g, 'Virtual disk. Real SMART, lifetime, and power-on data must be checked on the physical host.')
.replace(/虚拟\s*Disk/g, 'Virtual disk')
.replace(/宿主机\s*View/g, 'physical host')
.replace(/Back\s*List/g, 'Back to list')
.replace(/Container\s*List/g, 'Container List')
.replace(/Snapshot\s*List/g, 'Snapshot List')
.replace(/All\s*Type/g, 'All types')
.replace(/All\s*Status/g, 'All statuses')
.replace(/All\s*System/g, 'All systems')
.replace(/AutoStop\s*Off/g, 'Auto-stop off')
.replace(/AutoStop\s*On/g, 'Auto-stop on')
.replace(/\s{2,}/g, ' ')
}
+67
View File
@@ -0,0 +1,67 @@
export type SSHAuthMode = 'auto_password' | 'password' | 'key'
export type ReinstallSSHAuthMode = SSHAuthMode | 'keep'
const supportedKeyTypes = new Set([
'ssh-ed25519',
'ssh-rsa',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'sk-ssh-ed25519@openssh.com',
'sk-ecdsa-sha2-nistp256@openssh.com',
])
export function generateSSHPassword() {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
const digits = '23456789'
const symbols = '!@#$%*-_+='
const all = letters + digits + symbols
const pick = (chars: string) => chars[secureRandomInt(chars.length)]
let password = pick(letters) + pick(digits)
while (password.length < 16) password += pick(all)
return secureShuffle(password.split('')).join('')
}
export function sshPasswordError(password: string) {
if (password.length < 8 || password.length > 64) return '密码长度必须为 8-64 位'
if (/\s/.test(password)) return '密码不能包含空白字符'
if (!/[A-Za-z]/.test(password)) return '密码至少需要包含字母'
if (!/\d/.test(password)) return '密码至少需要包含数字'
return ''
}
export function sshPublicKeyError(publicKey: string) {
const key = publicKey.trim()
if (!key) return '请填写 SSH 公钥'
if (key.length > 8192) return 'SSH 公钥长度不能超过 8192 字符'
if (/[\r\n]/.test(key)) return 'SSH 公钥只能填写一行'
const parts = key.split(/\s+/)
if (parts.length < 2 || !supportedKeyTypes.has(parts[0])) return 'SSH 公钥格式不正确'
return ''
}
function secureRandomInt(maxExclusive: number) {
if (!Number.isSafeInteger(maxExclusive) || maxExclusive <= 0) {
throw new Error('invalid random range')
}
const values = new Uint32Array(1)
const maxUint32 = 0x100000000
const limit = Math.floor(maxUint32 / maxExclusive) * maxExclusive
let value = 0
do {
crypto.getRandomValues(values)
value = values[0]
} while (value >= limit)
return value % maxExclusive
}
function secureShuffle<T>(items: T[]) {
const next = [...items]
for (let i = next.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1)
const value = next[i]
next[i] = next[j]
next[j] = value
}
return next
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 179 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

+332 -41
View File
@@ -11,8 +11,218 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
normalize_lang() {
lang="$1"
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
en|en_*|en-*) echo en ;;
zh|zh_*|zh-*) echo zh ;;
*) echo "" ;;
esac
}
detect_lang() {
normalized="$(normalize_lang "${CLICD_LANG:-}")"
if [ -n "$normalized" ]; then
echo "$normalized"
return
fi
normalized="$(normalize_lang "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}")"
if [ -n "$normalized" ]; then
echo "$normalized"
return
fi
echo zh
}
choose_language() {
normalized="$(normalize_lang "${CLICD_LANG:-}")"
if [ -n "$normalized" ]; then
echo "$normalized"
return
fi
case "$ACTION" in
-h|--help|help)
detect_lang
return
;;
esac
if [ -t 0 ]; then
{
echo "====================================="
echo " 请选择语言 / Select language"
echo "====================================="
echo " 1) 简体中文"
echo " 2) English"
printf " 请输入 1/2 [1]: "
} >&2
IFS= read -r answer || answer=""
case "$answer" in
2) echo en ;;
*) echo zh ;;
esac
return
fi
if [ -r /dev/tty ] && [ -w /dev/tty ] && { printf '' > /dev/tty; } 2>/dev/null; then
{
echo "====================================="
echo " 请选择语言 / Select language"
echo "====================================="
echo " 1) 简体中文"
echo " 2) English"
printf " 请输入 1/2 [1]: "
} > /dev/tty
IFS= read -r answer < /dev/tty || answer=""
case "$answer" in
2) echo en ;;
*) echo zh ;;
esac
return
fi
detect_lang
}
CLICD_LANG_DETECTED="$(choose_language)"
export CLICD_LANG="$CLICD_LANG_DETECTED"
tr_msg() {
msg="$*"
[ "$CLICD_LANG_DETECTED" = "en" ] || { printf '%s' "$msg"; return; }
msg="$(printf '%s' "$msg" | sed \
-e 's/中文安装\/卸载脚本/Installer\/Uninstaller/g' \
-e 's/警告/Warning/g' \
-e 's/错误/Error/g' \
-e 's/安装\/卸载未完成。请查看日志:/Install\/uninstall did not complete. Check log: /g' \
-e 's/如果你确认这是程序问题,请提交 issue/If this looks like a CLICD bug, please open an issue: /g' \
-e 's/开始:/Starting: /g' \
-e 's/完成:/Completed: /g' \
-e 's/步骤失败:/Step failed: /g' \
-e 's/退出码:/exit code: /g' \
-e 's/最近 80 行日志:/Last 80 log lines: /g' \
-e 's/请将上述日志和系统信息提交到:/Please submit the log above and system info to: /g' \
-e 's/系统检测:/System check: /g' \
-e 's/当前安装包仅支持/This installer only supports/g' \
-e 's/当前架构:/current architecture: /g' \
-e 's/未检测到 systemd 或 OpenRC,无法安装服务。/systemd or OpenRC was not detected; cannot install service./g' \
-e 's/暂不支持当前 Linux 发行版:/Unsupported Linux distribution: /g' \
-e 's/请提交 issue 并附上/Please open an issue with/g' \
-e 's/发行版/Distribution/g' \
-e 's/不在主要支持列表,将按检测到的软件包管理器尝试安装。/is not in the primary support list; trying the detected package manager./g' \
-e 's/存储检测:/Storage check: /g' \
-e 's/根文件系统=/root filesystem=/g' \
-e 's/可用空间=/available space=/g' \
-e 's/根分区可用空间低于 5GB,下载镜像或创建 KVM\/LXC 时可能失败。/Root partition has less than 5GB available; image downloads or KVM\/LXC creation may fail./g' \
-e 's/请使用 root 权限运行:/Run as root: /g' \
-e 's/或执行:/Or run: /g' \
-e 's/卸载:/Uninstall: /g' \
-e 's/问题反馈:/Issues: /g' \
-e 's/日志文件:/Log file: /g' \
-e 's/仓库地址:/Repository: /g' \
-e 's/未知操作:/Unknown action: /g' \
-e 's/卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC\/KVM 实例和缓存数据。/Uninstall will stop and remove the CLICD service, configuration database, CLICD-created LXC\/KVM instances, and cached data./g' \
-e 's/为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-\* 下载临时容器和 vm-数字 的 KVM 域。/To avoid deleting production data, the script only removes LXC containers named ct-NUMBER, temporary clicd-img-dl-* download containers, and KVM domains named vm-NUMBER./g' \
-e 's/如需确认卸载,请输入:YES/Type YES to confirm uninstall:/g' \
-e 's/已取消卸载。如需非交互卸载,请设置 CLICD_UNINSTALL_CONFIRM=1。/Uninstall cancelled. For non-interactive uninstall, set CLICD_UNINSTALL_CONFIRM=1./g' \
-e 's/正在卸载 CLICD.../Uninstalling CLICD.../g' \
-e 's/正在删除 CLICD 创建的 LXC 容器(\/var\/lib\/lxc\/ct-数字).../Removing CLICD-created LXC containers (\/var\/lib\/lxc\/ct-NUMBER).../g' \
-e 's/保留 \/root\/clicd-backups,避免误删部署\/回滚备份。确认不需要后可手动删除。/Keeping \/root\/clicd-backups to avoid deleting deployment\/rollback backups. Remove it manually if no longer needed./g' \
-e 's/CLICD 卸载完成/CLICD uninstall complete/g' \
-e 's/已删除服务、二进制、SQLite\/配置数据、CLICD LXC\/KVM 实例、/Removed service, binary, SQLite\/config data, CLICD LXC\/KVM instances,/g' \
-e 's/CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。/CLICD image cache, firewall rules, host hooks, quota records, and temporary files./g' \
-e 's/已保留 \/root\/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份\/共享镜像。/Kept \/root\/clicd-backups and non-CLICD global LXC cache to avoid deleting production backups\/shared images./g' \
-e 's/日志:/Log: /g' \
-e 's/兼容性检查/Compatibility check/g' \
-e 's/存储环境检查/Storage environment check/g' \
-e 's/安装系统依赖/Install system dependencies/g' \
-e 's/配置内核网络参数/Configure kernel networking/g' \
-e 's/配置运行时服务/Configure runtime services/g' \
-e 's/配置 libvirt default NAT 网络/Configure libvirt default NAT network/g' \
-e 's/配置 UID\/GID 映射/Configure UID\/GID mapping/g' \
-e 's/配置 LXC 存储权限/Configure LXC storage permissions/g' \
-e 's/检查 project quota/Check project quota/g' \
-e 's/下载发行版包/Download release package/g' \
-e 's/安装 CLICD 二进制/Install CLICD binary/g' \
-e 's/安装并启动 CLICD 服务/Install and start CLICD service/g' \
-e 's/已写入面板语言:/Panel language saved: /g' \
-e 's/写入面板语言/Save panel language/g' \
-e 's/面板语言写入失败,请安装后在面板右下角手动切换。/Failed to save panel language. Please switch it manually from the lower-left panel control after installation./g' \
-e 's/正在使用 apk 安装依赖.../Installing dependencies with apk.../g' \
-e 's/正在使用 apt 安装依赖.../Installing dependencies with apt.../g' \
-e 's/正在使用 dnf 安装依赖.../Installing dependencies with dnf.../g' \
-e 's/正在使用 yum 安装依赖.../Installing dependencies with yum.../g' \
-e 's/依赖安装后仍未找到/Still missing after dependency installation: /g' \
-e 's/,请检查 LXC 软件源\/安装日志。/. Check the LXC repository\/install log./g' \
-e 's/,请检查系统网络工具包。/. Check the system network tools package./g' \
-e 's/,请检查 iproute2 安装。/. Check the iproute2 installation./g' \
-e 's/,请检查 libvirt-client\/libvirt-clients 安装。/. Check the libvirt-client\/libvirt-clients installation./g' \
-e 's/,请检查 qemu-utils\/qemu-img 安装。/. Check the qemu-utils\/qemu-img installation./g' \
-e 's/,请检查 cloud-image-utils\/cloud-utils 安装。/. Check the cloud-image-utils\/cloud-utils installation./g' \
-e 's/可选依赖未安装:/Optional dependency was not installed: /g' \
-e 's/当前系统 /Current system /g' \
-e 's/ 未找到 dnf\/yum,无法安装依赖。/ does not have dnf\/yum; cannot install dependencies./g' \
-e 's/Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。/Windows KVM initialization requires one of genisoimage, mkisofs, or xorriso./g' \
-e 's/未检测到 \/dev\/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。/\/dev\/kvm was not detected. LXC is available, but KVM VMs require hardware virtualization or nested virtualization./g' \
-e 's/正在启用内核转发配置.../Enabling kernel forwarding settings.../g' \
-e 's/正在配置 LXC 和 KVM 服务.../Configuring LXC and KVM services.../g' \
-e 's/服务 /Service /g' \
-e 's/ 启动失败,将继续安装并在运行时降级处理。/ failed to start; installation will continue and runtime fallback will be used./g' \
-e 's/未检测到 systemd 单元 /systemd unit was not detected: /g' \
-e 's/,跳过。/; skipped./g' \
-e 's/检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。/Detected the legacy libvirt libvirtd service; using libvirtd mode./g' \
-e 's/未检测到支持的服务管理器。CLICD 当前支持 systemd 或 OpenRC。/No supported service manager was detected. CLICD currently supports systemd or OpenRC./g' \
-e 's/正在检查 libvirt default NAT 网络.../Checking libvirt default NAT network.../g' \
-e 's/未找到 virsh,跳过 libvirt default NAT 网络检查。/virsh was not found; skipping the libvirt default NAT network check./g' \
-e 's/libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。/libvirt default network is still not active. Run virsh net-info default for details./g' \
-e 's/libvirt default NAT 网络已启用。/libvirt default NAT network is enabled./g' \
-e 's/正在配置 subordinate UID\/GID 范围.../Configuring subordinate UID\/GID ranges.../g' \
-e 's/根文件系统 /Root filesystem /g' \
-e 's/ 不需要\/不适合自动启用 ext4 project quotaCLICD 将使用兼容磁盘限制模式。/ does not need or is not suitable for automatic ext4 project quota; CLICD will use compatible disk limit mode./g' \
-e 's/ 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。/ is not supported for automatic project quota; CLICD will use compatible disk limit mode./g' \
-e 's/根分区来源 /Root partition source /g' \
-e 's/ 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。/ is not a block device; skipping automatic project quota check and using compatible disk limit mode./g' \
-e 's/未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。/tune2fs was not found; skipping project quota check and using compatible disk limit mode./g' \
-e 's/检测到 ext4 project quota 已可用。/ext4 project quota is already available./g' \
-e 's/ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。/ext4 project quota is not enabled; CLICD will automatically fall back to loopback image disk limit mode./g' \
-e 's/当前目录未找到 clicd 二进制,将下载发行版包。/No local clicd binary found; downloading release package./g' \
-e 's/正在下载发行版包:/Downloading release package: /g' \
-e 's/下载发行版包需要 curl 或 wget。/Downloading the release package requires curl or wget./g' \
-e 's/下载的发行版包中未找到 clicd 二进制。/The downloaded release package does not contain the clicd binary./g' \
-e 's/未找到 clicd 二进制,安装无法继续。/clicd binary was not found; installation cannot continue./g' \
-e 's/已安装二进制:/Installed binary: /g' \
-e 's/正在安装 CLICD 服务.../Installing CLICD service.../g' \
-e 's/正在清理 CLICD 防火墙和网桥规则.../Cleaning CLICD firewall and bridge rules.../g' \
-e 's/已清理 /Cleaned /g' \
-e 's/ 中的 CLICD 配额记录/ CLICD quota records/g' \
-e 's/跳过当前安装目录 /Skipping current installation directory /g' \
-e 's/,避免中断后续安装步骤。/ to avoid interrupting later installation steps./g' \
-e 's/ 被占用,终止占用进程后重试删除.../ is busy; killing occupying processes and retrying removal.../g' \
-e 's/正在删除 CLICD 使用的 LXC 镜像缓存.../Removing LXC image cache used by CLICD.../g' \
-e 's/正在删除 KVM 虚拟机域 /Removing KVM VM domain /g' \
-e 's/正在销毁 CLICD 创建的 KVM 虚拟机.../Destroying CLICD-created KVM VMs.../g' \
-e 's/检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default\/virbr0。/Non-CLICD VMs are still using the libvirt default network, so default\/virbr0 has been kept./g' \
-e 's/正在删除 CLICD 创建的 libvirt default NAT 网络.../Removing CLICD-created libvirt default NAT network.../g' \
-e 's/已删除 /Removed /g' \
-e 's/检测到 /Detected /g' \
-e 's/安装完成/Installation complete/g' \
-e 's/Web 面板/Web panel/g' \
-e 's/二进制/Binary/g' \
-e 's/安装日志/Install log/g' \
-e 's/服务/Service/g' \
-e 's/运行日志/Runtime log/g' \
-e 's/首次安装时的初始账号信息:/Initial account information for first installation:/g' \
-e 's/如果没有显示密码,说明服务器已有/If no password is shown, the server already has/g' \
-e 's/已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。/Existing admin passwords are stored with bcrypt and cannot be recovered. Change it in the panel or reset configuration./g' \
-e 's//: /g' \
-e 's//, /g' \
-e 's/。/./g' \
-e 's//(/g' \
-e 's//)/g' \
-e 's/、/, /g' \
)"
printf '%s' "$msg"
}
echo "====================================="
echo " CLICD 中文安装/卸载脚本"
echo " $(tr_msg "CLICD 中文安装/卸载脚本")"
echo "====================================="
write_log_file() {
@@ -22,21 +232,26 @@ write_log_file() {
}
log() {
echo "[clicd] $*"
write_log_file "[clicd] $*"
msg="$(tr_msg "$*")"
echo "[clicd] $msg"
write_log_file "[clicd] $msg"
}
warn() {
echo "[clicd][警告] $*" >&2
write_log_file "[警告] $*"
label="$(tr_msg "警告")"
msg="$(tr_msg "$*")"
echo "[clicd][$label] $msg" >&2
write_log_file "[$label] $msg"
}
die() {
echo "[clicd][错误] $*" >&2
write_log_file "[错误] $*"
label="$(tr_msg "错误")"
msg="$(tr_msg "$*")"
echo "[clicd][$label] $msg" >&2
write_log_file "[$label] $msg"
echo "" >&2
echo "安装/卸载未完成。请查看日志:$LOG_FILE" >&2
echo "如果你确认这是程序问题,请提交 issue:$ISSUE_URL" >&2
echo "$(tr_msg "安装/卸载未完成。请查看日志:")$LOG_FILE" >&2
echo "$(tr_msg "如果你确认这是程序问题,请提交 issue:")$ISSUE_URL" >&2
exit 1
}
@@ -62,11 +277,11 @@ run_step() {
fi
rc="$?"
echo "" >&2
echo "[clicd][错误] 步骤失败:$step_name,退出码:$rc" >&2
echo "[clicd][错误] 最近 80 行日志:$LOG_FILE" >&2
echo "[clicd][$(tr_msg "错误")] $(tr_msg "步骤失败:")$(tr_msg "$step_name")$(tr_msg "")$(tr_msg "退出码:")$rc" >&2
echo "[clicd][$(tr_msg "错误")] $(tr_msg "最近 80 行日志:")$LOG_FILE" >&2
tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
echo "" >&2
echo "请将上述日志和系统信息提交到:$ISSUE_URL" >&2
echo "$(tr_msg "请将上述日志和系统信息提交到:")$ISSUE_URL" >&2
exit "$rc"
}
@@ -104,10 +319,10 @@ check_storage_compatibility() {
}
if [ "$(id -u)" -ne 0 ]; then
echo "请使用 root 权限运行:sudo ./install.sh"
echo "或执行:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
echo "卸载:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
echo "问题反馈:$ISSUE_URL"
echo "$(tr_msg "请使用 root 权限运行:")sudo ./install.sh"
echo "$(tr_msg "或执行:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
echo "$(tr_msg "卸载:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
echo "$(tr_msg "问题反馈:")$ISSUE_URL"
exit 1
fi
@@ -125,6 +340,28 @@ if [ -r /etc/os-release ]; then
fi
usage() {
if [ "$CLICD_LANG_DETECTED" = "en" ]; then
cat << EOF
Usage:
./install.sh Install or upgrade CLICD
./install.sh uninstall Uninstall CLICD (removes containers, VMs, image cache, and config data)
Environment variables:
CLICD_REPO=owner/repo Default: ${REPO}
CLICD_VERSION=latest|v1.0.0 Default: latest
CLICD_LANG=en|zh Default: auto
CLICD_LOG_FILE=/path/file.log Default: ${LOG_FILE}
Examples:
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes
Log: ${LOG_FILE}
Issues: ${ISSUE_URL}
EOF
return
fi
cat << EOF
用法:
./install.sh 安装或升级 CLICD
@@ -133,6 +370,7 @@ usage() {
环境变量:
CLICD_REPO=owner/repo 默认:${REPO}
CLICD_VERSION=latest|v1.0.0 默认:latest
CLICD_LANG=en|zh 默认:自动检测
CLICD_LOG_FILE=/path/file.log 默认:${LOG_FILE}
示例:
@@ -598,9 +836,9 @@ confirm_uninstall() {
return
fi
echo ""
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2
echo "如需确认卸载,请输入:YES" >&2
echo "[clicd][$(tr_msg "警告")] $(tr_msg "卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。")" >&2
echo "[clicd][$(tr_msg "警告")] $(tr_msg "为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。")" >&2
echo "$(tr_msg "如需确认卸载,请输入:YES")" >&2
if [ -r /dev/tty ]; then
IFS= read -r answer < /dev/tty
elif [ -t 0 ]; then
@@ -667,13 +905,13 @@ uninstall_clicd() {
echo ""
echo "====================================="
echo " CLICD 卸载完成"
echo " $(tr_msg "CLICD 卸载完成")"
echo "====================================="
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。"
echo " 日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
echo " $(tr_msg "已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、")"
echo " $(tr_msg "CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。")"
echo " $(tr_msg "已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。")"
echo " $(tr_msg "日志:")$LOG_FILE"
echo " $(tr_msg "问题反馈:")$ISSUE_URL"
echo "====================================="
}
@@ -720,7 +958,7 @@ install_apk() {
libvirt-client \
libvirt-qemu
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso; do
for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -749,12 +987,14 @@ install_apt() {
xfsprogs \
dnsmasq-base \
qemu-kvm \
qemu-system-x86 \
qemu-utils \
libvirt-daemon-system \
libvirt-clients \
cloud-image-utils \
genisoimage \
xorriso \
smartmontools \
virtinst \
ovmf
}
@@ -802,7 +1042,7 @@ install_dnf() {
cloud-utils \
genisoimage
for pkg in lxcfs xorriso edk2-ovmf; do
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -837,7 +1077,7 @@ install_yum() {
cloud-utils \
genisoimage
for pkg in lxcfs xorriso edk2-ovmf; do
for pkg in lxcfs xorriso edk2-ovmf smartmontools; do
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
done
}
@@ -1279,33 +1519,83 @@ install_service() {
fi
}
set_panel_language() {
lang="$CLICD_LANG_DETECTED"
db="/root/.clicd/config.db"
if [ "$lang" != "zh" ] && [ "$lang" != "en" ]; then
lang="zh"
fi
saved=0
i=0
while [ ! -f "$db" ] && [ "$i" -lt 20 ]; do
i=$((i + 1))
sleep 1
done
if [ -f "$db" ] && has_cmd python3; then
CLICD_PANEL_LANG="$lang" CLICD_DB="$db" python3 - <<'PY' >/dev/null 2>&1 && saved=1 || saved=0
import os
import sqlite3
db = os.environ["CLICD_DB"]
lang = os.environ["CLICD_PANEL_LANG"]
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
conn.execute("INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', ?)", (lang,))
conn.commit()
conn.close()
PY
fi
if [ "$saved" != "1" ] && [ -f "$db" ] && has_cmd sqlite3; then
sqlite3 "$db" "CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', '$lang');" >/dev/null 2>&1 && saved=1 || saved=0
fi
if [ "$saved" != "1" ] && has_cmd curl; then
curl -k -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "https://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || \
curl -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "http://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || saved=0
fi
if [ "$saved" = "1" ]; then
log "已写入面板语言:$lang"
if has_cmd systemctl && systemctl is-active clicd >/dev/null 2>&1; then
systemctl restart clicd >/dev/null 2>&1 || true
elif has_cmd rc-service; then
rc-service clicd restart >/dev/null 2>&1 || true
fi
else
warn "面板语言写入失败,请安装后在面板右下角手动切换。"
fi
}
print_summary() {
echo ""
echo "====================================="
echo " 安装完成"
echo " $(tr_msg "安装完成")"
echo "====================================="
echo " Web 面板:http://YOUR_SERVER_IP:8999"
echo " 二进制:/usr/local/bin/clicd"
echo " 安装日志:$LOG_FILE"
echo " 问题反馈:$ISSUE_URL"
echo " $(tr_msg "Web 面板:")http://YOUR_SERVER_IP:8999"
echo " $(tr_msg "二进制:")/usr/local/bin/clicd"
echo " $(tr_msg "安装日志:")$LOG_FILE"
echo " $(tr_msg "问题反馈:")$ISSUE_URL"
if is_systemd; then
echo " 服务:systemctl {start|stop|restart|status} clicd"
echo " 运行日志:journalctl -u clicd -f"
echo " $(tr_msg "服务:")systemctl {start|stop|restart|status} clicd"
echo " $(tr_msg "运行日志:")journalctl -u clicd -f"
elif is_openrc; then
echo " 服务:rc-service clicd {start|stop|restart|status}"
echo " 运行日志:tail -f /var/log/clicd.log /var/log/clicd.err"
echo " $(tr_msg "服务:")rc-service clicd {start|stop|restart|status}"
echo " $(tr_msg "运行日志:")tail -f /var/log/clicd.log /var/log/clicd.err"
fi
echo "====================================="
echo ""
echo "首次安装时的初始账号信息:"
echo "$(tr_msg "首次安装时的初始账号信息:")"
if is_systemd; then
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
else
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
fi
echo ""
echo "如果没有显示密码,说明服务器已有 /root/.clicd/config.db"
echo "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。"
echo "$(tr_msg "如果没有显示密码,说明服务器已有") /root/.clicd/config.db."
echo "$(tr_msg "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。")"
}
run_step "兼容性检查" check_os_compatibility
@@ -1315,10 +1605,11 @@ run_step "配置内核网络参数" configure_kernel_networking
run_step "配置运行时服务" setup_runtime_services
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
run_step "配置 UID/GID 映射" setup_subids
run_step "Configure LXC storage permissions" configure_lxc_storage_access
run_step "配置 LXC 存储权限" configure_lxc_storage_access
run_step "检查 project quota" try_enable_project_quota
run_step "下载发行版包" download_release_if_needed
run_step "安装 CLICD 二进制" install_binary
run_step "安装并启动 CLICD 服务" install_service
run_step "写入面板语言" set_panel_language
sleep 2
print_summary